Files
register-referentie/infra/docker-compose.yml
not 1dd8bd4e1b
CI / lint (push) Successful in 1m17s
CI / build (push) Successful in 1m12s
CI / unit (push) Successful in 1m26s
CI / frontend (push) Successful in 2m58s
CI / mutation (push) Successful in 9m1s
CI / verify-stack (push) Successful in 8m53s
S-24/#25 · Helm chart + Kubernetes deployment, and Caddy for the portals (#166) (#167)
## What & why

Two changes, made and verified together on a real cluster.

**S-24 / #25 — a Helm chart for the platform.** One chart, `infra/helm/big-reference`,
whose `values.yaml` is a near-literal transcription of `infra/docker-compose.yml`, rendered
by three generic templates (Deployment, Job, Service) over a `workloads` map. Adding a
service is a values edit. `make k8s-lint` renders and schema-checks the whole stack without
a cluster. The issue asked for a *sketch*; this is deployed and verified end to end (see
below), which is more than it asked for — the part it asked for that is **not** here is the
production-posture write-up (HA, secrets, backup), see Known gaps.

**#166 — Caddy replaces nginx in the portals.** nginx resolves a variable `proxy_pass`
upstream itself, using only the `resolver` directive and never `/etc/resolv.conf`'s search
domains. That had cost two workarounds in one script: rewriting the resolver address for
rootless podman, and injecting a full FQDN so the bare `bff` name could resolve on
Kubernetes. Caddy dials per request through the system resolver, so `reverse_proxy
bff:8080` works on every engine unchanged; `apps/portal-nginx-resolver.sh` and the chart's
`BFF_HOST` env are deleted.

Closes #25
Closes #166

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation — twice: the Caddyfile contract test
      before the Caddyfiles, `make k8s-lint` before the chart.
