Life ConnectLife Connect
Wiki index
Architecture
Services
UI
Concepts
Runbooks
Infra
Swagger Docs
GitHub
Wiki index
Architecture
Services
UI
Concepts
Runbooks
Infra
Swagger Docs
GitHub
  • Runbooks

    • Run the local stack
    • Deploy a PR preview
    • Provision a new environment
Last updated 2026-10-09

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 in infra/seeds/ — that's where make seed looks first. The directory itself is git-tracked (via .gitkeep) but every *.tar.gz inside is gitignored, so a stray git add won'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 changedWhat to run
Backend Java codemake build-base && docker compose -f docker/compose.yaml --profile services up -d --build --no-deps adb-<svc>
adb-ui Angular codenothing — wrangler dev watches dist/ and auto-reloads
compose.yaml env or wiringdocker compose -f docker/compose.yaml --profile services up -d --no-deps adb-<svc>
Mongo contentmake seed-clear && make seed DUMP=...
A data migration (packages/data/migrations), or after a seedpnpm migrate up local (the plan), then the same with --apply — data migrations
Keycloak realm-exportdocker 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

TargetWhat runsFirst-run time
make upmongo, keycloak, localstack, mailpit~30 s
make build-baseMaven build of every JAR (shared adb-local-build image)~10–15 min (first time), then cached
make up-servicesruns build-base then all Spring servicesfirst 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

ContainerHost portNotes
mongo27018rs0 single-node replica set; container internal port stays 27017
keycloak8080Realm ADB pre-imported, dev user dev/dev (email aligned with the seeded Pierre Sacco — see Seeding the local Mongo)
localstack4566SQS/SNS/S3/Secrets Manager mock
mailpit8025 (UI), 1025 (SMTP)Catches outbound mail
adb-persons8081/persons/actuator/health
adb-utilities8082
adb-files8088host port 8088 → container 8083 (avoids local port clash)
adb-contracts8084
adb-parts8085
adb-aggregates8086
adb-accounting8089does 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-reports8090actuator endpoint requires auth (returns 401, but server is up)
adb-views8092actuator endpoint requires auth (returns 401, but server is up)
adb-ui4200served 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:

  1. make build-base runs mvn -DskipTests install over the entire monorepo via the repo-root pom.xml aggregator. The output is a Docker image tagged adb-local-build:latest containing every spring-boot JAR.
  2. 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> -am on the host (if you have Java + Maven) and rebuild only that container with docker 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 vmnetd driver sometimes holds 27017 even when lsof shows nothing.
  • adb-files binds to host port 8088, not 8083. Same reason.
  • First run of make build-base takes 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_CREDENTIALS or skip the service for local dev.
  • adb-ui first start is slow — runs npm install (~3–5 min) inside the container before ng build --watch kicks off, then wrangler dev boots. Subsequent restarts skip the install thanks to the named adb-ui-node-modules and adb-ui-angular-cache volumes. 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's AuthGuard calls GET /persons/me immediately 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/me returns 403 "No JWT token found in request headers" — wrangler dev (workerd) silently strips the Authorization header from outbound subrequests when the originating browser request was credentialed (Cookie + Sec-Fetch-*). The worker forces credentials: "omit" on the proxied fetch in apps/ui-legacy/src/entry.mjs to 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):

  1. The DUMP= argument — e.g. make seed DUMP=/explicit/path.tar.gz.
  2. The $ATLAS_DUMP_PATH env var.
  3. The newest restore-*.tar.gz in infra/seeds/ (gitignored).
  4. The newest restore-*.tar.gz in ~/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:

  1. Extracts the WiredTiger files from the tarball.
  2. Boots a temporary mongo:8 against them with the embedded replica-set name (the Atlas cluster adb-prod runs MongoDB 8.0.21 — the throwaway container must match to read its WiredTiger physical files directly).
  3. 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).
  4. mongodumps every adb-* database, then mongorestore --drops into the stack's mongo container.
  5. 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's dunning field is always null.
  • The files phase makes a real AWS S3 PUT — the local files Worker has no S3 emulator, so uploading the two seed documents needs S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY provisioned as Wrangler secrets for the local files process.

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 http on a loopback host, an ENVIRONMENT/NODE_ENV naming staging or production, and any Worker whose /healthz does not answer localDev: true (LOCAL_DEV="true" AND ENVIRONMENT="development").
  • Every consumer wakes up, not only views. The same streams feed adb-accounting-events, adb-contracts-events and adb-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 dev turns 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 dev was up is not projected.
  • A multi-config session (npx wrangler dev -c … -c services/event-dispatch/wrangler.jsonc) does not run the dev script: run the loop yourself with node services/event-dispatch/scripts/dev.mjs (it starts its own wrangler) or POST /wake by 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:

  1. Fetches real translations from the prod sheet — when SPREADSHEET_URL and the 14 *_GID env vars are set in .env.local, the wrapper calls create_translations.sh, which downloads each tab as TSV and emits <NAME>_{en,fr}.properties into target/i18n/. The pom registers that directory as a resource, so the bundles end up on the classpath.
  2. 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}.properties files. /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:

FilePurposeTracked in git?
infra/.env.exampleSchema reference (every key, no values)✅ yes
infra/.env.localYour local-dev values (against real dev infra if you want)❌ gitignored
infra/.env.stagingStaging values❌ gitignored
infra/.env.prodProduction 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.
Edit this page
Last Updated:
Contributors: Grégory Horion
Next
Deploy a PR preview