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 run
drizzle-kit push --forceagainstpackages/db/src/schema.tsto land any schema delta, - 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.
Drizzle push hangs at boot
The API container runs drizzle-kit push --force against schema.ts on first start. If you suspect a runaway migration:
docker compose -f docker-compose.prod.yml logs api | grep drizzleA clean push exits within a few seconds; anything longer means Postgres is unresponsive (check docker compose logs postgres).