> ## Documentation Index
> Fetch the complete documentation index at: https://docs.causeloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Local stack as a deployment-parity tool

> Why infra/docker-compose.product.yml exists for operations, not just onboarding: the cloud-equivalent mapping, loopback-only ports, volume lifecycle, and backup rehearsal.

[Local product stack](/onboarding/local-product-stack) documents `infra/docker-compose.product.yml` from a developer's point of view — what to run, what each service does, how readiness works. This page covers the same stack from an operations point of view: it is the only environment where you can rehearse a backup/restore cycle, inspect object-store versioning, or reproduce a production incident without touching a cloud account. Read both; this one assumes you already know the service list and readiness gate from the onboarding page.

## Why this stack, not a shared cloud sandbox

`docs/CLOUD_DEPLOYMENT.md` states the goal directly: the same two container images (this repo's `Dockerfile` and `../frontend/Dockerfile`) run locally, on AWS, and on Azure. `docker-compose.product.yml` builds those exact images rather than approximating them, so a behavior you reproduce locally — a readiness failure, a worker retry, a migration ordering bug — is the same behavior a cloud deployment would show, not an artifact of a simplified dev harness. This is why the compose file is called out as an operations tool and not just a getting-started convenience: it is the cheapest, fastest place to rehearse a production failure mode.

## Service-to-cloud-equivalent mapping

This is the same mapping documented in `docs/LOCAL_SETUP.md` and `docs/CLOUD_DEPLOYMENT.md`, restated here as the reference table for operations work:

| Local service                           |         Host port | AWS equivalent                                             | Azure equivalent                                |
| --------------------------------------- | ----------------: | ---------------------------------------------------------- | ----------------------------------------------- |
| `frontend` (Next.js)                    |           `13000` | ECS/Fargate service behind an internet-facing ALB          | Container Apps (external ingress)               |
| `api` (FastAPI product surface)         |           `18000` | Private ECS/Fargate service, resolved by service discovery | Container Apps (internal-only)                  |
| `postgres` (pgvector/pgvector:pg16)     |           `55433` | RDS PostgreSQL 16                                          | PostgreSQL Flexible Server                      |
| `redis`                                 |           `16379` | ElastiCache (TLS + AUTH token)                             | Azure Managed Redis (classic protocol)          |
| `minio` (API / console)                 | `19000` / `19001` | S3                                                         | Blob Storage                                    |
| `mailpit` (SMTP / UI)                   | `11025` / `18025` | Amazon SES                                                 | Azure Communication Services Email              |
| `migrate` (one-off)                     |                 — | One-off ECS task (`run-migration.sh`)                      | One-off Container Apps Job                      |
| `local-admin` (one-off)                 |                 — | One-off ECS task (`run-admin-bootstrap.sh`)                | One-off Container Apps Job                      |
| `product-worker` / `provisioner-worker` |                 — | Two ECS services, distinct IAM execution/task roles        | Two Container Apps, distinct managed identities |

Every override for the ports above is an environment variable: `CAUSELOOP_FRONTEND_PORT`, `CAUSELOOP_API_PORT`, `CAUSELOOP_POSTGRES_PORT`, `CAUSELOOP_REDIS_PORT`, `CAUSELOOP_MINIO_PORT`, `CAUSELOOP_MINIO_CONSOLE_PORT`, `CAUSELOOP_MAILPIT_SMTP_PORT`, `CAUSELOOP_MAILPIT_PORT`. These ports are intentionally different from the historical research stack's ports (`infra/docker-compose.yml`) so both can run on one machine at once.

## Why MinIO and Mailpit are local-only emulators, not deployment components

Two of the eleven services in the compose file have no cloud counterpart in the deployment sense — they exist purely so a developer can observe behavior that, in the cloud, happens inside a managed provider you can't easily inspect:

* **MinIO** is an S3-API-compatible object store. Local code talks to it through `CAUSELOOP_OBJECT_PROVIDER: s3` and `CAUSELOOP_OBJECT_ENDPOINT: http://minio:9000` — the same S3 SDK calls a real AWS deployment makes, just pointed at a local endpoint. AWS uses native S3 directly; Azure uses Blob Storage directly. Neither cloud template runs MinIO or anything like it. The only reason MinIO exists is so you can open its console at `127.0.0.1:19001` (credentials `causeloop` / `causeloop-dev`, local-only) and see object versions, retention, and the effect of the `minio-init` bootstrap job with your own eyes.
* **Mailpit** captures outbound SMTP locally instead of sending it. `docs/LOCAL_SETUP.md` is explicit that Mailpit is an ephemeral provider sink, not application state: the durable record of an invitation or password-reset is the encrypted `control.platform_jobs` row the API commits atomically with the auth-state change, not the message sitting in Mailpit's UI. Production rejects the `local-smtp` provider outright and never exposes a mailbox UI or logs a token — Mailpit's entire reason for existing is to let you read a message you can never read in a real environment.

Of the two, only Mailpit runs with hardened settings appropriate to a component that only exists for local inspection: it runs as a non-root, read-only container (`user: "65532:65532"`, `read_only: true`, `security_opt: no-new-privileges:true`) with a `tmpfs` mount for its own database file. MinIO runs with the image's own defaults — no `user`, `read_only`, or `security_opt` override in the compose file.

## Loopback-only port publishing

Every port `docker-compose.product.yml` publishes is bound to `127.0.0.1`, not `0.0.0.0`:

```yaml theme={null}
ports:
  - "127.0.0.1:${CAUSELOOP_API_PORT:-18000}:8000"
```

This is deliberate and matches the cloud posture it rehearses: in every cloud environment the API is private (reachable only from the frontend, over a private network path), and only the frontend is public. Binding every local port to loopback means a developer's machine can't accidentally expose the API, Postgres, Redis, or MinIO to the local network the way `0.0.0.0` publishing would — the same "API is never directly reachable" invariant holds locally and in the cloud, just enforced by loopback binding instead of a VPC/VNet security group.

## Volume lifecycle

The stack's state lives in three named (not bind-mounted) Docker volumes:

```yaml theme={null}
volumes:
  product_postgres:
  product_redis:
  product_minio:
```

Container disk itself is never an application store — anything written outside these three volumes disappears on container recreation, by design (it mirrors Fargate/Container Apps, where the container filesystem is also ephemeral). This has one important operational consequence:

```bash theme={null}
docker compose -f infra/docker-compose.product.yml down
```

`down` removes containers and the network but **preserves** the three named volumes. Every tenant, dataset, checkpoint, and uploaded object you created survives a restart. Only `down -v` deletes them, and there is no confirmation prompt — treat `-v` with the same caution you'd apply to dropping a cloud RDS instance or emptying a versioned S3 bucket.

<Warning>
  On Docker Desktop instances limited to roughly 1 GB of memory, run `docker compose -f infra/docker-compose.product.yml build` and then `up -d` as two separate steps. Starting with `up -d --build` lets the frontend's production build compete with the already-running data plane for memory on constrained hosts.
</Warning>

## Backup/restore rehearsal

`scripts/rehearse_product_backup.sh` is the local stand-in for the AWS and Azure quarterly restore drills described in `docs/CLOUD_OPERATIONS.md` — it's the same underlying discipline (dump, restore into an isolated target, verify, clean up) at a scale you can run before every release instead of once a quarter:

```bash theme={null}
scripts/rehearse_product_backup.sh
```

The script:

1. Runs `pg_dump -U causeloop -d causeloop -Fc` inside the running `postgres` container, writing a custom-format dump to a scratch path.
2. Creates a throwaway database named `causeloop_restore_rehearsal_<pid>` and restores the dump into it with `pg_restore` — never touching the live `causeloop` database.
3. Asserts `control.tenants` resolves inside the restored copy (`to_regclass('control.tenants') IS NOT NULL`), proving the control-plane schema came back intact.
4. Separately confirms MinIO's `causeloop-uploads` bucket still reports object versioning enabled (`mc version info`).
5. Drops the scratch database and deletes the dump file on exit via a trap — this cleanup runs even if an earlier step fails.

A green run here is the local evidence that a real backup/restore/versioning story works before you trust the same discipline against RDS/S3 or Flexible Server/Blob. Run it before writing or trusting a production backup runbook — see [Backup and recovery](/deployment/backup-and-recovery) for the cloud-specific drills this rehearsal stands in for.

## Rehearsing the incident recovery order locally

`docs/CLOUD_OPERATIONS.md` documents a strict startup order for an incident recovery: PostgreSQL first (it's the metadata/business authority), then the referenced object versions, then the provisioner, then the product worker, then the API, then the frontend — with readiness required and any queued/running jobs reconciled before traffic reopens. The local stack's own `depends_on: condition:` graph is a runnable version of exactly that order:

```mermaid theme={null}
flowchart TD
    PG[postgres] -->|healthy| MIG[migrate]
    MIG -->|completed| PW[product-worker]
    MIG -->|completed| PRW[provisioner-worker]
    PW -->|started| API[api]
    PRW -->|started| API
    API -->|healthy| FE[frontend]
```

You can rehearse a scale-to-zero-and-recover drill locally with:

```bash theme={null}
docker compose -f infra/docker-compose.product.yml stop api product-worker provisioner-worker frontend
docker compose -f infra/docker-compose.product.yml up -d
```

`postgres`, `redis`, and `minio` keep their volumes and stay up throughout (the data plane is never what you're stopping in this drill); restarting the application-tier services exercises the same `migrate` → workers → `api` → `frontend` ordering a real incident recovery follows, against the same `GET /health/ready` gate described in [Local product stack](/onboarding/local-product-stack). If this doesn't come back healthy locally, don't assume the equivalent AWS/Azure restart order will either.

## Env var parity, not just port parity

Beyond the port table above, the environment variable *names* the stack uses are the same names used in production, only pointed at local endpoints — `CAUSELOOP_OBJECT_PROVIDER: s3`, `CAUSELOOP_OBJECT_BUCKET: causeloop-uploads`, `CAUSELOOP_OBJECT_ENDPOINT: http://minio:9000`, and the standard `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_DEFAULT_REGION` triple (set to the MinIO root credentials and a placeholder region locally). A real AWS deployment sets the same `CAUSELOOP_OBJECT_PROVIDER: s3` and lets `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` come from the task's IAM role instead of literal container environment values, with `CAUSELOOP_OBJECT_ENDPOINT` unset so the AWS SDK's default S3 endpoint resolution applies. This is why the local stack is described as running "the same code," not "equivalent code" — the object-store client in `services/storage/object_store.py` doesn't know or care whether it's talking to MinIO or S3; only the endpoint and credential source differ.

## Related

* [Local product stack](/onboarding/local-product-stack)
* [Cloud environments](/deployment/environments)
* [Backup and recovery](/deployment/backup-and-recovery)
* [Migrations](/deployment/migrations)
