# Production topology — platform-neutral, runs on any host with Docker: # browser → frontend (public) → backend (private network only) → postgres/redis # # The backend publishes no host port: it is reachable only on the compose # network. A runtime Next Route Handler proxies allow-listed /api/v1/* paths # via BACKEND_INTERNAL_URL. Set DATABASE_URL / REDIS_URL to use managed data # stores instead of the bundled containers. # # Usage (see docs/deployment.md): # ./scripts/deploy.sh # build, migrate, start, wait for health # docker compose -f docker-compose.prod.yml --env-file .env.production up --build # # No secrets live in this file; they come from the env file. services: backend: build: context: ./apps/backend target: prod environment: APP_ENV: ${APP_ENV:-production} DATABASE_URL: ${DATABASE_URL:-} REDIS_URL: ${REDIS_URL:-} POSTGRES_HOST: postgres REDIS_HOST: redis RESEARCH_EXTRACTOR_PROVIDER: ${RESEARCH_EXTRACTOR_PROVIDER:-deepseek} RESEARCH_MODEL: ${RESEARCH_MODEL:-} DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com} DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-v4-pro} EMAIL_GENERATOR_PROVIDER: ${EMAIL_GENERATOR_PROVIDER:-fake} BACKEND_CORS_ORIGINS: '["${FRONTEND_ORIGIN:-http://localhost:3000}"]' depends_on: postgres: condition: service_healthy redis: condition: service_healthy healthcheck: test: ["CMD-SHELL", "python -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health')\""] interval: 15s timeout: 5s retries: 5 restart: unless-stopped worker: build: context: ./apps/backend target: prod command: ["uv", "run", "--no-dev", "python", "-m", "app.worker"] environment: APP_ENV: ${APP_ENV:-production} DATABASE_URL: ${DATABASE_URL:-} REDIS_URL: ${REDIS_URL:-} POSTGRES_HOST: postgres REDIS_HOST: redis RESEARCH_EXTRACTOR_PROVIDER: ${RESEARCH_EXTRACTOR_PROVIDER:-deepseek} RESEARCH_MODEL: ${RESEARCH_MODEL:-} DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com} DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-v4-pro} EMAIL_GENERATOR_PROVIDER: ${EMAIL_GENERATOR_PROVIDER:-fake} depends_on: postgres: condition: service_healthy redis: condition: service_healthy healthcheck: test: ["CMD", "python", "-c", "import os; os.kill(1, 0)"] interval: 30s timeout: 5s retries: 3 start_period: 10s restart: unless-stopped frontend: build: context: ./apps/frontend target: prod args: NEXT_PUBLIC_ENABLE_RESEARCH: ${NEXT_PUBLIC_ENABLE_RESEARCH:-false} environment: BACKEND_INTERNAL_URL: http://backend:8000 FRONTEND_ACCESS_PASSWORD: ${FRONTEND_ACCESS_PASSWORD:?set FRONTEND_ACCESS_PASSWORD in the env file} ports: - "${FRONTEND_BIND:-0.0.0.0}:${FRONTEND_PORT:-3000}:3000" depends_on: backend: condition: service_healthy healthcheck: # The whole frontend sits behind the access passcode, so the probe # authenticates with it (read from the container env, never inlined). test: - CMD - node - -e - >- fetch("http://127.0.0.1:3000/", {headers: {authorization: "Basic " + Buffer.from(":" + process.env.FRONTEND_ACCESS_PASSWORD).toString("base64")}}) .then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1)) interval: 15s timeout: 5s retries: 5 restart: unless-stopped postgres: image: postgres:16-alpine environment: POSTGRES_USER: ${POSTGRES_USER:-app} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in the env file} POSTGRES_DB: ${POSTGRES_DB:-importer_hunter} volumes: - pgdata-prod:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-app} -d ${POSTGRES_DB:-importer_hunter}"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped redis: image: redis:7-alpine healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped volumes: pgdata-prod: