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’sDockerfile (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.
postgres — pgvector/pgvector:pg16
postgres — pgvector/pgvector:pg16
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.redis — redis:7.4-alpine
redis — redis:7.4-alpine
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.minio — minio/minio:RELEASE.2025-09-07T16-13-09Z
minio — minio/minio:RELEASE.2025-09-07T16-13-09Z
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.minio-init — minio/mc, restart: no
minio-init — minio/mc, restart: no
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.mailpit — axllent/mailpit:v1.30.0
mailpit — axllent/mailpit:v1.30.0
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.migrate — one-off, restart: no
migrate — one-off, restart: no
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.local-admin — one-off, restart: no
local-admin — one-off, restart: no
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.product-worker — durable, restart: unless-stopped
product-worker — durable, restart: unless-stopped
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.provisioner-worker — durable, restart: unless-stopped
provisioner-worker — durable, restart: unless-stopped
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.api — durable, restart: unless-stopped
api — durable, restart: unless-stopped
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.frontend — durable, restart: unless-stopped
frontend — durable, restart: unless-stopped
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’sdepends_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 1against the control schema. - worker — both
product-workerandprovisioner-worker(CAUSELOOP_REQUIRED_WORKERS) have written acontrol.runtime_heartbeatsrow within the lastCAUSELOOP_WORKER_READY_SECONDS(45s default), each advertising at least the job typesproduct_app.py’s hardcodedREQUIRED_WORKER_JOB_TYPESmap requires for it (a superset passes; this is independent of the compose file’sCAUSELOOP_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-smtphere) is reachable. - redis — a successful
PING, required wheneverCAUSELOOP_REDIS_REQUIREDistrue(always true in this Compose file).
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.
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
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.