feat(infra): dockerised full-stack dev mode — apps compose profile (ADR-0030)
CI / scan (pull_request) Successful in 3m46s
CI / commits (pull_request) Successful in 3m46s
CI / check (pull_request) Successful in 4m4s
CI / a11y (pull_request) Successful in 4m25s
Docs site / build (pull_request) Successful in 4m37s
CI / perf (pull_request) Successful in 8m43s

Add an 'apps' Compose profile that runs the three Nx dev servers
(portal-bff, portal-shell, portal-admin) from a shared Dockerfile.dev,
so the whole stack boots with 'dev.sh up apps' and no native Node/pnpm.

- Dockerfile.dev: node:24-bookworm + corepack (pnpm from packageManager
  at runtime), NX_DAEMON=false. No build-time COPY/install.
- dev-entrypoint.sh: BFF runs prisma generate + migrate deploy then
  serves; SPA services go straight to nx serve.
- dev.compose.yml: one-shot apps-deps installs into a shared
  node_modules volume once (apps gate on its completion, no install
  race); repo bind-mounted, node_modules + .nx in named volumes; BFF
  reuses its own .env (required:false) with host URLs overridden to
  Compose service names.
- dev.sh: apps added to ALL_PROFILES + usage.
- Docs: which-mode-when table (setup doc), apps section (infra README),
  CLAUDE.md roll-up. ADR-0030 flipped to accepted.

