Skip to main content
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: 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:
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:
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:
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.
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.

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:
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 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: You can rehearse a scale-to-zero-and-recover drill locally with:
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 → apifrontend ordering a real incident recovery follows, against the same GET /health/ready gate described in 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.