Docker Compose reference#
The docker-compose.yml at the repo root is the deployment artifact. This page walks the file service-by-service so you can reason about what each container does, why it's wired the way it is, and which knobs are safe to turn.
For per-variable detail (what COOKIE_SECURE does, how DATABASE_URL is composed), see Environment variables. For the topology rationale, see the section landing page.
Services#
The stack has five services: postgres, migrate, backend, frontend, and proxy. Four run continuously; migrate is one-shot.
postgres#
postgres:
image: postgres:16-alpine
restart: unless-stopped
env_file: .env
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
- pgbackup:/backups
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
The Postgres 16 Alpine image with the standard POSTGRES_* env-var bootstrap. Two volumes: pgdata for the live data directory, pgbackup as a mount point for pg_dump output.
The health check uses pg_isready — the same probe depends_on: condition: service_healthy consumes downstream. With interval: 10s and retries: 5, a slow first boot has up to 60 s after the start_period to come up before downstream services give up.
The service has no ports: block. Postgres is reachable only from inside the Docker network, addressed as the hostname postgres. Publishing 5432 to the host is unsafe and unnecessary; if you need ad-hoc SQL access, use docker compose exec postgres psql ....
migrate#
migrate:
build: { context: ./backend, dockerfile: Dockerfile }
image: ticket-board-migrate:latest
env_file: .env
environment:
DATABASE_URL: ${DATABASE_URL}
ENV: dev
COOKIE_SECURE: "false"
SECRET_KEY: ${SECRET_KEY}
depends_on:
postgres:
condition: service_healthy
entrypoint: ["/bin/sh", "-c", "pip install --quiet psycopg2-binary && python -m alembic -c /app/alembic.ini upgrade head"]
restart: "no"
The migrate service runs alembic upgrade head against the live database and exits. The backend service has depends_on.migrate.condition: service_completed_successfully, so the API will not start until the schema is at head.
A few details worth understanding:
- It reuses the backend image. Same code, same model definitions — Alembic sees the same
alembic.iniand the samedb/migrations/versions/package the runtime ORM imports its models from. The runtime itself never invokes Alembic. - The entrypoint installs
psycopg2-binaryinline. Alembic's runner uses a synchronous DB connection, but the production image only ships the asyncasyncpgdriver. Rather than carry psycopg2 in the runtime image where it isn't needed, the migrate service installs it on the fly. The cost is a few seconds of pip per upgrade. ENV=devandCOOKIE_SECURE=falseare hard-coded. Alembic doesn't serve traffic, so the security posture is irrelevant — these overrides exist only to satisfy the settings validator without forcing operators to special-case the migrate run.restart: "no". This service is supposed to exit. A restart loop on success would mean the schema is migrating in a loop.
After a successful run, docker compose ps shows migrate as Exited (0). That's normal. The next docker compose up will run it again and (assuming no new migrations) it will exit successfully without changing anything — Alembic is idempotent at head.
Re-running migrations manually
docker compose run --rm migrate runs only the migration step. Useful when you've added a manual migration and want to apply it without restarting the backend.
backend#
backend:
build: { context: ./backend, dockerfile: Dockerfile }
image: ticket-board-backend:latest
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: ${DATABASE_URL}
depends_on:
postgres:
condition: service_healthy
migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/readyz')"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
FastAPI + FastMCP under uvicorn, two workers by default (see the Dockerfile for the exact entrypoint). The health probe hits /readyz, which verifies the database connection and that settings loaded — a stricter check than /healthz (liveness only).
Like Postgres, the backend has no ports: block. Caddy reaches it on the internal network at backend:8000. Exposing it on the host would bypass Caddy and break the single-origin cookie model.
depends_on waits for two conditions:
postgresmust be healthy.migratemust have exited successfully.
If either is unmet, the backend will not start. This is what makes docker compose up safe on a fresh checkout — by the time the API answers a request, the schema is at head.
frontend#
frontend:
build: { context: ./frontend, dockerfile: Dockerfile }
image: ticket-board-frontend:latest
restart: unless-stopped
env_file: .env
environment:
BACKEND_URL: http://backend:8000
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
A Next.js standalone build. BACKEND_URL=http://backend:8000 is the address that Next.js server components and route handlers use for server-side fetches — they go directly to the backend over Docker's internal network, skipping the round trip through Caddy.
Browser-side fetches still go through Caddy, hitting /api/v1/* on the same origin as the UI. Both paths terminate at the same backend; only the request route differs.
The health probe pings the Next.js /api/health route handler. The frontend depends_on waits for the backend to be healthy, so by the time the UI accepts a request the API is already serving.
proxy#
proxy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_healthy
Caddy 2 reading the repo's Caddyfile. Two volumes — caddy_data (ACME certs, OCSP staples) and caddy_config (runtime config cache) — persist across restarts so ACME doesn't re-issue certificates needlessly.
The Caddyfile does three things:
- Routes
/api/v1/*,/mcp/bot/*,/healthz, and/readyztobackend:8000. - Routes everything else to
frontend:3000. - Rewrites MCP tokens to
<redacted>in the access log before any JSON line is emitted.
The token-redaction filter is the security-load-bearing piece. See Homelab setup for why and how it works.
Why only the proxy publishes ports#
There is exactly one entry point into the stack: Caddy on :80 and :443. This is not a stylistic preference; it's required for the session cookie to work.
The backend's SameSite=Lax cookie is bound to the origin that issued it. If you publish backend:8000 to the host alongside the Caddy origin, the browser sees two origins for the same logical app, and:
- API calls from the frontend (which travel through Caddy) get the cookie.
- API calls made directly to the backend port do not, because they're cross-origin.
Worse, debugging this is miserable — login looks like it succeeds (the Set-Cookie is returned) but subsequent requests are unauthenticated. Leave the backend unexposed; debugging the two-origin cookie failure is harder than the convenience is worth.
The migrate, backend, and frontend services intentionally omit ports: for this reason. Postgres is also unexposed because nothing outside the network needs it.
depends_on chain#
flowchart LR
pg[postgres<br/>service_healthy]
mig[migrate<br/>service_completed_successfully]
be[backend<br/>service_healthy]
fe[frontend<br/>service_healthy]
px[proxy]
pg --> mig
pg --> be
mig --> be
be --> fe
be --> px
fe --> px
Read top-to-bottom: Postgres must be healthy before migrations can run; migrations must succeed before the backend starts; the backend must be healthy before the frontend starts; the proxy waits on both UI and API.
A consequence: a Postgres misconfiguration that fails the pg_isready probe will halt the entire stack at migrate, with no API or UI ever coming up. Check docker compose logs postgres first when nothing else starts.
Healthchecks#
Every long-running service has one:
| Service | Probe | Endpoint |
|---|---|---|
postgres |
pg_isready |
(built-in) |
backend |
urlopen |
http://localhost:8000/readyz |
frontend |
http.get |
http://localhost:3000/api/health |
proxy |
(none configured) | — |
/readyz is stricter than /healthz. Liveness (/healthz) just says "the process is up"; readiness (/readyz) confirms the DB connection and settings load. The backend's health check uses readiness because a backend that can't talk to the database isn't ready to handle traffic, regardless of whether the process is running.
Caddy has no health check in docker-compose.yml because nothing waits on it. If you want one, the standard caddy:2-alpine image responds to wget --quiet --tries=1 --spider http://localhost:80/healthz (the proxy passes the path through to the backend).
Volumes#
Four named volumes. Docker keeps them across docker compose down; only docker compose down -v destroys them.
| Volume | Holds | Loss impact |
|---|---|---|
pgdata |
Postgres data directory — every domain row, every audit event, FTS indexes | Total data loss; restore from backup |
pgbackup |
Reserved mount point for in-container pg_dump workflows |
No live data; today's make backup writes to the host via shell redirection and does not use this mount |
caddy_data |
ACME certificates, OCSP staples | Caddy re-issues certificates on next start; brief outage while ACME runs |
caddy_config |
Caddy runtime config cache | Rebuilt automatically |
Only pgdata is truly load-bearing. Back it up. The other three are recreatable.
docker compose down -v is destructive
The -v flag deletes all named volumes, including pgdata. This is the right command for a full reset (e.g. starting over with a fresh seed), but it's catastrophic in production. The Makefile's reset-db target has an extra guard that refuses unless DATABASE_URL ends in _dev or _test for exactly this reason.
Mounting host paths for backups#
make backup already writes timestamped dumps into ./backups/ on the host — it shells docker compose exec -T postgres pg_dump ... and redirects the output, so no bind mount is required for the Makefile workflow.
For cron-in-container patterns (for example, pg_dump --file=/backups/... invoked by a sidecar), bind-mount ./backups over the container's /backups path:
See Backup and restore for the full procedure.
Networks#
The Compose file does not declare a network explicitly. Docker Compose creates a default bridge network named <project>_default (the project name comes from the directory or -p flag).
Inside that network, services resolve each other by service name:
backendreaches Postgres atpostgres:5432.- The frontend reaches the backend at
backend:8000. - Caddy reaches the backend at
backend:8000and the frontend atfrontend:3000.
There is no need to expose any of these to the host or to add custom networks. If you do need to attach the stack to an external Docker network (for example, to put it behind a shared reverse proxy you run for multiple apps), add a networks: block at the top of the file and attach each service.
Image build context#
The backend and frontend services have build contexts pointed at their respective package directories. docker compose up --build rebuilds both images from source; docker compose pull doesn't help here because the images are not published to a registry — they're built locally.
Backend image details#
The backend Dockerfile is a two-stage build:
- Builder stage (
python:3.12-slim): Installs Poetry, exports a production-onlyrequirements.txt, creates a virtualenv at/venv, and pip-installs everything into it. - Runtime stage (
python:3.12-slim): Copies the venv and source into a thin image, runs as a non-rootappuser, and starts uvicorn directly so PID 1 receives signals.
The runtime image does not contain Poetry, pip's build tools, or psycopg2 — the migrate service installs psycopg2 on demand. The entrypoint is python -m uvicorn ticket_board.asgi:app --host 0.0.0.0 --port 8000 --workers 2.
Frontend image details#
The frontend image uses the Next.js standalone build target. The standalone output bundles only the files needed to run the server — no node_modules, no dev dependencies, no source maps.
Custom Compose overrides#
Compose merges docker-compose.yml with docker-compose.override.yml (or any file passed to -f). Common overrides:
Bind-mount backups onto the host:
# docker-compose.override.yml
services:
postgres:
volumes:
- pgdata:/var/lib/postgresql/data
- ./backups:/backups
Pin Postgres to a specific patch version:
Attach to an external proxy network:
Avoid override patterns that re-publish the backend or frontend ports, set conflicting ENV/COOKIE_SECURE values, or change the Caddy routing. Those are the constraints the rest of the stack assumes.