# Phase 2B full-stack local dev/test — Postgres + egress-proxy + Provisioner + the lightweight # Platform API + the Gateway, all on one machine (the documented Phase 1/2A/2B dev exception; # production is the two-VM Proxmox split, see docs/LAB_ARCHITECTURE.md §1). # # `api` (platform/api) replaces Phase 2A's "identity" service — that was a full, --privileged # Lab image booting an unused k3s cluster just to serve roster/curriculum/progress APIs. This # is a plain Node process: no k3s, no --privileged, no terminal runtime. It owns # students/cohorts/enrollments/curriculum/progress/scenario_attempts in Postgres and is the # only service that talks to the Provisioner (student launch/reset AND instructor/admin # actions) — see docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md. # # gateway, provisioner, and api's egress-adjacent needs aside, `api` does NOT need # network_mode: host — it never touches Docker networking or firewall rules itself, it only # calls the Provisioner's HTTP API and Postgres. gateway and provisioner keep network_mode: # host because they need to reach ephemeral per-student Lab containers by internal IP / # manage firewall rules on the host network namespace. services: postgres: image: postgres:16-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: rootnode_platform ports: - "5433:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 3s retries: 10 egress-proxy: build: ./egress-proxy network_mode: host provisioner: build: ./provisioner network_mode: host cap_add: - NET_ADMIN - NET_RAW volumes: - /var/run/docker.sock:/var/run/docker.sock environment: PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5433/rootnode_platform PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default} PROVISIONER_PORT: "4100" LAB_IMAGE_REF: ${LAB_IMAGE_REF:-rootnode-academy:test} EGRESS_PROXY_PORT: "3128" DNS_RESOLVER: "127.0.0.11" NO_PROXY_CIDRS: "10.42.0.0/16,10.43.0.0/16" depends_on: postgres: condition: service_healthy api: build: context: .. dockerfile: platform/api/Dockerfile network_mode: host # needs to reach the Provisioner at localhost:4100 (host-networked); # port 4300 is directly the host's port under host networking, no # `ports:` mapping needed (and compose rejects one with this mode). environment: # localhost:5433, not the "postgres" compose hostname — network_mode: host means this # container is on the host's network namespace, not the compose bridge network, so # compose's internal DNS doesn't resolve here (same reason Provisioner uses localhost too). PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5433/rootnode_platform PROVISIONER_URL: "http://localhost:4100" PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default} # Phase 5B — the website/commerce integration boundary's shared secret (see # docs/WEBSITE_ENROLLMENT_INTEGRATION.md). Optional in dev: unset means # POST /api/integrations/enrollments refuses every request (fails closed), which is fine # for anyone not exercising that boundary locally. INTEGRATION_SHARED_SECRET: ${INTEGRATION_SHARED_SECRET:-} # Phase 5C — activation/reset email links are built from this origin (see # docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §7). The Gateway's own dev URL, since that's # what a browser actually hits locally. EMAIL_PROVIDER is deliberately left unset here — # it defaults to the dev provider (email/dev-provider.js), which never makes a real # network call no matter what recipient it's given. PUBLIC_APP_ORIGIN: ${PUBLIC_APP_ORIGIN:-http://localhost:4200} EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-RootNode Academy} # Empty/unset by default (fail-safe: dev provider, no real email — see email/provider.js). # Set all four in platform/.env only for a deliberate one-off live-delivery test; unset # again afterward so routine dev/test runs never risk a real send. EMAIL_PROVIDER: ${EMAIL_PROVIDER:-} GOOGLE_SERVICE_ACCOUNT_JSON: ${GOOGLE_SERVICE_ACCOUNT_JSON:-} EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-} GOOGLE_DELEGATED_USER: ${GOOGLE_DELEGATED_USER:-} PLATFORM_API_PORT: "4300" # No ADMIN_PASSWORD anymore (Phase 3A) — instructor accounts live in Postgres, created via # `npm run bootstrap-owner` for the first owner and the admin portal after that. # Empty by default — unset/empty means the exact same dev behavior as before this was # parametrized (dev_reset_token echoed, dev-preview route reachable). Phase 5C's # e2e-phase5c.js sets this to "1" via .env for one test to prove production gating for # real, then unsets it again — see that test's own comment. COOKIE_SECURE: ${COOKIE_SECURE:-} # Phase 9.1 — real-Stripe-TEST-mode local validation. Empty/unset by default (fail-safe: # payment-provider.js defaults to the deterministic TestProvider when PAYMENT_PROVIDER is # unset — the same "dev by default, real provider opt-in" pattern EMAIL_PROVIDER already # uses above). Set all three in platform/.env only for a deliberate real-Stripe-TEST # session; STRIPE_SECRET_KEY must be a TEST key (sk_test_...) — never a live one. PAYMENT_PROVIDER: ${PAYMENT_PROVIDER:-} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} # TRUST_PROXY: "1" # set once fronted by a trusted reverse proxy depends_on: postgres: condition: service_healthy provisioner: condition: service_started gateway: build: ./gateway network_mode: host environment: GATEWAY_PORT: "4200" PROVISIONER_URL: "http://localhost:4100" PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default} PLATFORM_API_URL: "http://localhost:4300" depends_on: - provisioner - api