- [x] Implementation makes the test pass.
- [x] Conventional Commits referencing the issues (`refs #25` / `refs #166`).
- [ ] CI green — awaiting the run on this PR (`make k8s-lint`, `dotnet format` and the new
      unit self-check pass locally; the compose e2e and mutation lanes are CI's).
- [ ] `docker compose up` from a fresh clone reaches green health checks within 3 minutes —
      the portal images were rebuilt and verified standalone, but a full `make up` run has
      not been done on this branch. Please confirm in review or let CI's smoke test speak.
- [x] Docs updated — `docs/runbooks/kubernetes-talos.md` (new), `frontend-decisions.md`,
      `demo-script.md`, and the docs that named nginx.
- [x] ADR added — ADR-0033 (chart) and ADR-0034 (Caddy).
- [ ] Demo note in `docs/demo-script.md` — not added: the deployment target is not a
      user-visible slice, and the Caddy swap is invisible to the demo script beyond the
      wording fix included here.

## How it was verified

Brought up from scratch on a single-node Talos v1.14.0 VM (6 vCPU / 10 GB, virtio disk)
under virt-manager: **29 pods ready and four bootstrap Jobs complete in under three
minutes, zero restarts**, using ~4.4 GB of the VM's 10 GB.

- Full Common Ground path: portal Caddy → BFF → domain → Flowable → ACL → OpenZaak +
  Objecten → NRC → event-subscriber → projection → public register (`INGEDIEND`, reference
  matching the submitted registration).
- Werkbak read with an MFA'd medewerker token → 200.
- The browser flow driven with Playwright against `http://localhost:30140`: secure context,
  `crypto.subtle` present, Keycloak form reached, login completed, **no console errors**.
- Routing checked against a stub BFF: SPA fallback serves deep links, each portal proxies
  its own groups, and a portal does *not* proxy a neighbour's group.

## Notes for reviewers

Three bugs this shook out, each fixed at the cause rather than the symptom:

1. **`command` vs `args`.** Compose's `command:` replaces the image CMD; Kubernetes'
   replaces the ENTRYPOINT. Transcribing one to the other broke every upstream image that
   relies on its entrypoint — postgres refused to run as root, Keycloak tried to exec
   `start-dev`. The chart now `fail`s at render time on `command`.
2. **Concurrent migrations.** Both `/setup_configuration.sh` and `/start.sh` run
   `manage.py migrate`; compose serialises them with `depends_on`, Kubernetes has no such
   edge, so the init Job and its web pod raced (`relation "zgw_consumers_service" already
   exists`). The four Django services now do both steps in order in the web pod — which
   also deletes four workloads.
3. **`emptyDir` databases are wiped by any pod-template change.** `make k8s-reseed` now
   also restarts `event-subscriber` and `projection-api`, which create the projection
   schema on start and otherwise keep writing to a schema-less database.

Known gaps / follow-ups:

- **Secrets.** `values.yaml` carries the dev credentials in plain text (`admin/admin`, the
  ZGW client secret, the two Objecten tokens) and the chart has no `Secret` objects. Fine
  for a laptop demo, and exactly what #25's "production posture" ADR should address — I
  suggest a follow-up issue rather than stretching this PR.
- **No CI gate for the chart yet.** `make k8s-lint` exists but is not wired into
  `.gitea/workflows/ci.yaml`, and nothing enforces that the chart and the compose file stay
  in step. Worth a small follow-up.
- **This is two slices in one PR.** They were built and verified together and the diff is
  entangled (the chart was written against Caddy from the start), so splitting now would
  mean re-creating an nginx-shaped chart to throw away. Happy to split if you'd rather.
- **Rebased onto #161** (merged as #165) rather than merged, to keep the history linear.
  One conflict, in the `unit:` target where both branches add a self-check line — resolved
  by keeping both. #161's `infra/host-browser.yml` arrived with
  `/usr/share/nginx/html/config.json` and is fixed to `/usr/share/caddy/` inside the
  `feat(portals)` commit, so no commit on this branch leaves that overlay pointing at a
  path the images no longer have.Reviewed-on: #167
2026-09-10 08:53:58 +00:00

861 lines
34 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Development stack — boots all infra services plus the ACL and BFF.
#
# Consolidates infra/openzaak/, infra/opennotificaties/, infra/keycloak/,
# and infra/flowable/ and adds the ACL and BFF services.
#
# Port map (host):
# 8000 OpenZaak ZGW API (admin: admin / admin)
# 8001 Open Notificaties (admin: admin / admin)
# 8080 BFF GET /health → Healthy
# 8090 Flowable REST http://localhost:8090/flowable-rest/service/
# 8100 ACL GET /health → Healthy POST /zaken
# 8110 Event Subscriber GET /health → Healthy POST /notifications
# 8120 projection-api GET /health → Healthy GET /register
# 8180 Keycloak (admin: admin / admin)
#
# docker compose -f infra/docker-compose.yml up -d --build --wait
#
# After first boot, seed + publish the BIG catalogus:
# OZ_PUBLISH=1 python infra/openzaak/seed_catalogus.py
# The ACL discovers the zaaktype by identificatie (S-27, ADR-0021), so there is no URL to inject —
# just point its BaseUrl at an OpenZaak host OpenZaak accepts on zaak-create (a container IP; a
# single-label host is rejected):
# ACL_OPENZAAK_BASEURL=http://<openzaak-ip>:8000/ docker compose -f infra/docker-compose.yml up -d acl
services:
# ── OpenZaak (S-01) ──────────────────────────────────────────────────────
oz-db:
image: docker.io/postgis/postgis:17-3.5
environment:
POSTGRES_USER: openzaak
POSTGRES_PASSWORD: openzaak
POSTGRES_DB: openzaak
command: postgres -c max_connections=300
volumes:
- oz-db:/var/lib/postgresql/data
healthcheck:
# pg_isready only checks TCP; the second clause verifies PostGIS is installed
# so oz-init migrations can safely start (avoids race on cold container start).
test: ["CMD-SHELL", "pg_isready -U openzaak -d openzaak && psql -U openzaak -d openzaak -c 'SELECT PostGIS_Version();' -q 2>/dev/null"]
interval: 5s
timeout: 5s
retries: 30
start_period: 15s
networks: [cg]
oz-redis:
image: docker.io/library/redis:7
networks: [cg]
oz-init:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: &oz-env
# 1 uWSGI worker, not the image default of 4×4 (#147, same lever as #145): OpenZaak serves
# single-request smoke checks here and is not load-tested, so 4 idle Django workers just pin
# ~800 MB and pressure the shared runner. The -init (setup_configuration) and -celery containers
# share this anchor and ignore it — they don't run uwsgi.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: oz-db
DB_NAME: openzaak
DB_USER: openzaak
DB_PASSWORD: openzaak
IS_HTTPS: "no"
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: oz-redis:6379/0
CACHE_AXES: oz-redis:6379/0
CELERY_BROKER_URL: redis://oz-redis:6379/1
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
DISABLE_2FA: "true"
# Publish notifications to NRC (always present in this full stack). The NRC
# service + notifications_config are provisioned by setup_configuration
# (infra/openzaak/setup_configuration/data.yaml). See ADR-0007 / S-01-c.
NOTIFICATIONS_DISABLED: "false"
OPENZAAK_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENZAAK_SUPERUSER_EMAIL: admin@localhost
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
# data.yaml is streamed into this external volume by infra/seed-config.sh
# before start (bind mounts don't reach sibling containers on the CI runner).
volumes:
- oz-config:/app/setup_configuration:ro
depends_on:
oz-db:
condition: service_healthy
oz-redis:
condition: service_started
networks: [cg]
openzaak:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: *oz-env
healthcheck:
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
ports:
- "8000:8000"
depends_on:
oz-init:
condition: service_completed_successfully
networks: [cg]
oz-celery:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: *oz-env
command: /celery_worker.sh
depends_on:
oz-init:
condition: service_completed_successfully
networks: [cg]
# ── Open Notificaties / NRC (S-01-c) ─────────────────────────────────────
nrc-db:
image: docker.io/postgis/postgis:17-3.5
environment:
POSTGRES_USER: opennotificaties
POSTGRES_PASSWORD: opennotificaties
POSTGRES_DB: opennotificaties
command: postgres -c max_connections=300
volumes:
- nrc-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U opennotificaties -d opennotificaties"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
nrc-redis:
image: docker.io/library/redis:7
networks: [cg]
nrc-init:
# Plain base image — nrc-init runs migrations only (see command below), so it
# needs no baked config.
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: &nrc-env
# 1 uWSGI worker, not the image default of 4×4 (#147) — see the oz-env note above.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: nrc-db
DB_NAME: opennotificaties
DB_USER: opennotificaties
DB_PASSWORD: opennotificaties
IS_HTTPS: "no"
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: nrc-redis:6379/0
CACHE_AXES: nrc-redis:6379/0
CELERY_BROKER_URL: redis://nrc-redis:6379/1
CELERY_RESULT_BACKEND: redis://nrc-redis:6379/1
DISABLE_2FA: "true"
OPENNOTIFICATIES_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost
RUN_SETUP_CONFIG: "true"
# nrc-beat fires `execute_notifications` this often to drain scheduled
# notifications to subscribers (upstream default 20s). See ADR-0007.
NOTIFICATION_SEC_INTERVAL: "5"
# Runs migrations + setup_configuration (S-01-c): the JWT credential, the
# Autorisaties-API delegation, and the `zaken` kanaal that let OpenZaak publish.
# data.yaml is streamed into rr-nrc-config by infra/seed-config.sh (bind mounts
# don't reach sibling containers on the CI runner). See data.yaml + ADR-0007.
command: /setup_configuration.sh
volumes:
- nrc-config:/app/setup_configuration:ro
depends_on:
nrc-db:
condition: service_healthy
nrc-redis:
condition: service_started
openzaak:
condition: service_healthy
networks: [cg]
nrc-web:
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: *nrc-env
healthcheck:
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
ports:
- "8001:8000"
depends_on:
nrc-init:
condition: service_completed_successfully
networks: [cg]
nrc-celery:
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: *nrc-env
command: /celery_worker.sh
depends_on:
nrc-init:
condition: service_completed_successfully
networks: [cg]
# Celery beat drains the ScheduledNotification rows the API creates on publish
# and hands them to the worker. Without it, notifications are accepted but never
# delivered to subscribers — required, not optional. See ADR-0007.
nrc-beat:
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: *nrc-env
command: /celery_beat.sh
depends_on:
nrc-init:
condition: service_completed_successfully
networks: [cg]
# ── Keycloak (S-02) ──────────────────────────────────────────────────────
keycloak:
image: quay.io/keycloak/keycloak:26.1
command: ["start-dev", "--import-realm"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_HEALTH_ENABLED: "true"
KC_HTTP_ENABLED: "true"
ports:
- "8180:8080"
# realm exports are streamed into this external volume by infra/seed-config.sh.
volumes:
- kc-realms:/opt/keycloak/data/import:ro
networks: [cg]
# ── Flowable (S-03) ──────────────────────────────────────────────────────
flowable-db:
image: docker.io/library/postgres:16
environment:
POSTGRES_USER: flowable
POSTGRES_PASSWORD: flowable
POSTGRES_DB: flowable
volumes:
- flowable-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U flowable -d flowable"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
flowable-rest:
image: docker.io/flowable/flowable-rest:latest
environment:
SPRING_DATASOURCE_DRIVER-CLASS-NAME: org.postgresql.Driver
SPRING_DATASOURCE_URL: jdbc:postgresql://flowable-db:5432/flowable
SPRING_DATASOURCE_USERNAME: flowable
SPRING_DATASOURCE_PASSWORD: flowable
ports:
- "8090:8080"
depends_on:
flowable-db:
condition: service_healthy
networks: [cg]
flowable-init:
image: docker.io/curlimages/curl:latest
restart: "no"
# registratie.bpmn + diploma-eligibility.dmn are streamed into this external volume by
# infra/seed-config.sh.
volumes:
- fl-bpmn:/work:ro
command:
- sh
- -c
- |
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments:
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
# must go via dmn-api. The process's DMN service task then resolves the decision across deployments
# by key (S-13, ADR-0016). Both steps are idempotent (skip if already deployed).
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
echo "diploma-eligibility DMN already deployed; skip"
else
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN"
fi
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
echo "registratie BPMN already deployed; skip"
else
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
fi
depends_on:
flowable-rest:
condition: service_started
networks: [cg]
# ── ACL ──────────────────────────────────────────────────────────────────
acl:
build:
context: ../services/acl
dockerfile: Dockerfile
image: register-referentie/acl:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: acl
# Overridable so verify-domain can point the ACL at the same OpenZaak host that
# owns the seeded zaaktype URL (host-consistent zaak creation, ADR-0009).
Acl__OpenZaak__BaseUrl: ${ACL_OPENZAAK_BASEURL:-http://openzaak:8000/}
Acl__OpenZaak__ClientId: big-reference-seed
Acl__OpenZaak__Secret: insecure-dev-secret-change-me
Acl__Defaults__Bronorganisatie: "517439943"
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
# The ACL resolves the (server-assigned) zaaktype + diploma informatieobjecttype URLs from the
# Catalogi API by these stable business keys (S-27, ADR-0021) — no URL to capture and inject.
# BaseUrl above stays overridable because OpenZaak rejects a single-label host on zaak creation,
# so verify-domain still points the ACL at OpenZaak's container IP.
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a
# static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves
# it by name — lazily, on the first approval, so no depends_on is needed here.
# Dotted host on purpose — see the `objecten.local` alias below (ADR-0029).
Acl__Objecten__BaseUrl: http://objecten.local:8000/
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
Acl__Objecten__ObjecttypeName: RegisterRecord
ports:
- "8100:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
openzaak:
condition: service_healthy
networks: [cg]
# ── BIG Domain Service (S-05) ──────────────────────────────────────────────
# Orchestrates a registration: POST /registrations creates the aggregate and
# starts the registratie Flowable process; a hosted worker acquires the
# OpenZaakAanmaken job, opens a zaak via the ACL and completes it (ADR-0009).
# Talks only to Flowable (Workflow Client, §8.2) and the ACL (§8.1).
domain:
build:
context: ../services/domain
dockerfile: Dockerfile
image: register-referentie/domain:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: domain
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
Flowable__Username: rest-admin
Flowable__Password: test
Acl__BaseUrl: http://acl:8080/
ports:
- "8130:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
acl:
condition: service_healthy
flowable-init:
condition: service_completed_successfully
networks: [cg]
# ── BFF ──────────────────────────────────────────────────────────────────
bff:
build:
context: ../services/bff
dockerfile: Dockerfile
image: register-referentie/bff:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: bff
# The BFF is the portals' only backend; it validates digid tokens and fans out (ADR-0010).
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
# verify token request both use keycloak:8080 to keep the issuer consistent.
Keycloak__Authority: http://keycloak:8080/realms/digid
# Behandelaars authenticate against the medewerker realm; the BFF validates it for /behandel/* (S-12c).
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/
# The beheer catalogus read reaches the ACL directly (S-15a, ADR-0025).
Downstream__Acl__BaseUrl: http://acl:8080/
ports:
- "8080:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
domain:
condition: service_healthy
projection-api:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
# ── Read projection (S-06) ────────────────────────────────────────────────
# One Postgres DB backing the rebuildable read projection (PRD §8.4): the Event
# Subscriber writes it, projection-api reads it. See ADR-0008.
projection-db:
image: docker.io/library/postgres:16
environment:
POSTGRES_USER: projection
POSTGRES_PASSWORD: projection
POSTGRES_DB: projection
volumes:
- projection-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U projection -d projection"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
# Consumes NRC notifications (abonnement callback) and projects zaak-created events
# into register_projection. Build context is the repo root: it shares the read model
# in services/projection-api/Projection.ReadModel.
event-subscriber:
build:
context: ..
dockerfile: services/event-subscriber/Dockerfile
image: register-referentie/event-subscriber:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: event-subscriber
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
# The subscriber enriches the projection with each zaak's reference (identificatie) by asking
# the ACL — the only code allowed to read ZGW (§8.1, #78).
Acl__BaseUrl: http://acl:8080/
# The bearer Open Notificaties must present on the abonnement callback. NRC's
# registration probe expects a 401 without it (ADR-0007). Dev-only token.
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
ports:
- "8110:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 15s
depends_on:
projection-db:
condition: service_healthy
acl:
condition: service_healthy
networks: [cg]
# The read side of the projection. Shares Projection.ReadModel, so build context is root.
projection-api:
build:
context: ..
dockerfile: services/projection-api/Dockerfile
image: register-referentie/projection-api:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: projection-api
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
ports:
- "8120:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 15s
depends_on:
projection-db:
condition: service_healthy
networks: [cg]
# ── Self-Service portal (S-08d) ────────────────────────────────────────────
# Caddy serves the Angular app and reverse-proxies /self-service + /openbaar to the BFF
# (same-origin, no CORS). The Playwright e2e drives it inside this network so the DigiD
# token issuer (keycloak:8080) matches the BFF's authority (ADR-0010).
self-service:
build:
context: ..
dockerfile: apps/self-service/Dockerfile
image: register-referentie/self-service:dev
ports:
- "8140:80"
healthcheck:
# 127.0.0.1, not localhost: keeps the check on the interface Caddy is published on.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
# The openbaar (public) register portal: Caddy serves the Angular app and reverse-proxies
# /openbaar to the BFF. Anonymous — no DigiD, no Keycloak dependency (S-09).
openbaar:
build:
context: ..
dockerfile: apps/openbaar/Dockerfile
image: register-referentie/openbaar:dev
ports:
- "8141:80"
healthcheck:
# 127.0.0.1, not localhost: keeps the check on the interface Caddy is published on.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
networks: [cg]
# The behandel portal: Caddy serves the Angular app and reverse-proxies /behandel to the BFF.
# Behandelaars log in against the Keycloak medewerker realm (ADR-0013; S-12).
behandel:
build:
context: ..
dockerfile: apps/behandel/Dockerfile
image: register-referentie/behandel:dev
ports:
- "8142:80"
healthcheck:
# 127.0.0.1, not localhost: keeps the check on the interface Caddy is published on.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
# The beheer portal: Caddy serves the Angular app and reverse-proxies /beheer to the BFF.
# Beheerders log in against the Keycloak medewerker realm (same realm as behandel, S-15a).
beheer:
build:
context: ..
dockerfile: apps/beheer/Dockerfile
image: register-referentie/beheer:dev
ports:
- "8143:80"
healthcheck:
# 127.0.0.1, not localhost: keeps the check on the interface Caddy is published on.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
# ── Objecttypen API (S-18a) — upstream Maykin image, verbatim ──────────────
# The register's objecttype catalogue. Same shape as the other CG modules: own DB + redis, an
# `-init` that runs setup_configuration (RUN_SETUP_CONFIG → migrate + provision a static API token)
# from the external config volume streamed in by infra/seed-config.sh, and a health-checked web
# service that depends on init completing.
objecttypen-db:
image: docker.io/library/postgres:17-alpine
environment:
POSTGRES_USER: objecttypes
POSTGRES_PASSWORD: objecttypes
POSTGRES_DB: objecttypes
volumes:
- objecttypen-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U objecttypes"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
objecttypen-redis:
image: docker.io/library/redis:7
networks: [cg]
objecttypen-init:
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
environment: &objecttypen-env
# 1 uWSGI worker, not the image default of 4×4: this API only serves single-request smoke
# checks and sits idle during the e2e step — 4 idle Django workers each pin ~200 MB and starve
# the shared CI runner (#144). Init ignores this (it runs setup_configuration, not uwsgi).
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objecttypes.conf.docker
SECRET_KEY: ${OBJECTTYPES_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecttypen-db
DB_NAME: objecttypes
DB_USER: objecttypes
DB_PASSWORD: objecttypes
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: objecttypen-redis:6379/0
CACHE_AXES: objecttypen-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
# data.yaml is streamed into this external volume by infra/seed-config.sh before start.
volumes:
- objecttypen-config:/app/setup_configuration:ro
depends_on:
objecttypen-db:
condition: service_healthy
objecttypen-redis:
condition: service_started
networks: [cg]
objecttypen:
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
environment: *objecttypen-env
healthcheck:
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
ports:
- "8020:8000"
depends_on:
objecttypen-init:
condition: service_completed_successfully
networks: [cg]
# ── RegisterRecord objecttype (S-18c) — API-seeded one-shot ────────────────
# The Objecttypen setup_configuration (3.4.2) can only provision tokens — no declarative objecttype
# step — so this one-shot creates the RegisterRecord objecttype + a published version over the API
# once Objecttypen is healthy (idempotent; ADR-0020 self-seed, ADR-0027 schema). The schema + script
# are streamed into the external config volume by infra/seed-config.sh, like the *-init volumes.
registerrecord-init:
image: docker.io/library/python:3-slim
environment:
OBJECTTYPEN: http://objecttypen:8000
OBJECTTYPEN_TOKEN: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
SCHEMA: /config/registerrecord.schema.json
command: python /config/register.py
volumes:
- registerrecord-config:/config:ro
depends_on:
objecttypen:
condition: service_healthy
networks: [cg]
# ── Objecten API (S-18b) — upstream Maykin image, verbatim ─────────────────
# The authoritative object store. Same shape as Objecttypen (own DB + redis, an `-init` that runs
# setup_configuration from the external config volume, a health-checked web). Two differences: the
# DB is PostGIS (objects carry geometry), and setup_configuration registers the Objecttypen API
# (S-18a) as a trusted service so an object can reference its objecttype.
objecten-db:
image: docker.io/postgis/postgis:17-3.5
environment:
POSTGRES_USER: objects
POSTGRES_PASSWORD: objects
POSTGRES_DB: objects
volumes:
- objecten-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U objects"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
objecten-redis:
image: docker.io/library/redis:7
networks: [cg]
objecten-init:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: &objecten-env
# 1 uWSGI worker, not the image default of 4×4 — see the objecttypen note above (#144).
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objects.conf.docker
SECRET_KEY: ${OBJECTS_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecten-db
DB_NAME: objects
DB_USER: objects
DB_PASSWORD: objects
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: objecten-redis:6379/0
CACHE_AXES: objecten-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
CELERY_BROKER_URL: redis://objecten-redis:6379/1
CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1
# Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC
# service + notifications_config are provisioned by setup_configuration
# (infra/objecten/setup_configuration/data.yaml), and objecten-celery below actually sends
# them — notifications_api_common only queues the task. See ADR-0028 for why S-19a left this
# off until all four pieces existed.
NOTIFICATIONS_DISABLED: "false"
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
# data.yaml is streamed into this external volume by infra/seed-config.sh before start.
volumes:
- objecten-config:/app/setup_configuration:ro
depends_on:
objecten-db:
condition: service_healthy
objecten-redis:
condition: service_started
# Objecten's setup_configuration registers the Objecttypen service; that service only needs to
# exist as config, but wait for Objecttypen to be up so the register is meaningful end to end.
objecttypen:
condition: service_healthy
networks: [cg]
objecten:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: *objecten-env
healthcheck:
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
ports:
- "8021:8000"
depends_on:
objecten-init:
condition: service_completed_successfully
networks:
cg:
# Objecten reflects the *request* Host into the `url` it returns, and
# notifications_api_common publishes that url as the notification's hoofdObject /
# resourceUrl — which NRC types as a URLField, and Django's URLValidator rejects a
# single-label host ("Voer een geldige URL in."). So every caller whose writes must be
# notified addresses Objecten by this dotted alias instead of `objecten` (ADR-0029).
# Reads are unaffected and still use the plain service name.
aliases:
- objecten.local
# The celery worker that actually delivers Objecten's notifications to NRC (S-19b-1, ADR-0029).
# notifications_api_common only schedules the send on transaction commit; without a worker the
# task sits in redis forever and every register write is silently undelivered. Mirrors oz-celery.
# No beat: Objecten is a publisher, not a subscriber — nrc-beat drains the delivery queue.
objecten-celery:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: *objecten-env
command: /celery_worker.sh
depends_on:
objecten-init:
condition: service_completed_successfully
networks: [cg]
# ── Observability backplane (S-16a, ADR-0023) ──────────────────────────────
# Grafana-native stack: Tempo ingests OTLP traces (the .NET services export
# straight to it — no collector hop, S-16b), Prometheus scrapes service
# /metrics (S-16c), and Grafana reads both with datasources auto-provisioned.
# Config is baked into small built images (COPY) rather than streamed into
# external config volumes like the upstream CG modules — these aren't verbatim
# peer images, so a built image is the simpler path that still reaches sibling
# containers on the CI runner. Not in WAIT_SVCS: run-observability-check.sh
# polls Grafana itself, so no in-image healthcheck tool is needed.
tempo:
build:
context: ./observability/tempo
image: register-referentie/tempo:dev
command: ["-config.file=/etc/tempo.yaml"]
# Cap the backplane's footprint so it can't starve the app stack + the Playwright browser on the
# memory-tight CI runner (verify-e2e OOM history, commit d5e5fa2). Generous vs idle (~150M).
mem_limit: 400m
networks: [cg]
prometheus:
build:
context: ./observability/prometheus
image: register-referentie/prometheus:dev
mem_limit: 400m
ports:
- "9090:9090"
networks: [cg]
grafana:
build:
context: ./observability/grafana
image: register-referentie/grafana:dev
mem_limit: 512m
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
GF_AUTH_ANONYMOUS_ENABLED: "true"
ports:
- "3000:3000"
depends_on:
- tempo
- prometheus
networks: [cg]
volumes:
oz-db:
nrc-db:
flowable-db:
projection-db:
objecttypen-db:
objecten-db:
# Config volumes — created and populated out-of-band by infra/seed-config.sh
# (docker cp), because bind mounts don't reach sibling containers on the CI
# runner. `external` keeps the names deterministic; the seed step manages them.
oz-config:
external: true
name: rr-oz-config
nrc-config:
external: true
name: rr-nrc-config
kc-realms:
external: true
name: rr-kc-realms
fl-bpmn:
external: true
name: rr-fl-bpmn
objecttypen-config:
external: true
name: rr-objecttypen-config
registerrecord-config:
external: true
name: rr-registerrecord-config
objecten-config:
external: true
name: rr-objecten-config
networks:
cg: