> ## 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 product stack

> Every service in the production-parity Docker Compose stack — role, port, health gate, privilege boundary, and the ops commands to run it.

`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](/onboarding/prerequisites) and it is the only mode that reflects the deployed system.

<Note>
  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:

  ```bash theme={null}
  python3 scripts/check_local_setup.py --mode product
  ```
</Note>

## Bring the stack up

```bash theme={null}
docker compose -f infra/docker-compose.product.yml up -d --build
python3 scripts/smoke_product.py
```

`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](/api-reference/integration/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.

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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](/architecture/workers-and-jobs) for the job state machine.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

## 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.

```mermaid theme={null}
flowchart TD
    PG[postgres] -->|healthy| MIG[migrate]
    MI[minio] -->|healthy| MINIT[minio-init]
    MIG -->|completed| LA[local-admin]
    MIG -->|completed| PW[product-worker]
    MIG -->|completed| PRW[provisioner-worker]
    MINIT -->|completed| PW
    MINIT -->|completed| PRW
    MP[mailpit] -->|healthy| PW
    RD[redis] -->|healthy| PW
    LA -->|completed| API[api]
    PW -->|started| API
    PRW -->|started| API
    API -->|healthy| FE[frontend]
```

`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.

<Warning>
  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:

  ```bash theme={null}
  docker compose -f infra/docker-compose.product.yml build
  docker compose -f infra/docker-compose.product.yml up -d
  ```
</Warning>

## 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.

| Service          | Default host port | Override variable              | Deployment equivalent                         |
| ---------------- | ----------------: | ------------------------------ | --------------------------------------------- |
| Next.js frontend |           `13000` | `CAUSELOOP_FRONTEND_PORT`      | ECS/Fargate or Container Apps                 |
| Product API      |           `18000` | `CAUSELOOP_API_PORT`           | private ECS/Fargate or internal Container App |
| PostgreSQL       |           `55433` | `CAUSELOOP_POSTGRES_PORT`      | RDS PostgreSQL or PostgreSQL Flexible Server  |
| Redis            |           `16379` | `CAUSELOOP_REDIS_PORT`         | ElastiCache or Azure Managed Redis            |
| MinIO API        |           `19000` | `CAUSELOOP_MINIO_PORT`         | S3 or Blob Storage                            |
| MinIO console    |           `19001` | `CAUSELOOP_MINIO_CONSOLE_PORT` | n/a (local inspection only)                   |
| Mailpit SMTP     |           `11025` | `CAUSELOOP_MAILPIT_SMTP_PORT`  | SES or Azure Communication Services Email     |
| Mailpit UI       |           `18025` | `CAUSELOOP_MAILPIT_PORT`       | n/a (local inspection only)                   |

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

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

These are named (not bind-mounted) volumes; container disk itself is never an application store. Stop the stack with:

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

`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

```bash theme={null}
# Container status, including one-off jobs' exit codes
docker compose -f infra/docker-compose.product.yml ps

# Tail a single service
docker compose -f infra/docker-compose.product.yml logs -f api
docker compose -f infra/docker-compose.product.yml logs -f product-worker provisioner-worker

# Validate the Compose file without starting anything
docker compose -f infra/docker-compose.product.yml config

# Rehearse a full backup/restore + object-versioning check against a scratch database
scripts/rehearse_product_backup.sh
```

`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](/deployment/backup-and-recovery).

## Related

* [Prerequisites](/onboarding/prerequisites)
* [Local onboarding walkthrough](/onboarding/local-onboarding-walkthrough)
* [Workers and jobs](/architecture/workers-and-jobs)
* [Backup and recovery](/deployment/backup-and-recovery)
