Files
register-referentie/infra/docker-compose.local.yml
T
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

766 lines
30 KiB
YAML
Raw 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.
# LOCAL development stack — runs with a plain `docker compose up`, no make / no
# external seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
# macOS, or rootless Podman on Linux).
#
# Self-seeding (S-B04, #110, ADR-0020): unlike the CI stack — where the verify-* scripts seed the
# zaaktype and register the NRC abonnement at test time — this stack does that itself, via one-shot
# init containers (local-seed, nrc-subscribe) + a DMN deploy in flowable-init, so a fresh bring-up
# completes the whole flow with no manual steps. `make verify-local` asserts it.
#
# docker compose -f infra/docker-compose.local.yml up -d --build # podman
# docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop
# docker compose -f infra/docker-compose.local.yml down --volumes
#
# It is identical to infra/docker-compose.yml EXCEPT that the three config inputs
# (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are **bind-mounted** from
# the repo instead of being streamed into external volumes by infra/seed-config.sh.
# Bind mounts work here because a local daemon can see your working directory —
# the seed dance only exists for the containerized CI runner, where it can't. See
# docs/runbooks/gitea-actions-gotchas.md.
#
# `infra/docker-compose.yml` remains the CI-canonical stack; keep the two in sync.
#
# Port map (host):
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
# 8100 ACL · 8130 Domain · 8180 Keycloak (all admin: admin / admin — dev only)
# 8140 self-service portal · 8141 openbaar register · 8142 behandel portal
#
# Portal OIDC on the HOST: browse the portals at their 8140/8141/8142 ports and log in via
# Keycloak on localhost:8180 (KC_HOSTNAME below pins the issuer there; the BFF still validates
# in-network via keycloak:8080). Test users are in docs/synthetic-data.md.
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:
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) — idle workers pressure the runner; the
# -init/-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 twin). See ADR-0007.
NOTIFICATIONS_DISABLED: "false"
OPENZAAK_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENZAAK_SUPERUSER_EMAIL: admin@localhost
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
# Bind mount (`:z` relabels for SELinux on Linux; a no-op on Docker Desktop).
volumes:
- ./openzaak/setup_configuration:/app/setup_configuration:ro,z
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:
# Migrations + setup_configuration (S-01-c): the JWT credential, Autorisaties-API
# delegation, and the `zaken` kanaal that let OpenZaak publish. Config is
# bind-mounted here (this twin is the local/no-make path). See ADR-0007.
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"
NOTIFICATION_SEC_INTERVAL: "5"
command: /setup_configuration.sh
volumes:
- ./opennotificaties/setup_configuration:/app/setup_configuration:ro,z
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 scheduled notifications to subscribers — required for
# delivery, 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"
# Pin the frontend/issuer URL to the host-published address so a browser on the host and the
# tokens it gets both use localhost:8180. KC_HOSTNAME_BACKCHANNEL_DYNAMIC lets in-network
# callers (the BFF via keycloak:8080) still resolve token/jwks endpoints to their request host,
# so the BFF validates the localhost:8180 issuer while fetching keys over the compose network.
KC_HOSTNAME: http://localhost:8180
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
ports:
- "8180:8080"
volumes:
- ./keycloak/realms:/opt/keycloak/data/import:ro,z
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"
volumes:
- ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z
- ../workflows/diploma-eligibility.dmn:/work/diploma-eligibility.dmn:ro,z
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 registratie process's DMN service task then resolves the decision across
# deployments by key (S-13, ADR-0016). Without this the WachtOpDocumenten completion 404s on the
# missing decision and the case never reaches Beoordelen (S-B04). Both steps are idempotent.
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]
# ── Local bootstrap: seed the zaaktype + wire the ACL (S-B04, #110, ADR-0020) ─────────────────
# The zaaktype UUID is assigned by OpenZaak at creation, so it can't be a static value in this
# file. This one-shot seeds + publishes the BIG zaaktype (and the Diploma informatieobjecttype)
# and writes their server-assigned URLs into a shared volume as acl.env, which the ACL sources on
# startup (below). It is the local-stack equivalent of what infra/run-domain-check.sh does for CI.
# Reaches OpenZaak by its container IP because a single-label host fails OpenZaak's URLValidator.
local-seed:
image: docker.io/library/python:3-slim
restart: "no"
volumes:
- ./openzaak/seed_catalogus.py:/work/seed_catalogus.py:ro,z
- ./local/seed-zaaktype.sh:/work/seed-zaaktype.sh:ro,z
- seed-env:/out
command: ["sh", "/work/seed-zaaktype.sh"]
depends_on:
openzaak:
condition: service_healthy
networks: [cg]
# ── ACL ──────────────────────────────────────────────────────────────────
acl:
build:
context: ../services/acl
dockerfile: Dockerfile
image: register-referentie/acl:dev
# The ACL discovers its zaaktype + informatieobjecttype URLs from the Catalogi API by the business
# keys below (S-27, ADR-0021), so no URL is injected. It still needs its OpenZaak BaseUrl pointed at
# a URL-valid host (OpenZaak rejects a single-label host like `openzaak` on zaak-create), so the
# local-seed one-shot writes that IP base into seed-env:/seed/acl.env, which the entrypoint sources
# (set -a) before the app starts. A runtime-generated env file is why we override the entrypoint here
# rather than use `env_file:` (which compose reads at parse time, before the seed has run).
entrypoint: ["/bin/sh", "-c", "set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll"]
environment:
Acl__OpenZaak__BaseUrl: http://openzaak:8000/ # placeholder; seed-env/acl.env supplies the IP base
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
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"
volumes:
- seed-env:/seed:ro
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
openzaak:
condition: service_healthy
local-seed:
condition: service_completed_successfully
networks: [cg]
# ── BFF ──────────────────────────────────────────────────────────────────
bff:
build:
context: ../services/bff
dockerfile: Dockerfile
image: register-referentie/bff:dev
environment:
# Reach Keycloak over the compose network for metadata/keys; the discovered issuer is the
# host-pinned localhost:8180 (KC_HOSTNAME above), which is what browser tokens carry — so
# validation matches without the BFF ever needing to resolve localhost:8180 itself.
Keycloak__Authority: http://keycloak:8080/realms/digid
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api: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]
# ── BIG Domain Service (S-05) ─────────────────────────────────────────────
domain:
build:
context: ../services/domain
dockerfile: Dockerfile
image: register-referentie/domain:dev
environment:
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]
# ── Read projection (S-06) ────────────────────────────────────────────────
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]
event-subscriber:
build:
context: ..
dockerfile: services/event-subscriber/Dockerfile
image: register-referentie/event-subscriber:dev
environment:
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
# The subscriber enriches the projection with each zaak's reference by asking the ACL — the only
# code allowed to read ZGW (§8.1, #78). Required: startup throws without it (parity with the
# canonical compose).
Acl__BaseUrl: http://acl:8080/
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]
# ── Local bootstrap: register the NRC abonnement (S-B04, #110, ADR-0020) ──────────────────────
# Without a subscription, OpenZaak's notifications reach NRC and are delivered nowhere, so the
# projection (and the openbaar register) stay empty. This one-shot registers an abonnement on the
# `zaken` kanaal pointing at the event-subscriber's /notifications callback — the CI equivalent is
# infra/verify-notification-driver.py. The callback uses the event-subscriber's container IP (a
# single-label host fails NRC's URLValidator). It is a leaf (nothing depends on it), so it can wait
# for the event-subscriber without creating a cycle with the ACL bootstrap.
nrc-subscribe:
image: docker.io/library/python:3-slim
restart: "no"
volumes:
- ./local/register-abonnement.py:/work/register-abonnement.py:ro,z
environment:
NRC_BASE: http://nrc-web:8000
SINK_HOST: event-subscriber
SINK_PORT: "8080"
SINK_AUTH: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
command: ["python", "/work/register-abonnement.py"]
depends_on:
nrc-web:
condition: service_healthy
event-subscriber:
condition: service_started
networks: [cg]
projection-api:
build:
context: ..
dockerfile: services/projection-api/Dockerfile
image: register-referentie/projection-api:dev
environment:
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]
# ── Portals (S-08/S-09/S-12) ──────────────────────────────────────────────
# Caddy serves each Angular app and reverse-proxies its endpoint group to the BFF (same-origin).
# The images bake config.json with the compose authority (keycloak:8080), which a HOST browser
# can't resolve — so here we bind-mount a config.json pointing at the host-published localhost:8180
# (matching KC_HOSTNAME). openbaar is anonymous and needs no config.
self-service:
build:
context: ..
dockerfile: apps/self-service/Dockerfile
image: register-referentie/self-service:dev
ports:
- "8140:80"
volumes:
- ./local-config/self-service.config.json:/usr/share/caddy/config.json:ro,z
healthcheck:
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]
openbaar:
build:
context: ..
dockerfile: apps/openbaar/Dockerfile
image: register-referentie/openbaar:dev
ports:
- "8141:80"
healthcheck:
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]
behandel:
build:
context: ..
dockerfile: apps/behandel/Dockerfile
image: register-referentie/behandel:dev
ports:
- "8142:80"
volumes:
- ./local-config/behandel.config.json:/usr/share/caddy/config.json:ro,z
healthcheck:
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) — bind-mounted config (local variant) ──────────
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-local
# 1 uWSGI worker, not the image default of 4×4 (#144) — idle workers starve the CI runner.
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
volumes:
- ./objecttypen/setup_configuration:/app/setup_configuration:ro,z
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-local
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 (local variant) ─
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:
- ./objecttypen-registerrecord:/config:ro,z
depends_on:
objecttypen:
condition: service_healthy
networks: [cg]
# ── Objecten API (S-18b) — bind-mounted config (local variant) ─────────────
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-local
# 1 uWSGI worker, not the image default of 4×4 (#144) — idle workers starve the CI runner.
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
volumes:
- ./objecten/setup_configuration:/app/setup_configuration:ro,z
depends_on:
objecten-db:
condition: service_healthy
objecten-redis:
condition: service_started
objecttypen:
condition: service_healthy
networks: [cg]
objecten:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: *objecten-env-local
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-local
command: /celery_worker.sh
depends_on:
objecten-init:
condition: service_completed_successfully
networks: [cg]
volumes:
oz-db:
nrc-db:
flowable-db:
projection-db:
objecttypen-db:
objecten-db:
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
seed-env:
networks:
cg: