Run the local stack
AI-generated content
This document was generated by an AI assistant. Verify accuracy before relying on the details.
infra/docker/compose.yaml runs the supporting infrastructure (MongoDB replica set, Keycloak, LocalStack, Mailpit) AND the 9 Spring Boot services. Everything is built from monorepo source — only Docker is required, no AWS credentials, no host-side Java/Maven, no external Maven repositories beyond Maven Central + Spring Milestones.
First-time setup (zero → working SPA)
The shortest path that gets you to a logged-in dev/dev on http://localhost:4200/ showing the home dashboard, in French, against real seeded data.
Prerequisites
- Docker Desktop (or any Docker engine + compose v2). Allocate ≥ 8 GB RAM to the engine — the full stack peaks around 6 GB.
- An Atlas snapshot tarball of the dev cluster — ask the team for a recent
restore-<id>.tar.gz(the file the "Download Snapshot" button produces in the Atlas UI). Drop it ininfra/seeds/— that's wheremake seedlooks first. The directory itself is git-tracked (via.gitkeep) but every*.tar.gzinside is gitignored, so a straygit addwon't commit it. See Seeding the local Mongo from an Atlas dump for the full lookup order. - Read access on the i18n spreadsheet (https://docs.google.com/spreadsheets/d/1pSlwsb6XEZ_uzz-KtNgffFrzEgLR6WcToLvO-_IeVsc). It's currently public, no Google credentials needed.
One-time setup
cd infra
# 1. Create your local .env file from the template and add the i18n vars.
cp .env.example .env.local
chmod 600 .env.local
Open .env.local and uncomment / fill in just the translation block at the bottom (the rest is needed only if you want to point at real AWS dev infra — LocalStack covers everything for local dev):
SPREADSHEET_URL=https://docs.google.com/spreadsheets/d/1pSlwsb6XEZ_uzz-KtNgffFrzEgLR6WcToLvO-_IeVsc/export?format=tsv
CATALOG_GID=2016792665
COMMON_GID=1740544717
DOCUMENTS_GID=1844992713
PERSONS_GID=1558004497
NOTES_GID=1409729206
LISTS_GID=378239050
ACCOUNTING_GID=1607045627
CONTRACTS_GID=1161123401
PARTS_GID=59981804
SEARCH_GID=1022530540
NACE_GID=14446475
TICKET_GID=452972274
JEDI_GID=536707157
ADMIN_GID=1828090766
(Without these, make build-base falls back to empty translation stubs and the SPA shows raw keys instead of French/English text — the rest of the stack still works.)
Boot, seed, login
cd infra
# 2. Build all JARs (10–15 min first time, ~30s on rebuild).
# Fetches translations from the Google Sheet using the env vars above.
make build-base
# 3. Boot infrastructure + every Spring service. Builds the per-service
# images on first run (~1 min), caches afterwards.
make up-services
# 4. Seed the Mongo databases from the Atlas snapshot. Picks up the
# most recent infra/seeds/restore-*.tar.gz automatically (with
# ~/Downloads/ as legacy fallback); pass an explicit path with
# `DUMP=/some/where/restore-XXX.tar.gz` if needed.
make seed
# From the repo root, `pnpm seed` lists the dumps in infra/seeds/ and asks which one to
# restore (`pnpm seed --latest` skips the menu, `pnpm seed DUMP=seeds/restore-XXX.tar.gz`
# names one — path relative to infra/); `pnpm seed:check` is the same menu followed by the
# dry run; `pnpm seed:clear` drops every adb-* database first.
#
# ONE-SHOT ALTERNATIVE: the Claude Code launcher's "adb-stack" configuration
# (.claude/scripts/start-stack.sh) does all three layers in order — containers, then the
# workers, then the front ONCE :8787 answers. Order matters: the front listens after ~15 s and
# the 16 workers need ~70 s cold, so a front started too early proxies to a dead :8787, renders
# raw i18n keys and a technical-error toast, and looks like a crash. The "adb-ui" configuration
# starts the front alone and refuses to run when no backend is up.
#
# SEARCH INDEXES: `make search-indexes` is idempotent and SKIPS an index that already exists,
# so a changed definition needs an explicit update — mongosh's
# `db.<coll>.updateSearchIndex(<name>, { mappings, analyzers })` against the running mongo, then
# poll `getSearchIndexes(<name>)` until it is READY again (seconds).
# The seed also runs `make keycloak-ensure` (infra/scripts/keycloak-ensure-local.sh): the
# local realm gets the `adb-ui-extranet` client, SMTP → mailpit (http://localhost:8025) and
# unmanaged user attributes, without which "Créer un accès extranet" answers 502.
# 5. Open the SPA. Login: dev / dev.
open http://localhost:4200/
You should land on the home dashboard with menu items in French ("Propriétaires", "Locataires", "Mandats", "Sinistres", …). Switch to English from the language flag in the topbar — localStorage.language just flips between "fr" and "en" and reloads.
Day-to-day workflow
| You changed | What to run |
|---|---|
| Backend Java code | make build-base && docker compose -f docker/compose.yaml --profile services up -d --build --no-deps adb-<svc> |
adb-ui Angular code | nothing — wrangler dev watches dist/ and auto-reloads |
compose.yaml env or wiring | docker compose -f docker/compose.yaml --profile services up -d --no-deps adb-<svc> |
| Mongo content | make seed-clear && make seed DUMP=... |
A data migration (packages/data/migrations), or after a seed | pnpm migrate up local (the plan), then the same with --apply — data migrations |
| Keycloak realm-export | docker volume rm adb-local_keycloak-data && make up-services (Keycloak only re-imports on a clean H2 DB) |
make down-all # stop everything (containers stay around, volumes preserved)
make logs-all SERVICE=adb-views # follow logs for one service
make ps # container health
Starting over from scratch
make reset # destructive: prompts for "yes"
# nukes containers + volumes + locally-built
# images, but leaves .env.local and
# infra/seeds/*.tar.gz alone
make reset YES=1 # skip the prompt (CI / scripted use)
make fresh # one-shot: reset + build-base + up-services + seed
make fresh YES=1 # skip the prompt
Use reset when something has gone weird (corrupted mongo replica set, stuck volumes, mismatched JARs) and you want a guaranteed-clean rebuild. Use fresh to chain that with the full setup recipe.
Two-tier model
| Target | What runs | First-run time |
|---|---|---|
make up | mongo, keycloak, localstack, mailpit | ~30 s |
make build-base | Maven build of every JAR (shared adb-local-build image) | ~10–15 min (first time), then cached |
make up-services | runs build-base then all Spring services | first time = build-base + ~1 min, subsequent = ~30 s |
Make-target reference
cd infra
# Just the supporting infra (mongo, keycloak, localstack, mailpit):
make up
# Or full stack (infra + 9 Spring services):
make up-services
make ps # see container health
make logs # follow infra logs
make logs-all # follow infra + service logs (pass SERVICE=adb-x for one)
make down-all # stop everything
# Foreground variants — everything streams to your terminal, Ctrl+C tears it down:
make up-fg
make up-services-fg
# Mongo seeding (see "Seeding the local Mongo from an Atlas dump" below):
make seed # auto-find infra/seeds/restore-*.tar.gz
make seed DUMP=/path/to/restore-XXX.tar.gz
make seed-clear # drop every adb-* db (keeps the volume)
# Nuclear option (see "Starting over from scratch" above):
make reset # tear down + remove volumes + remove images
make fresh # reset + build-base + up-services + seed
How services reach LocalStack
Every Spring service builds its SqsAsyncClient / SnsClient / S3Client via MessagingConfig / AwsS3Config. These read AwsEndpointOverride.fromEnv() — which checks AWS_ENDPOINT_URL and applies it as .endpointOverride(...) on the builder. The compose file sets AWS_ENDPOINT_URL=http://localstack:4566, so locally everything routes there. In production the env var is unset and the SDK targets real AWS as usual. (This is the equivalent of what global AWS_ENDPOINT_URL support gives you in AWS SDK 2.21+; the services still pin SDK 2.17, hence the helper.)
What's running
| Container | Host port | Notes |
|---|---|---|
mongo | 27018 | rs0 single-node replica set; container internal port stays 27017 |
keycloak | 8080 | Realm ADB pre-imported, dev user dev/dev (email aligned with the seeded Pierre Sacco — see Seeding the local Mongo) |
localstack | 4566 | SQS/SNS/S3/Secrets Manager mock |
mailpit | 8025 (UI), 1025 (SMTP) | Catches outbound mail |
adb-persons | 8081 | /persons/actuator/health |
adb-utilities | 8082 | |
adb-files | 8088 | host port 8088 → container 8083 (avoids local port clash) |
adb-contracts | 8084 | |
adb-parts | 8085 | |
adb-aggregates | 8086 | |
adb-accounting | 8089 | does not start locally without a valid Google Cloud service-account JSON in GOOGLE_SHEETS_CLIENT_CREDENTIALS. Skip in local dev unless you need accounting. |
adb-reports | 8090 | actuator endpoint requires auth (returns 401, but server is up) |
adb-views | 8092 | actuator endpoint requires auth (returns 401, but server is up) |
adb-ui | 4200 | served by wrangler dev inside a node:20-bookworm container. First start runs npm install (~3–5 min) then ng build --watch; wrangler picks up changes on rebuild. The entry Worker's config is apps/ui-legacy/wrangler.jsonc (it moved out of adb-ui/ into the pnpm workspace; adb-ui/ is now the Angular app only) |
Verifying it works
# every Spring service (except adb-accounting + adb-ui — see caveats above)
for spec in 8081/persons 8082/utilities 8088/files 8084/contracts \
8085/parts 8086/aggregates 8090/reports 8092/views; do
port="${spec%/*}"; name="${spec#*/}"
curl -fsS "http://localhost:$port/$name/actuator/health" || echo " $port: not yet"
done
# Keycloak realm reachable
curl -fsS http://localhost:8080/realms/adb >/dev/null && echo "keycloak OK"
# LocalStack queues exist (24 queues + 24 DLQs = 48)
aws --endpoint-url http://localhost:4566 sqs list-queues \
--queue-name-prefix local- | jq '.QueueUrls | length'
How the build works
The Java build happens in two layers:
make build-baserunsmvn -DskipTests installover the entire monorepo via the repo-rootpom.xmlaggregator. The output is a Docker image taggedadb-local-build:latestcontaining every spring-boot JAR.- Each service's Dockerfile is a 3-line file:
FROM eclipse-temurin:21-jre-alpine,COPY --from=adb-local-build:latest /workspace/<svc>/.../target/*.jar /app/app.jar, ENTRYPOINT.
This means:
- The Maven dependency download happens once, not per service.
- A code change anywhere triggers a rebuild of
adb-local-build(Maven layer cache helps but isn't perfect). - For very fast iteration on a single service you can
mvn package -pl :<svc> -amon the host (if you have Java + Maven) and rebuild only that container withdocker compose build <svc>.
The aws-maven extension in each service's .mvn/extensions.xml is preserved (CI still uses it to publish to the prod S3 Maven repo) but .mvn/settings.xml no longer references the S3 URLs, so it's a no-op for local builds.
Common issues
- Mongo binds to host port 27018, not 27017. macOS Docker Desktop's
vmnetddriver sometimes holds 27017 even whenlsofshows nothing. - adb-files binds to host port 8088, not 8083. Same reason.
- First run of
make build-basetakes 10–15 minutes while Maven resolves all transitive deps (Spring Boot, Keycloak, AWS SDK, …). Subsequent runs are fast thanks to the BuildKit cache mount on/root/.m2/repository. - adb-accounting fails to start with "Error reading credentials from stream, 'type' field not specified" — the service requires a valid Google Cloud service-account JSON to connect to Google Sheets at startup. Either provide one via
GOOGLE_SHEETS_CLIENT_CREDENTIALSor skip the service for local dev. - adb-ui first start is slow — runs
npm install(~3–5 min) inside the container beforeng build --watchkicks off, thenwrangler devboots. Subsequent restarts skip the install thanks to the namedadb-ui-node-modulesandadb-ui-angular-cachevolumes. The legacy nginx-based Dockerfile (adb-ui/Dockerfile) is not used by the local stack any more. - adb-ui post-login lands on
/denied— the Angular app'sAuthGuardcallsGET /persons/meimmediately after login. If the local Mongo is empty, the call returns 204 and the SPA routes to/denied. Seed the local Mongo from a real Atlas dump first — see below. /persons/mereturns 403 "No JWT token found in request headers" —wrangler dev(workerd) silently strips theAuthorizationheader from outbound subrequests when the originating browser request was credentialed (Cookie + Sec-Fetch-*). The worker forcescredentials: "omit"on the proxied fetch inapps/ui-legacy/src/entry.mjsto keep the bearer untouched. If you re-introduce browser-style request semantics in that proxy, every backend call will start failing with this error.
Seeding the local Mongo from an Atlas dump
The Spring services start with empty databases. The SPA's home page needs at least the dev user's Person and matching Organization to render — without them GET /persons/me returns 204 and AuthGuard routes to /denied. Rather than fabricate fixtures, the local stack imports a real MongoDB Atlas snapshot.
Where to drop the tarball
Recommended: infra/seeds/ — git-tracked directory (via .gitkeep) with all *.tar.gz files gitignored. That's where make seed looks first, so the common workflow is just:
cd infra
cp /wherever/you/got/it/restore-<snapshot-id>.tar.gz seeds/
make seed # auto-picks the most recent
make seed lookup order (the first hit wins):
- The
DUMP=argument — e.g.make seed DUMP=/explicit/path.tar.gz. - The
$ATLAS_DUMP_PATHenv var. - The newest
restore-*.tar.gzininfra/seeds/(gitignored). - The newest
restore-*.tar.gzin~/Downloads/(legacy fallback, convenient for engineers used to the old workflow).
Why infra/seeds/? It keeps the tarball next to the rest of the local-stack artefacts (so it survives make reset, doesn't get lost in your Downloads folder), and the directory's gitignore protects you from ever committing a dump file by accident — only .gitkeep is tracked, every *.tar.gz is ignored.
# Verify the tarball is in place and gitignored:
ls -lh infra/seeds/ # should show your restore-*.tar.gz
git check-ignore -v infra/seeds/restore-*.tar.gz # confirms it's ignored
What the script (infra/scripts/seed-from-atlas-dump.sh) does:
- Extracts the WiredTiger files from the tarball.
- Boots a temporary
mongo:8against them with the embedded replica-set name (the Atlas clusteradb-prodruns MongoDB 8.0.21 — the throwaway container must match to read its WiredTiger physical files directly). - Force-reconfigures it as a single-node primary (Atlas snapshots ship with a stale replica-set config that won't elect a primary on its own).
mongodumps everyadb-*database, thenmongorestore --drops into the stack'smongocontainer.- Tears the temp container down.
The seeded dev identity lands on Pierre Sacco — pierre.sacco@life-connect.fr, organization 5f7325ec5d7c2205ca4688df, with agent / agent-admin / internal / application-admin roles. The Keycloak realm-export pins the dev user's email to that address so personRepository.findOneByUserEmail(...) resolves.
To swap to a different seeded user, change email in the users block of infra/docker/keycloak/realm-export.json, recreate the keycloak container (docker volume rm adb-local_keycloak-data then docker compose up -d keycloak), and clear the SPA's session storage.
Wipe + re-seed if you need a clean slate:
make seed-clear # drops every adb-* database
make seed DUMP=/path/to/another.tar.gz
Why the worker injects an organization-id header
The backend's AdbJwtGrantedAuthoritiesConverter filters the JWT's accesses claim by the organization-id request header — without it the user has zero scopes and every call 403s. The SPA only sets that header once it has loaded the user's organizations, but the very first GET /persons/me runs before that. The worker provides a fallback via the DEFAULT_ORGANIZATION_ID var in apps/ui-legacy/wrangler.jsonc (matching the seeded Pierre's org). Production deploys never hit the proxy code path, so production behaviour is unchanged.
Why every Spring service has extra_hosts: ["localhost:host-gateway"]
The browser logs in via http://localhost:8080/realms/ADB/..., which Keycloak embeds in the JWT's iss claim. The backend validates that JWT against SECURITY_ISSUER_URI, which has to resolve to the same string from inside the container — hence the host-gateway entry. If you change SECURITY_ISSUER_URI to e.g. http://keycloak:8080/..., every API call fails with JwtException: The iss claim is not valid.
Golden org (WP-03)
Different stack than the rest of this page
Everything above this section describes the docker-compose Spring Boot stack (org 5f7325ec5d7c2205ca4688df, seeded from an Atlas dump). The golden org described here targets the Cloudflare-migration stack instead — the 17 wrangler dev service Workers pnpm dev starts from the repo root (persons :8788, parts :8791, contracts :8795, accounting :8796, tickets :8794, files :8790, …), against the SAME docker-compose infra tier (Keycloak, Mongo). Its local org id is 62d4ffdd0fa1798e82091168 — read live from the Keycloak token's own accesses[0].organization_id claim, not from adb-ui/realm-export.json (the two are confirmed to differ). Cross-check against tests/parity/README.md before relying on either org id — this page is advisory for current state, per CLAUDE.md.
The pnpm dev (Cloudflare) stack starts with empty adb-* Mongo databases beyond whatever make seed/make seed-slim populated above — none of the CF services' own read paths are exercised by those older seeds beyond persons/organization/accounting reference data. tests/parity (@adb/parity) fixes this for the CF stack by building one deterministic golden organization through the real service APIs (never by writing to Mongo directly): 3 owners (natural, company + legal rep, indivision), 4 tenants (2 natural, 1 company, 1 flatshare), a mono-ownership building and a condominium with 12 units (one per type), an RDC mandate, 3 RC leases (civil, commercial, takeover), a GLI contract, a signed SEPA mandate, a proposed + processed rent-call period, 4 invoices, a payment, a CAF dossier, and 6 tickets — one of each type, each with an event.
pnpm dev # from the repo root — starts the 17 Workers (leave running)
curl -s localhost:8788/healthz # confirm persons is up before seeding
pnpm --filter @adb/parity seed:golden # idempotent — safe to re-run
pnpm --filter @adb/parity seed:dump # exports to infra/seeds/data/*/*.json
pnpm --filter @adb/parity seed:check # verifies every id in seed.json still resolves live
make -C infra seed-golden # restores the export additively (upsert by _id, no --drop)
seed:golden stops dead on the first confirmed DTO/backend gap it hits (logged to tests/parity/seed/GAPS.md with the endpoint, payload, response, and the equivalent adb-ui file:line) rather than working around it — read tests/parity/README.md for the full idempotence design, the handful of best-effort (non-fatal) steps, and known limitations. Two are worth knowing up front:
- Dunning ("relance") cannot be created via HTTP at all — the underlying row is only ever written by a message-bus consumer reacting to a ledger balance-changed event.
seed.json'sdunningfield is alwaysnull. - The files phase makes a real AWS S3 PUT — the local
filesWorker has no S3 emulator, so uploading the two seed documents needsS3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEYprovisioned as Wrangler secrets for the localfilesprocess.
Change streams and the views read model (local CDC doorbell)
In staging and production, a MongoDB Atlas Database Trigger per watched collection POSTs /wake { db, collection } to services/event-dispatch, whose per-collection ChangeStreamDO tails the change stream and publishes on the CF Queues — the views-* queues that feed the services/views read model (owner « Biens » pane, GRR list, view.listProperties) among them. Those triggers do not exist against the local mongodb-atlas-local.
pnpm dev stands in for them: event-dispatch's dev script (services/event-dispatch/scripts/dev.mjs) runs wrangler dev on :8798 and a doorbell loop (src/dev-doorbell.mjs) that POSTs /wake for every source of src/routing.mjs every 20 s (below the DO's 30 s idle close, so the streams stay open), with an exponential backoff (1 s → 30 s) while wrangler or the broker is still starting. Its log lines are prefixed [cdc-doorbell].
- Local only. The loop refuses a target that is not
httpon a loopback host, anENVIRONMENT/NODE_ENVnaming staging or production, and any Worker whose/healthzdoes not answerlocalDev: true(LOCAL_DEV="true"ANDENVIRONMENT="development"). - Every consumer wakes up, not only views. The same streams feed
adb-accounting-events,adb-contracts-eventsandadb-tickets-events, so the local accounting / contracts / tickets consumers now react to local writes the way they do in staging.CDC_DEV_DOORBELL=0 pnpm devturns the loop off. - Changes before the first wake are not replayed. A source with no stored resume token opens at the present; data seeded before
pnpm devwas up is not projected. - A multi-config session (
npx wrangler dev -c … -c services/event-dispatch/wrangler.jsonc) does not run thedevscript: run the loop yourself withnode services/event-dispatch/scripts/dev.mjs(it starts its own wrangler) or POST/wakeby hand.
Translation bundles (i18n)
adb-utilities exposes /utilities/i18n/<NAME>?language=<lang> for the SPA's MultiTranslateHttpLoader (defined in adb-ui/src/app/content/layout/layout.module.ts). The handler calls ResourceBundle.getBundle(<NAME>, locale, classloader), so each bundle (COMMON, CONTRACTS, PERSONS, ACCOUNTING, PARTS, NOTES, LISTS, DOCUMENTS, SEARCH, TICKET, CATALOG, NACE, JEDI, ADMIN) needs <NAME>_en.properties and <NAME>_fr.properties on the JAR's classpath. The repo only ships JEDI_*.properties — the others come from a private Google Sheet.
Build pipeline
make build-base invokes adb-utilities/pipeline_scripts/prepare-translations.sh inside the build image, before mvn install. The wrapper either:
- Fetches real translations from the prod sheet — when
SPREADSHEET_URLand the 14*_GIDenv vars are set in.env.local, the wrapper callscreate_translations.sh, which downloads each tab as TSV and emits<NAME>_{en,fr}.propertiesintotarget/i18n/. The pom registers that directory as a resource, so the bundles end up on the classpath. - Stubs — when those env vars are missing (the default for any engineer who doesn't have spreadsheet read access), it writes empty
<NAME>_{en,fr}.propertiesfiles./utilities/i18n/<NAME>then returns{}(200) instead of 500. The SPA shows raw translation keys (COMMON.SAVE,CONTRACTS.MANDATE.CREATE, …), which is the current local-dev experience — but no error storm in the console.
Enabling real translations
Get read access to the sheet (https://docs.google.com/spreadsheets/d/12gBJ99fHXDJjY84lx1dprXbj_bAzC5tK), copy the gid for each tab into .env.local (see the env var names in infra/.env.example under "adb-utilities translation bundles"), then re-run make build-base.
The Makefile sources .env.local and forwards the variables as Docker build-args; the Dockerfile re-injects them as env vars for the wrapper RUN step.
Per-environment secrets and env files
The compose stack runs against LocalStack by default — no real credentials needed. To run against the real dev / staging / prod AWS instead (or to seed AWS Secrets Manager from the same values), use the infra/.env.* files:
| File | Purpose | Tracked in git? |
|---|---|---|
infra/.env.example | Schema reference (every key, no values) | ✅ yes |
infra/.env.local | Your local-dev values (against real dev infra if you want) | ❌ gitignored |
infra/.env.staging | Staging values | ❌ gitignored |
infra/.env.prod | Production values | ❌ gitignored |
# Use a specific env file with docker compose
cd infra
docker compose --env-file .env.local -f docker/compose.yaml --profile services up
# Seed AWS Secrets Manager for an env (uses the file you point at)
./scripts/seed-secrets.sh staging .env.staging
The env files contain real credentials. They are chmod 600 and gitignored, but rotate values immediately if any file ever lands in chat / a PR / a screenshot.
Adding a new queue / event
The single source of truth is infra/src/catalog/services.js. After updating the catalog, refresh the queue list in infra/docker/localstack-init/bootstrap.sh and the matching env vars in infra/docker/compose.yaml (the cloud Spring profile reads each queue URL by name).
See also
- Pulumi project overview — what the local stack mirrors.
- EventBridge fanout — how the queues are wired in production.