Skip to main content

Docker Compose

The repo's root docker-compose.yml is the single entry point for self-hosting Clone. Four services, one Postgres volume, and a handful of environment variables.

Bring it up

cp .env.example .env
# Edit .env: POSTGRES_PASSWORD, ANTHROPIC_API_KEY, ELEVENLABS_API_KEY, etc.
docker compose -f docker-compose.local.yml up --build

docker-compose.local.yml builds every service from source on each up and uses HTTP / non-privileged ports (http://localhost:8080). See the repo root README for details.

Postgres data persists in the named volume pgdata and survives container recreates.

Service reference

db: postgres:16-alpine # internal port 5432
api: build apps/server/Dockerfile # internal port 8000
mcp: build apps/mcp/Dockerfile # internal port 3000
web: build apps/web/Dockerfile # exposed 80, 443

db

Postgres 16. Healthchecked with pg_isready -U clone. Uses POSTGRES_PASSWORD from .env. Database name clone, user clone.

api

Builds from the repo root so packages/schema/events.schema.json is reachable by recording.views (which loads it via parents[3]).

Key env vars:

VarPurpose
DATABASE_URLpostgresql://clone:${POSTGRES_PASSWORD}@db:5432/clone
DEBUG"False" in production.
ALLOWED_HOSTSclone.is,www.clone.is,api.clone.is,api,localhostapi and localhost are required for in-container service-to-service calls (e.g. mcp → http://api:8000) since Django's DisallowedHost check matches the Host header verbatim.
ANTHROPIC_API_KEYRequired for Prediction and memory promotion.

mcp

environment:
MCP_TRANSPORT: http
PORT: 3000
CLONE_API_URL: http://api:8000
# CLONE_API_TOKEN intentionally unset — HTTP mode reads the bearer
# off each request so one MCP instance serves many users.
depends_on:
- api

web

ports:
- "80:80"
- "443:443"
volumes:
- /etc/letsencrypt:/etc/letsencrypt:ro
depends_on:
- api
- mcp

The build context is the repo root because apps/web/public/* symlinks into packages/design/assets/, and the Vite alias @clone/design resolves to ../../packages/design. The same image bundles the docs build (apps/docs/build//usr/share/nginx/html/docs/) so /docs/ works without a separate hosting target.

Migrations and admin

# One-shot Django commands run in the api service.
docker compose run --rm api python manage.py migrate
docker compose run --rm api python manage.py createsuperuser

Persistence

  • pgdata named volume — Postgres data. Back this up; nothing else in the stack is stateful on disk.
  • /etc/letsencrypt (host) — TLS certs, mounted read-only into the web container.

Health and logs

docker compose ps # which services are up
docker compose logs -f api # follow Django logs
docker compose logs -f web # follow nginx + access logs
docker compose exec db psql -U clone # open a psql session

When something breaks

  • API responds with 400 DisallowedHostALLOWED_HOSTS doesn't include the Host header value the request arrived with. Add the host (often the internal one used by service-to-service calls).
  • MCP returns 502 from web — the mcp service is not listening on 3000 yet (still booting), or nginx.conf's /mcp block lost its proxy_buffering off setting (Streamable HTTP requires it).
  • Predictions return 503ANTHROPIC_API_KEY is missing, invalid, or rate-limited (the server collapses these into 503 / 429 — see apps/server/predictions/views.py).

DNS-specific gotchas — including the api.clone.is NXDOMAIN issue that bit the live deploy — live in DNS & TLS.