Skip to main content
infra/docker-compose.product.yml runs the same topology locally that is deployed to AWS or Azure: one Next.js frontend, one FastAPI API, two durable workers, and the data-plane services they depend on (PostgreSQL/pgvector, Redis, MinIO, Mailpit). There is no in-memory fallback and no demo-session shortcut — if a dependency isn’t healthy, the API’s readiness check fails and the console shows a loading, locked, or error state instead of synthetic data. This is the mode documented as product in Prerequisites and it is the only mode that reflects the deployed system.
Requires Docker Compose v2 and the frontend checked out as a sibling directory (../frontend relative to this repo) — the frontend service builds that directory’s Dockerfile by relative path. Verify both before starting:

Bring the stack up

smoke_product.py checks GET /health/live and GET /health/ready through the frontend’s /api/backend proxy (matching how the browser reaches the API in every environment — see Proxy and sessions), then confirms /login renders HTML. It takes --frontend-url and --api-url overrides for non-default ports or a non-local target. Open http://127.0.0.1:13000/login. The local-only staff credential is admin@causeloop.local / Local-Admin-Pass1! (seeded by the local-admin service below; committed to this Compose file, never used outside local Compose).

Services

Every long-running service builds from this repo’s Dockerfile (backend image) or ../../frontend/Dockerfile (frontend image), except four data-plane images pulled from upstream registries — postgres, redis, minio, and mailpit — plus the one-shot minio-init bootstrap job (minio/mc). Each entry below states its role, the environment boundary it runs inside, and what a client of the stack should wait on before treating it as up.
The single control-plane + tenant-schema database (causeloop database, causeloop/causeloop-dev superuser credentials used only by migrate and the provisioner’s owner connection). Health gate: pg_isready -U causeloop -d causeloop, checked every 3s. Every other service that touches the database depends on this passing first.
Backs the login rate limiter (CAUSELOOP_RATE_LIMIT_NAMESPACE: causeloop:local) with an append-only file for durability across restarts. Health gate: redis-cli ping. CAUSELOOP_REDIS_REQUIRED: "true" means the API’s readiness check fails without it — this is not an optional cache in product mode.
Local S3-compatible object store standing in for the cloud deployments’ native S3 (AWS) or Blob Storage (Azure) — CAUSELOOP_OBJECT_PROVIDER: s3 points the API and workers at it via CAUSELOOP_OBJECT_ENDPOINT: http://minio:9000. Root credentials causeloop / causeloop-dev (also the MinIO console login). Health gate: an mc ready probe run against its own API port.
One-shot bucket bootstrap: creates causeloop-uploads, sets it private (mc anonymous set none), turns on object versioning, and applies an ILM rule expiring raw objects after CAUSELOOP_OBJECT_RETENTION_DAYS (365 by default — the same retention both cloud Terraform/Bicep templates use) with a 30-day noncurrent-version expiry. Every service that writes or reads objects depends on this completing successfully, not just on minio being healthy.
Ephemeral local SMTP capture UI. Runs as a non-root, read-only container (user: "65532:65532", read_only: true, a tmpfs mount for its own SQLite file) — this is deliberately the most locked-down container in the stack, since it exists only to let a developer read outbound mail, never to hold real application state. Cloud deployments use SES (AWS) or Azure Communication Services Email instead and never expose a mailbox UI.
Runs python scripts/migrate_product.py, which applies the control schema’s Alembic migrations and ensures the app_rw role exists with its control-schema grants, using an owner-level MIGRATIONS_DATABASE_URL. Depends on postgres being healthy. Owns all control-plane DDL; only provisioner-worker also receives this same owner-level connection string (to run per-tenant CREATE SCHEMA and migrations) — the API and product-worker never do.
Runs scripts/create_employee.py --email admin@causeloop.local --name "Local Admin" --role onboarding_admin --password-env CAUSELOOP_BOOTSTRAP_ADMIN_PASSWORD, idempotently upserting the local staff account through the normal app_rw/SQLAlchemy path (APP_DATABASE_URL, pool capped to CAUSELOOP_DB_POOL_MAX_SIZE: "1" since it’s a single short-lived command). The password is injected as an environment variable rather than a CLI argument. Depends on migrate completing successfully.
Runs python scripts/run_pipeline_worker.py, claiming jobs of type materialize_insights, send_email, and train_tenant_model (CAUSELOOP_WORKER_JOB_TYPES) from the control.platform_jobs queue. Receives the same APP_DATABASE_URL (app_rw) as the API — no owner credentials. Depends on Mailpit and Redis being healthy and migrate/minio-init completing. See Workers and jobs for the job state machine.
Runs the same scripts/run_pipeline_worker.py entrypoint but claims only provision_tenant jobs. Receives owner-level MIGRATIONS_DATABASE_URL (to run CREATE SCHEMA and per-tenant Alembic migrations) in addition to APP_DATABASE_URL — a deliberately wider privilege boundary than product-worker, but narrower in every other dimension: it has no Redis connection and no email configuration. It does hold the same MinIO root credentials (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY) as the API and product-worker, with full read/write access — the narrowness here is behavioral, not credential-based: provision_tenant() only ever calls verify_object_storage (services/api/tenancy/provisioner.py) to confirm the bucket is reachable, never to read or write objects. Depends on migrate and minio-init completing.
The FastAPI product surface (services/api/product_app.py) — the allowlisted 41-operation boundary; nothing in services/api/main.py’s legacy research surface is reachable here. Receives APP_DATABASE_URL only, never owner credentials. Depends on local-admin completing and both workers having started (not necessarily healthy — the API’s own /health/ready is what actually gates on worker heartbeats; see below). Health gate: GET /health/ready returning 200, polled every 5s.
The Next.js console, built with NODE_ENV: production and pointed at the API over the Compose network (CAUSELOOP_API_URL: http://api:8000) — the browser never talks to the API directly; every request goes through src/app/api/backend/[...path]/route.ts. Depends on api being healthy, not just started.

Startup order and readiness

Compose’s depends_on: condition: graph enforces this order; a plain docker compose up -d (without --build) or a restart resumes from wherever the volumes already are. GET /health/ready (implemented in services/api/product_app.py) is the real readiness gate. The api container’s own healthcheck polls this endpoint directly, and frontend’s depends_on: api: condition: service_healthy piggybacks on that result — the frontend service defines no healthcheck of its own. smoke_product.py also keys off this endpoint, through the frontend’s /api/backend proxy. It reports ready only when every one of these independently passes:
  • database — a live SELECT 1 against the control schema.
  • worker — both product-worker and provisioner-worker (CAUSELOOP_REQUIRED_WORKERS) have written a control.runtime_heartbeats row within the last CAUSELOOP_WORKER_READY_SECONDS (45s default), each advertising at least the job types product_app.py’s hardcoded REQUIRED_WORKER_JOB_TYPES map requires for it (a superset passes; this is independent of the compose file’s CAUSELOOP_WORKER_JOB_TYPES).
  • object_storage — the configured bucket is reachable.
  • credential_encryption — the Fernet key used to encrypt stored LLM/source credentials is configured (CAUSELOOP_SECRET_PROVIDER: fernet, SOURCE_CREDS_KEY).
  • email_delivery — the configured email provider (local-smtp here) is reachable.
  • redis — a successful PING, required whenever CAUSELOOP_REDIS_REQUIRED is true (always true in this Compose file).
A 503 from /health/ready with one of these false is the first thing to check when the console won’t leave its loading state — it names exactly which dependency is missing, unlike a generic connection-refused error.
On Docker Desktop instances limited to roughly 1 GB of memory, build first and then start, so the frontend’s production build doesn’t compete with the already-running data plane for memory:

Ports and overrides

Every published port is loopback-only (127.0.0.1:<port>:<container-port>) and every one has a CAUSELOOP_*_PORT override, so multiple stacks (or a stack alongside the historical research stack on its own ports) can coexist on one machine. These ports are intentionally different from the historical research stack’s ports (infra/docker-compose.yml) so the two can run side by side. CAUSELOOP_OBJECT_RETENTION_DAYS (default 365) overrides the raw-upload retention minio-init configures, matching the same default both cloud Terraform/Bicep templates ship.

Volumes and data lifetime

These are named (not bind-mounted) volumes; container disk itself is never an application store. Stop the stack with:
down removes containers and the network but preserves these three named volumes — the tenants, datasets, checkpoints, and uploaded objects you created survive a restart. Only add -v (down -v) when you deliberately intend to delete every local tenant, dataset, and object. There is no confirmation prompt.

Useful operations

scripts/rehearse_product_backup.sh runs pg_dump -Fc inside the postgres container, restores it into a throwaway causeloop_restore_rehearsal_<pid> database, asserts control.tenants exists in the restored copy, and separately confirms MinIO’s causeloop-uploads bucket still has object versioning enabled (mc version info). It cleans up the scratch database and dump file on exit via a trap, including on failure. Run it before trusting a backup/restore runbook in a real environment — see Backup and recovery.