# Local-dev infrastructure stack for `apf_portal`. # # Spins up the runtime dependencies the BFF and ADRs assume: # - PostgreSQL 17 (per ADR-0006) # - Redis 7 (per ADR-0010 — single node in dev, # Sentinel HA in prod) # - OpenTelemetry Collector (per ADR-0012 — receives OTLP from the # BFF and the SPA, debug-logs traces in # v1, forwards to Jaeger when activated) # # Optional viewers / tooling behind Compose profiles: # - --profile dbtools → pgweb (Postgres GUI) # - --profile observability → Jaeger UI (trace explorer) # - --profile serve-static → Caddy reverse proxy that serves the # production build with the per-locale # routing the production reverse proxy # will use (per ADR-0019) # # Usage from infra/local/: # cp .env.example .env # $EDITOR .env # set strong dev passwords # docker compose -f dev.compose.yml up -d # # To bring up viewers too: # docker compose -f dev.compose.yml --profile dbtools --profile observability up -d # # State persists in named volumes (`apf-portal-postgres-data`, # `apf-portal-redis-data`). To wipe and start fresh: # docker compose -f dev.compose.yml down -v # # Bootstrap SQL (audit roles + schema, per ADR-0013) is applied on # first boot from init/postgres/. To replay after editing it, wipe # the volume with `down -v` above. name: apf-portal-dev # Shared base for the ADR-0030 `apps` profile — dev servers for the three # Nx apps. The repo is bind-mounted for hot reload; node_modules and the # Nx cache live in named volumes so the container's install (native # modules built for THIS image) is never shadowed by the host's. x-app-base: &app-base build: context: . dockerfile: Dockerfile.dev profiles: [apps] working_dir: /workspace volumes: - ../../:/workspace - app-node-modules:/workspace/node_modules - app-nx-cache:/workspace/.nx networks: - apf-portal-dev services: postgres: image: postgres:17.2-alpine container_name: apf-portal-postgres restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-portal} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in infra/local/.env} POSTGRES_DB: ${POSTGRES_DB:-portal_dev} ports: - '${POSTGRES_PORT:-5432}:5432' volumes: - postgres-data:/var/lib/postgresql/data # Bootstrap SQL runs only on a fresh data volume. - ./init/postgres:/docker-entrypoint-initdb.d:ro networks: - apf-portal-dev healthcheck: test: ['CMD-SHELL', 'pg_isready -U "${POSTGRES_USER:-portal}" -d "${POSTGRES_DB:-portal_dev}"'] interval: 5s timeout: 3s retries: 5 redis: image: redis:7.4-alpine container_name: apf-portal-redis restart: unless-stopped # AOF (`--appendonly yes`) keeps sessions across container restarts — # the dev equivalent of the persistence we want from the prod # Sentinel cluster (per ADR-0010 §"Persistence"). command: - redis-server - --requirepass - ${REDIS_PASSWORD:?REDIS_PASSWORD must be set in infra/local/.env} - --appendonly - 'yes' ports: - '${REDIS_PORT:-6379}:6379' volumes: - redis-data:/data networks: - apf-portal-dev healthcheck: test: ['CMD', 'redis-cli', '-a', '${REDIS_PASSWORD}', 'PING'] interval: 5s timeout: 3s retries: 5 otel-collector: image: otel/opentelemetry-collector-contrib:0.150.1 container_name: apf-portal-otel-collector restart: unless-stopped command: ['--config=/etc/otel-collector.yaml'] volumes: - ./otel-collector.yaml:/etc/otel-collector.yaml:ro ports: # OTLP receivers — the BFF and the SPA send here. - '${OTEL_GRPC_PORT:-4317}:4317' - '${OTEL_HTTP_PORT:-4318}:4318' networks: - apf-portal-dev # ------------------------------------------------------------------ # Optional: Postgres GUI (`--profile dbtools`). # pgweb is a lightweight, no-config alternative to pgAdmin. # ------------------------------------------------------------------ pgweb: image: sosedoff/pgweb:0.16.2 container_name: apf-portal-pgweb restart: unless-stopped profiles: [dbtools] # Use discrete CLI flags rather than `PGWEB_DATABASE_URL`. The URL # form is fragile: any special character in POSTGRES_PASSWORD # (`@`, `#`, `:`, `/`, `?`, `%`, `&`, …) breaks the userinfo # parser, and the resulting "Invalid URL" error is opaque. Discrete # flags accept any password verbatim. The image's ENTRYPOINT # already passes `--bind=0.0.0.0 --listen=8081`; these args are # appended. command: - --host=postgres - --port=5432 - --user=${POSTGRES_USER:-portal} - --pass=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in infra/local/.env} - --db=${POSTGRES_DB:-portal_dev} - --ssl=disable ports: - '${PGWEB_PORT:-8081}:8081' depends_on: postgres: condition: service_healthy networks: - apf-portal-dev # ------------------------------------------------------------------ # Optional: Jaeger UI for trace exploration (`--profile observability`). # The collector pipeline above always tries to forward to `jaeger:4317`; # when this profile is off, those exports fail silently (logged at # warn level by the collector — no blocking effect on the debug # exporter that prints traces to stdout). # OTLP ports (4317/4318) are NOT published from this service to # avoid colliding with the collector — the collector talks to # Jaeger on the internal Compose network. # # Image is Jaeger v2 (`jaegertracing/jaeger`, distinct from the v1 # `jaegertracing/all-in-one`). v2 is the rewrite built on top of # OpenTelemetry Collector and is the actively-developed line. OTLP # receivers are enabled by default (no COLLECTOR_OTLP_ENABLED env # needed) and the UI ships a built-in Light/Dark/Auto theme # selector — features the v1 image did not have. # ------------------------------------------------------------------ jaeger: image: jaegertracing/jaeger:2.17.0 container_name: apf-portal-jaeger restart: unless-stopped profiles: [observability] ports: - '${JAEGER_UI_PORT:-16686}:16686' networks: - apf-portal-dev # ------------------------------------------------------------------ # Optional: Caddy reverse proxy for the production build # (`--profile serve-static`). # # Workflow: # pnpm exec nx build portal-shell --configuration=production # ./infra/local/dev.sh up serve-static # open http://localhost:${SERVE_STATIC_PORT:-4200}/ # # Mirrors what the production reverse proxy will do per ADR-0019: # smart redirect at `/` (Accept-Language → `/fr/` or `/en/`), # per-locale SPA fallback, and `/{locale}/` routing. # No locking-down on TLS / auth — this is a local convenience that # boots in <1 s and binds to localhost only. # ------------------------------------------------------------------ serve-static: image: caddy:2.10-alpine container_name: apf-portal-serve-static restart: unless-stopped profiles: [serve-static] command: ['caddy', 'run', '--config', '/etc/caddy/Caddyfile', '--adapter', 'caddyfile'] volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro # Bind-mount the production build folder read-only. The path # resolves relative to this compose file, i.e. # /dist/apps/portal-shell/browser/. Empty until the # production build runs — caddy will 404 every request in that # state, which is the right signal. - ../../dist/apps/portal-shell/browser:/srv/dist:ro ports: - '${SERVE_STATIC_PORT:-4200}:4200' networks: - apf-portal-dev # ------------------------------------------------------------------ # ADR-0030 dockerised full-stack dev mode (`--profile apps`). # # ./infra/local/dev.sh up apps → infra + the three dev servers, # no native Node/pnpm on the host. # # Hot reload via the repo bind-mount. See docs/setup/ for the # "which mode when" guidance (native / devcontainer / compose apps). # NOTE: the SPA dev servers default to 4200/4300 — the same 4200 the # `serve-static` profile uses; don't run `apps` and `serve-static` # together, or override SHELL_PORT. # ------------------------------------------------------------------ # One-shot: populate the shared node_modules volume once so the three # app services don't race three concurrent installs on it. Exits when # done; the apps gate on its successful completion. apps-deps: <<: *app-base container_name: apf-portal-apps-deps command: ['pnpm', 'install', '--frozen-lockfile'] portal-bff: <<: *app-base container_name: apf-portal-bff-dev environment: APF_ROLE: bff NODE_ENV: development PORT: '3000' # Host-specific URLs rebuilt from infra/local/.env creds → Compose # service names. Compose `environment` wins over `env_file`, so # these override the localhost values in the BFF's own .env. DATABASE_URL: 'postgresql://${POSTGRES_USER:-portal}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-portal_dev}?schema=public' REDIS_URL: 'redis://default:${REDIS_PASSWORD}@redis:6379/0' OTEL_EXPORTER_OTLP_ENDPOINT: 'http://otel-collector:4318/v1/traces' # Secrets (Entra / session / jwks) come from the BFF's own dev env. # `required: false` so SPA-only devs can `up` without it — the BFF # then fails its own boot-time config validators with a clear message # rather than Compose erroring on a missing file. env_file: - path: ../../apps/portal-bff/.env required: false command: ['pnpm', 'exec', 'nx', 'serve', 'portal-bff'] ports: - '${BFF_PORT:-3000}:3000' depends_on: apps-deps: condition: service_completed_successfully postgres: condition: service_healthy redis: condition: service_healthy portal-shell: <<: *app-base container_name: apf-portal-shell-dev command: ['pnpm', 'exec', 'nx', 'serve', 'portal-shell', '--host', '0.0.0.0', '--port', '4200'] ports: - '${SHELL_PORT:-4200}:4200' depends_on: apps-deps: condition: service_completed_successfully portal-admin: <<: *app-base container_name: apf-portal-admin-dev command: ['pnpm', 'exec', 'nx', 'serve', 'portal-admin', '--host', '0.0.0.0', '--port', '4300'] ports: - '${ADMIN_PORT:-4300}:4300' depends_on: apps-deps: condition: service_completed_successfully volumes: postgres-data: name: apf-portal-postgres-data redis-data: name: apf-portal-redis-data app-node-modules: name: apf-portal-app-node-modules app-nx-cache: name: apf-portal-app-nx-cache networks: apf-portal-dev: name: apf-portal-dev