# Production overlay — adds TLS (Caddy auto-HTTPS), enforces auth, and sets restart policies. # Layer it on top of the base compose: # # DOMAIN=app.example.com docker compose -f docker-compose.yml -f docker-compose.prod.yml \ # --profile full up -d --build # # Requires: a VM with Docker, a DNS A record for $DOMAIN pointing at it, and ports 80+443 open. # Lock the rest down with a firewall (e.g. `ufw allow 80,443/tcp` and deny 8000/8080/5432/9000). # Fail-closed datastore credentials, shared by every service that opens the database or the object # store. This is an anchor rather than two copies because the API and the job worker MUST fail closed # in the same way: the worker inherits the base compose's permissive `${POSTGRES_PASSWORD:-bim}` # unless something overrides it here, so a copy that drifted would leave the worker connecting with # the dev default password in production while the API refused to start — and only the API's refusal # would be visible. x-prod-datastore: &prod-datastore DATABASE_URL: "postgresql+psycopg://${POSTGRES_USER:-bim}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env for production}@postgres:5432/${POSTGRES_DB:-bim}" S3_SECRET_KEY: "${S3_SECRET_KEY:?set S3_SECRET_KEY in .env for production}" services: caddy: image: caddy:2 restart: unless-stopped ports: ["80:80", "443:443"] environment: DOMAIN: "${DOMAIN:?set DOMAIN to your public hostname}" volumes: - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data - caddy-config:/config depends_on: [web] profiles: ["full"] web: restart: unless-stopped # Shared store for per-IP rate limiting + login-lockout counters across the API's uvicorn workers # (the base stack runs UVICORN_WORKERS=4; without Redis those counters are per-process). redis: image: redis:7-alpine restart: unless-stopped command: ["redis-server", "--save", "", "--appendonly", "no"] # ephemeral counters; no persistence healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 10 profiles: ["full"] api: restart: unless-stopped depends_on: redis: { condition: service_healthy } environment: AEC_RBAC: "1" # enforce auth/roles in production (override the dev default) AEC_REQUIRE_SECRET: "1" # refuse to start without AEC_AUTH_SECRET (fail-closed) AEC_HSTS: "1" # served over HTTPS via Caddy → enable HSTS AEC_COOKIE_SECURE: "1" # force the Secure flag on the auth cookie AEC_CSP: "${AEC_CSP:-1}" # strict resource Content-Security-Policy AEC_SIGNED_URL_TTL: "${AEC_SIGNED_URL_TTL:-3600}" AEC_MAX_UPLOAD_MB: "${AEC_MAX_UPLOAD_MB:-1024}" AEC_RATE_LIMIT_RPM: "${AEC_RATE_LIMIT_RPM:-600}" # per-IP throttle (shared via Redis below) AEC_REDIS_URL: "redis://redis:6379/0" # shared rate-limit + login-lockout across workers AEC_ADMIN_EMAILS: "${AEC_ADMIN_EMAILS:-}" # platform admins (Settings/audit/user mgmt) # Host-header pinning (opt-in): e.g. "app.example.com,localhost" — include localhost so the # container healthcheck and the internal nginx proxy keep working. Empty = no restriction. AEC_ALLOWED_HOSTS: "${AEC_ALLOWED_HOSTS:-}" # Fail-closed on the datastore secrets in production — refuse to start rather than silently fall # back to the dev defaults (the base compose uses `${…:-bim}` / `${…:-minioadmin}` for local dev). <<: *prod-datastore # Resource limits (opt-in): same env vars as the base compose (see .env.example). Kept OFF by # default so a too-low cap can't OOM-kill a legitimate large-IFC reconvert. Uncomment + size # for the production host. # deploy: # resources: # limits: # memory: "${AEC_API_MEM_LIMIT:-4g}" # cpus: "${AEC_API_CPUS:-2}" # JOB-WORKER-SPLIT — the queue container from the base compose, made production-shaped. The base # file already sets `AEC_JOB_WORKER=off` on the API and defines this service; all that is added here # is the restart policy and the fail-closed credentials. # # This is the service to scale when conversions back up — `--scale worker=N` — and the one to give # the memory limit to, because after the split it is where the large IFC parses actually happen. worker: restart: unless-stopped healthcheck: { disable: true } # inherited from the API image; this service binds no port environment: <<: *prod-datastore AEC_JOB_WORKER: "inline" # this process IS the worker # deploy: # opt-in cap — the heavy parses live here now, so size this one # resources: # before the API's. # limits: # memory: "${AEC_WORKER_MEM_LIMIT:-4g}" # cpus: "${AEC_WORKER_CPUS:-2}" postgres: restart: unless-stopped environment: POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env for production}" # deploy: # opt-in cap — uncomment + size for your host # resources: # limits: # memory: "${AEC_PG_MEM_LIMIT:-2g}" minio: restart: unless-stopped environment: MINIO_ROOT_PASSWORD: "${S3_SECRET_KEY:?set S3_SECRET_KEY in .env for production}" # deploy: # opt-in cap — uncomment + size for your host # resources: # limits: # memory: "${AEC_MINIO_MEM_LIMIT:-1g}" volumes: caddy-data: caddy-config: