# Production compose stack for Hetzner VM deployment. # # Decision references (from CONTEXT.md / RESEARCH.md): # D-01: Hetzner VM as the production host — single VPS, plain Docker Compose. # D-02: No orchestration layer — plain docker compose up on the VM. # D-03: Postgres runs inside the compose stack (not an external managed DB). # D-04: Caddy handles TLS via Let's Encrypt (persistent caddy_data volume required). # D-08: Same image built once; web and worker are launched via different `command:` values. # # Usage: # export IMAGE=ghcr.io/yourusername/xtablo TAG=v0.1.0 # docker compose -f docker-compose.prod.yaml --env-file .env.prod up -d services: # ------------------------------------------------------------------------- # Postgres (D-03) — internal only; no host port binding (RESEARCH Pitfall 5) # ------------------------------------------------------------------------- postgres: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB:-xtablo} POSTGRES_USER: ${POSTGRES_USER:-xtablo} # No default — operator MUST set POSTGRES_PASSWORD in .env.prod POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-xtablo}"] interval: 10s timeout: 5s retries: 10 # No `ports:` — Postgres is internal to the compose network only. # Exposing port 5432 to the host would allow unauthenticated internet access # on a VM with a public IP (RESEARCH Pitfall 5 / threat T-07-09). # ------------------------------------------------------------------------- # Web server (D-08) — /app/web binary from the shared image # ------------------------------------------------------------------------- web: image: ${IMAGE:-ghcr.io/yourusername/xtablo}:${TAG:-latest} command: /app/web restart: unless-stopped env_file: .env.prod depends_on: postgres: condition: service_healthy expose: - "8080" # No `ports:` — Caddy handles all external traffic and reverse-proxies to web:8080. # ------------------------------------------------------------------------- # Background worker (D-08) — /app/worker binary from the same image # ------------------------------------------------------------------------- worker: image: ${IMAGE:-ghcr.io/yourusername/xtablo}:${TAG:-latest} command: /app/worker restart: unless-stopped env_file: .env.prod depends_on: postgres: condition: service_healthy web: # Ensures web starts first so goose.Up() runs before the worker connects. # restart: unless-stopped means the worker self-heals if it races anyway. condition: service_started # ------------------------------------------------------------------------- # Caddy reverse proxy (D-04) — TLS termination via Let's Encrypt # ------------------------------------------------------------------------- caddy: image: caddy:2-alpine restart: unless-stopped ports: - "80:80" - "443:443" - "443:443/udp" volumes: - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config depends_on: - web volumes: # Postgres data — persist across container restarts and upgrades. postgres_data: # Caddy TLS data — stores Let's Encrypt certificates. # Losing this volume forces certificate re-issuance and may hit LE rate limits. caddy_data: # Caddy runtime config — persists across Caddy restarts. caddy_config: