services: webserver: build: context: . dockerfile: nginx/Dockerfile container_name: nginx_webserver volumes: - nginx_logs:/var/log/nginx restart: unless-stopped depends_on: api: condition: service_started networks: - proxy # external — reachable by your reverse proxy / traefik - internal # needs a path to the api container api: build: . container_name: wp_api environment: # Preferred: the API builds its own connection string from these and # encodes the password automatically (no manual URL-encoding needed). POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} POSTGRES_HOST: db # Optional full-URL override (must be URL-encoded if used). DATABASE_URL: ${DATABASE_URL:-} # Signs login session cookies, AFTER Okta has confirmed who someone is. # REQUIRED — compose fails fast if it's unset, and the API refuses to # start in production without it (see server/auth.py). AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)} AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12} # Okta OIDC — the only sign-in path (D15/D16). Not marked required the # way AUTH_SECRET_KEY is: the API starts without these, it just refuses # every sign-in and says so in the startup log (server/okta_auth.py # describe()). See server/.env.example for what each one is and how to # get it from the Okta app integration. OKTA_ISSUER: ${OKTA_ISSUER:-} OKTA_CLIENT_ID: ${OKTA_CLIENT_ID:-} OKTA_CLIENT_SECRET: ${OKTA_CLIENT_SECRET:-} OKTA_REDIRECT_URI: ${OKTA_REDIRECT_URI:-} # NOT ${OKTA_IDENTITY_CLAIM:-} — server/okta_auth.py's own default only # applies when the env var is UNSET, and compose setting it to an empty # string here is not the same thing as leaving it unset. An empty value # would make the API look for a claim literally named "", which is # never present, so EVERY sign-in would 503. Mirror the same default # here instead, so an operator who leaves .env's copy commented out gets # the real default, not a broken one. OKTA_IDENTITY_CLAIM: ${OKTA_IDENTITY_CLAIM:-preferred_username} # Optional — SMTP password for WP-assignment emails. Email is off by # default and enabled from the Admin console; this is the only email # secret and it is never stored in the DB. Leave unset until configured. SMTP_PASSWORD: ${SMTP_PASSWORD:-} # Optional — read-only SQL Server connection to the Micron asset catalog, # which backs the asset picker in the work package creator. Leave unset and # the picker cleanly falls back to manual entry (see server/assets_db.py). # Use a db_datareader login: the app only ever SELECTs. MICRON_DB_URL: ${MICRON_DB_URL:-} restart: unless-stopped depends_on: db: condition: service_healthy # waits for postgres to accept connections networks: - internal # Reaching the Micron database means leaving this compose project, and # `internal` is deliberately egress-free. `outbound` is attached to the api # container ONLY — the database and backup containers stay sealed. Detach it # again if you are not using the Micron asset picker. - outbound db: image: postgres:16-alpine container_name: wp_db environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - pgdata:/var/lib/postgresql/data restart: unless-stopped healthcheck: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] interval: 10s timeout: 5s retries: 5 networks: - internal # Scheduled pg_dump backups. Writes gzipped, timestamped dumps to ./backups on # the host (sync that folder offsite from the host — this container has no # internet egress). See scripts/db-backup.sh and DEPLOYMENT.md § Backups. backup: build: context: . dockerfile: scripts/backup.Dockerfile # postgres client + openssl container_name: wp_db_backup environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} PGHOST: db BACKUP_DIR: /backups BACKUP_KEEP: ${BACKUP_KEEP:-14} # keep the newest N dumps BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400} # 86400 = daily # Set BACKUP_ENC_PASSPHRASE in .env to encrypt dumps at rest (AES-256). # Required once the DB holds customer IP. Keep the passphrase off this host. BACKUP_ENC_PASSPHRASE: ${BACKUP_ENC_PASSPHRASE:-} volumes: - ./scripts:/scripts:ro - ./backups:/backups restart: unless-stopped depends_on: db: condition: service_healthy networks: - internal volumes: pgdata: nginx_logs: networks: proxy: name: proxy external: true internal: internal: true # no route off the host for anything on this network alone outbound: # An ordinary bridge network, i.e. one that HAS a default gateway. `internal` # above removes the gateway entirely, which blocks not just the internet but # the LAN and the VPN too — so the api container needs this second network to # reach the Micron asset database. Attached to `api` alone: `db` and `backup` # remain on `internal` only and still have no way off the host. # Detach it from api if you are not using the Micron asset picker. driver: bridge