Local Production Testing
The same docker-compose.prod.yml that runs your real deployment can also be used to exercise the full container stack on your laptop — useful when you're verifying a migration, debugging a release candidate, or rehearsing an upgrade against a freshly seeded database.
Prerequisites
- Docker (or OrbStack on macOS — recommended for the
.orb.localhostname routing). - A populated
.envfile in the repo root. See Getting Started for the full env recipe. The minimal set is:bashNUXT_SESSION_SECRET=$(openssl rand -hex 32) ADMIN_API_KEY=$(openssl rand -hex 32) IP_HASH_SECRET=$(openssl rand -hex 32) CHANNEL_ENCRYPTION_KEY=$(openssl rand -hex 32) DB_PASSWORD=$(openssl rand -hex 16) REDIS_PASSWORD=$(openssl rand -hex 16)
Bring the stack up
docker compose -f docker-compose.prod.yml up -d --buildThis builds the three application containers (web, api, tracker) plus the data layer (postgres, pgbouncer, redis) and caddy. The whole stack is reachable through Caddy on :80 / :443. The first boot:
- waits for Postgres + Redis health checks,
- lets the API container apply the committed migrations under
packages/db/src/migrations/, - boots the web frontend.
Without TLS termination
If you don't want Caddy issuing certs against localhost, point your DOMAIN at a local-only hostname (OrbStack: trackarr.orb.local) or override the Caddyfile to disable HTTPS for development.
Common commands
# Tail every service
docker compose -f docker-compose.prod.yml logs -f
# Tail one of the three apps
docker compose -f docker-compose.prod.yml logs -f api
docker compose -f docker-compose.prod.yml logs -f web
docker compose -f docker-compose.prod.yml logs -f tracker
# Connect to the DB
docker compose -f docker-compose.prod.yml exec postgres \
psql -U "$DB_USER" -d "$DB_NAME"
# Take everything down (keeps volumes)
docker compose -f docker-compose.prod.yml down
# Wipe everything (DB + Redis + uploads)
docker compose -f docker-compose.prod.yml down -vStatic SPA variant
docker-compose.static.yml is an overlay that swaps the SSR Nuxt container for a distroless nginx serving a pre-built SPA bundle (apps/web/Dockerfile.static). The rest of the stack — API, tracker, Caddy, Postgres, Redis, PgBouncer — is inherited from the prod compose unchanged.
docker compose \
-f docker-compose.prod.yml \
-f docker-compose.static.yml \
--env-file .env \
up -d --buildWhy bother:
- Runtime memory drops from ~120-150 MB sustained (SSR) to ~10-15 MB (nginx idle).
- Cold-start drops from ~2 s booting Nitro to ~0.1 s.
- Image size drops from ~80 MB (Node + bundle) to ~30 MB (nginx + assets).
Trade-offs:
- No SSR means no SEO out of the box (every page hydrates client- side). For a private tracker this is usually fine — nothing is indexed publicly anyway.
- First Contentful Paint is slower for the first uncached visit (the SPA shell must download + execute before anything renders). Subsequent navigations are much faster.
The static bundle reads the runtime tracker URLs from /api/runtime-config on boot, so the same image is portable across domains.
Troubleshooting
"Port already allocated"
Stop whatever else is binding :80 / :443 / :8080 / :6969 before bringing the stack up — typically a host nginx, another docker compose, or the dev server on pnpm dev.
Database connection errors
Check the .env values match. Both the api and postgres containers read ${DB_USER}, ${DB_PASSWORD}, ${DB_NAME} from the same file; a typo there shows up as password authentication failed for user "tracker" in the API logs.
Migrations hang at boot
The API container applies the committed migrations on start, holding a pg_advisory_lock so two containers cannot migrate at once. If you suspect a runaway migration:
docker compose -f docker-compose.prod.yml logs api | grep -E '\[Boot\]|\[Migrate\]'A clean run reports Migrations up to date within a few seconds; anything longer means Postgres is unresponsive (check docker compose logs postgres) or another container is holding the advisory lock.
Coming from an image that pushed the schema instead, run the one-time baseline first — see Upgrading.