Dev-only; production images stay with the ADR-0028 Container Registry
work. Additive + profile-gated — native and devcontainer flows
unchanged. Full-boot validation pending on a Docker host (see PR body).
This commit is contained in:
Julien Gautier
2026-05-29 19:46:13 +02:00
parent 8a2a99c351
commit 7ab43125a1
9 changed files with 216 additions and 14 deletions
+32 -8
View File
@@ -143,14 +143,16 @@ Old, no-longer-referenced images can be reaped during the periodic `docker syste
A Docker Compose recipe spinning up the runtime services the BFF and ADRs assume — Postgres, Redis, OpenTelemetry Collector — plus optional viewers / tooling (pgweb, Jaeger UI, Caddy serve-static) gated behind Compose profiles. Designed to start in a single command on a contributor's WSL2 / Linux / macOS host.
| File | Role |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| [`local/dev.sh`](local/dev.sh) | Convenience wrapper around `docker compose` — see "Convenience script" below |
| [`local/dev.compose.yml`](local/dev.compose.yml) | Service definitions: postgres, redis, otel-collector, plus pgweb / jaeger / caddy behind profiles |
| [`local/.env.example`](local/.env.example) | Credentials + ports template (copy to `.env`, which is git-ignored) |
| [`local/init/postgres/01-init.sql`](local/init/postgres/01-init.sql) | Bootstrap SQL for ADR-0013: audit roles + schema, applied on first boot only |
| [`local/otel-collector.yaml`](local/otel-collector.yaml) | Collector pipeline: OTLP receivers → batch → debug exporter (always) + forward to Jaeger when active |
| [`local/Caddyfile`](local/Caddyfile) | Reverse-proxy config for the `serve-static` profile — per-locale SPA fallback + smart `/` redirect (ADR-0019) |
| File | Role |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| [`local/dev.sh`](local/dev.sh) | Convenience wrapper around `docker compose` — see "Convenience script" below |
| [`local/dev.compose.yml`](local/dev.compose.yml) | Service definitions: postgres, redis, otel-collector, plus pgweb / jaeger / caddy / the `apps` dev servers behind profiles |
| [`local/Dockerfile.dev`](local/Dockerfile.dev) | Dev-only image (Node 24 + corepack) shared by the three `apps`-profile dev servers (ADR-0030) |
| [`local/dev-entrypoint.sh`](local/dev-entrypoint.sh) | Entrypoint for the `apps` services: BFF runs `prisma generate` + `migrate deploy`, then each runs `nx serve` |
| [`local/.env.example`](local/.env.example) | Credentials + ports template (copy to `.env`, which is git-ignored) |
| [`local/init/postgres/01-init.sql`](local/init/postgres/01-init.sql) | Bootstrap SQL for ADR-0013: audit roles + schema, applied on first boot only |
| [`local/otel-collector.yaml`](local/otel-collector.yaml) | Collector pipeline: OTLP receivers → batch → debug exporter (always) + forward to Jaeger when active |
| [`local/Caddyfile`](local/Caddyfile) | Reverse-proxy config for the `serve-static` profile — per-locale SPA fallback + smart `/` redirect (ADR-0019) |
### First-time setup
@@ -191,6 +193,7 @@ Run `./infra/local/dev.sh help` for the full reference. Cheat-sheet:
| `./infra/local/dev.sh up dbtools` | Core + pgweb |
| `./infra/local/dev.sh up observability` | Core + Jaeger |
| `./infra/local/dev.sh up serve-static` | Core + Caddy serving `dist/.../browser/` per ADR-0019 |
| `./infra/local/dev.sh up apps` | Core + the three Nx dev servers in Docker (ADR-0030) |
| `./infra/local/dev.sh down` | Tear down the whole stack (every profile in scope) |
| `./infra/local/dev.sh down -v` | Tear down + wipe named volumes (incl. audit-roles bootstrap) |
| `./infra/local/dev.sh stop pgweb` | Stop one service (containers stay around) |
@@ -202,6 +205,27 @@ Anything not matching one of the named verbs is passed through to `docker compos
If you prefer to call `docker compose` directly, every example below shows the raw command alongside the script form.
### Dockerised app dev mode — `apps` profile (ADR-0030)
The `apps` profile runs the three Nx dev servers **in Docker**, so a contributor can bring up the whole stack without installing Node / pnpm natively:
```bash
./infra/local/dev.sh up apps # infra + portal-bff:3000 + portal-shell:4200 + portal-admin:4300
```
How it works (see [ADR-0030](../docs/decisions/0030-dockerised-dev-mode.md)):
- A single [`Dockerfile.dev`](local/Dockerfile.dev) (Node 24 + corepack) backs all three services — one image, one install for the monorepo.
- The repo is bind-mounted for hot reload; `node_modules` and the Nx cache live in named volumes (`apf-portal-app-node-modules`, `apf-portal-app-nx-cache`) so the container's native modules are never shadowed by the host's.
- A one-shot `apps-deps` service runs `pnpm install` once into the shared volume; the three servers gate on its completion, avoiding a three-way install race.
- The BFF entrypoint runs `prisma generate` + `prisma migrate deploy` before serving.
**Prerequisite — the BFF still needs its secrets.** No native toolchain is required, but `apps/portal-bff/.env` (Entra / session / jwks config) must exist, same as native dev (`cp apps/portal-bff/.env.example apps/portal-bff/.env` then fill it). The host-specific URLs (`DATABASE_URL` / `REDIS_URL` / OTel endpoint) are overridden automatically to the Compose service names — you don't edit those for the container. SPA-only work (`up portal-shell`) doesn't need the BFF env.
**Port note.** The SPA dev servers default to 4200 / 4300 — 4200 is the same port the `serve-static` profile uses. Don't run `apps` and `serve-static` together, or set `SHELL_PORT` in `infra/local/.env`.
The three dev modes (native `nx serve`, devcontainer, this `apps` profile) and when to use each are summarised in [docs/setup/01-dev-debian-vm-setup.md](../docs/setup/01-dev-debian-vm-setup.md).
### Service endpoints (defaults)
| Service | Host port | Purpose |
+35
View File
@@ -0,0 +1,35 @@
# Dev-only image for the dockerised full-stack dev mode (ADR-0030).
#
# One image serves all three Nx apps (portal-bff / portal-shell /
# portal-admin) — the monorepo means a single install. The image is
# intentionally minimal: Node + corepack, nothing copied in. The repo
# is bind-mounted at runtime and dependencies install into a named
# volume (see dev.compose.yml `apps` profile), so node_modules — with
# its native modules built for THIS image — is never shadowed by the
# host's.
#
# NOT a production image. Production artefacts are out of scope per
# ADR-0030 and tracked against the ADR-0028 Container Registry work.
FROM node:24-bookworm
# corepack ships with Node 24. Enabling it lets pnpm resolve from the
# `packageManager` field in package.json at runtime — no version pinned
# here, so this image never drifts from the repo's pinned pnpm.
RUN corepack enable
# Nx + Angular CLI memory ceiling — matches the devcontainer (.devcontainer/
# devcontainer.json). 4 GB avoids OOM on large `nx` graph work.
ENV NODE_OPTIONS=--max-old-space-size=4096
# Nx daemon is per-container and would contend across the three app
# containers sharing one workspace bind-mount — disable it. Slight
# task-graph cost, robust behaviour.
ENV NX_DAEMON=false
WORKDIR /workspace
# No COPY / no install at build time: the repo is bind-mounted and the
# `apps-deps` one-shot service installs into the node_modules volume on
# first boot. Build context stays tiny (just infra/local/) — nothing is
# copied. Invoked via `bash` so it does not depend on the bind-mounted
# script keeping its executable bit.
ENTRYPOINT ["bash", "/workspace/infra/local/dev-entrypoint.sh"]
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Shared entrypoint for the ADR-0030 `apps` Compose profile services.
#
# Behaviour is driven by env + the passed command:
# - The `apps-deps` one-shot service runs `pnpm install` as its
# command; this entrypoint just execs it (APF_ROLE unset → no
# prisma). It populates the shared node_modules volume once, so
# the app services don't race three concurrent installs.
# - The BFF service sets APF_ROLE=bff → this entrypoint runs
# `prisma generate` + `prisma migrate deploy` (client + committed
# migrations) before serving. `migrate deploy`, never `migrate
# dev` — a container never authors migrations.
# - The SPA services (portal-shell / portal-admin) leave APF_ROLE
# unset → straight to `exec "$@"` (nx serve).
set -euo pipefail
cd /workspace
if [[ "${APF_ROLE:-}" == "bff" ]]; then
echo "[dev-entrypoint] BFF: prisma generate"
pnpm exec prisma generate
echo "[dev-entrypoint] BFF: prisma migrate deploy"
pnpm exec prisma migrate deploy
fi
echo "[dev-entrypoint] exec: $*"
exec "$@"
+93
View File
@@ -34,6 +34,23 @@
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
@@ -189,11 +206,87 @@ services:
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:
+6 -1
View File
@@ -16,7 +16,7 @@ COMPOSE_FILE="${SCRIPT_DIR}/dev.compose.yml"
# Profiles defined in dev.compose.yml. Keep in sync if a new profile
# is added.
ALL_PROFILES=(dbtools observability serve-static)
ALL_PROFILES=(dbtools observability serve-static apps)
# Build "--profile p1 --profile p2 …" as separate arguments.
build_all_profile_flags() {
@@ -60,6 +60,10 @@ Commands:
dbtools core + pgweb
observability core + jaeger
serve-static core + caddy (production-build reverse proxy)
apps core + the three Nx dev servers in
Docker — no native Node/pnpm needed
(ADR-0030). portal-bff:3000,
portal-shell:4200, portal-admin:4300.
Multiple targets allowed (e.g. `up dbtools observability`).
down [-v] Tear the stack down. Always runs with every
@@ -87,6 +91,7 @@ Commands:
Examples:
./infra/local/dev.sh up
./infra/local/dev.sh up all
./infra/local/dev.sh up apps
./infra/local/dev.sh up observability
./infra/local/dev.sh down -v
./infra/local/dev.sh stop pgweb