Local Development
On this page
- Prerequisites
- First-Run Setup
- Linux + host.docker.internal (/etc/hosts)
- Linux + UFW (host firewall)
- Linux + mold linker
- Running Locally
- Devstack Staleness Guard
- Postgres data volume + seed idempotency (ADR-048 §D1/§D2)
- Secrets: the repo-committed encrypted store + the field-encryption key (ADR-064, ADR-048 §D5)
- Local DB snapshot/rollback (Plan K F-045)
- Container Management
- Multi-replica devstack
- Seed Data
- Common Tasks
- Dockerfile (Multi-Stage Build)
- Infrastructure Dockerfiles
- CI vs Local Differences
Prerequisites
-
Rust 1.97.0 (edition 2024):
rust-toolchain.tomlpins the exact version, so rustup installs the right one automatically -
cargo-nextest:
cargo install cargo-nextest -
Docker Desktop (Windows/macOS) or Docker Engine (Linux), with Compose v2
-
glab CLI for GitLab issue and MR management: install from the GitLab CLI releases
-
Git hooks:
git config core.hooksPath .githooks(runs fmt, clippy, build, and nextest before push) -
mold linker (Linux only, optional, #1371): a per-machine opt-in that speeds up
cargo build/check/test; see Linux + mold linker below
First-Run Setup
-
Clone:
git clone git@gitlab.com:gadhs/application/ccwis/craig.git && cd craig -
Set hooks path:
git config core.hooksPath .githooks -
Copy env template:
cp .env.example .env(edit if needed — defaults work for devstack) -
Build workspace:
cargo build --workspace --locked -
Start devstack:
cargo xtask dev reload
Linux + host.docker.internal (/etc/hosts)
host.docker.internal is a Docker Desktop feature; the Docker daemon on Linux does not provide it. The devstack’s browser-facing URLs (Keycloak’s issuer, craig-web’s redirect URI) use that name so one configuration serves host browsers and Playwright-in-Docker alike. On Linux, host-side tooling needs the mapping added once:
echo '127.0.0.1 host.docker.internal' | sudo tee -a /etc/hosts
Without the mapping, browser logins and host-side CLI or test calls fail with could-not-resolve errors for host.docker.internal. Containers are unaffected: compose maps the name via extra_hosts: host-gateway wherever a container needs it (the Playwright e2e container, for example). If DNS resolves but connections time out instead, you are looking at the UFW problem below.
Linux + UFW (host firewall)
Docker Desktop on macOS/Windows handles container-to-host routing transparently, but Docker Engine on Linux with UFW enabled blocks user-defined bridge networks from reaching host-published ports. The symptom: cargo xtask e2e hangs with page.goto: Timeout while navigating to http://host.docker.internal:<port>/…;; DNS resolves, but the TCP connection times out.
cargo xtask dev start prints a warning when it detects active UFW. To unblock docker-bridge traffic without disabling the firewall:
# Allow forwarding between docker bridges and the host
sudo ufw default allow routed
sudo ufw reload
Or, if you prefer a tighter rule that only opens docker bridge interfaces:
sudo ufw route allow in on br+ out on br+
sudo ufw reload
Check sudo ufw status verbose to confirm. Firewalld/nftables users may need the equivalent rule for their stack.
Linux + mold linker
The mold linker (-fuse-ld=mold) is several times faster than the platform-default ld.bfd on this 66-crate workspace, which matters most on the link-time tail of every incremental cargo build/check/test. It is an optional local speedup, not a requirement: .cargo/config.toml deliberately commits no target.*.rustflags for it.
Leaving it uncommitted is a deliberate choice (#1371, from the MR !1310 review), not an oversight. Cargo picks ONE rustflags source, first found wins: CARGO_ENCODED_RUSTFLAGS, then RUSTFLAGS, then target.<triple>.rustflags, then build.rustflags. But when the same array key appears in multiple config files, the values join rather than replace, and higher-precedence entries land last. A committed target.<triple>.rustflags = […-fuse-ld=mold] therefore could not be cleanly reverted by a personal ~/.cargo/config.toml override: the project’s entry would always land last in the merged array, and because the linker driver honours the last -fuse-ld flag it sees, mold would win no matter what a personal config tried to set. Committing the flag would have made mold a de facto mandate for every Linux machine, with no working opt-out.
Opt in locally
Add it to your own (gitignored, outside the repo) ~/.cargo/config.toml:
# Debian/Ubuntu
sudo apt install mold
# Arch
sudo pacman -S mold
# Fedora
sudo dnf install mold
# ~/.cargo/config.toml
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
[target.aarch64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
This works cleanly (unlike the reverse case above) precisely because the project contributes no rustflags of its own to join with: your personal config is the only source. Add the musl triples too if you cross-build for Alpine targets locally.
Alternatively, per-invocation via the env var (which replaces config rustflags wholesale rather than joining):
RUSTFLAGS="-C link-arg=-fuse-ld=mold" cargo build
Docker: always on
The Alpine/musl Docker builder stage (Dockerfile) forces mold unconditionally: apk add mold plus ENV RUSTFLAGS="-C link-arg=-fuse-ld=mold" on the shared chef base stage, so both the cargo chef cook layer and the final build see identical rustflags (a mismatch would invalidate the ADR-039 dependency cache). No local install or opt-in is needed to build images. This is also where the win matters most: CI on RUNNER_SMALL was the original out-of-memory pressure that motivated #1371.
Running Locally
-
Start/rebuild all containers:
cargo xtask dev reload(builds images, starts containers, initializes Garage, waits for health) -
Restart services only (no rebuild):
cargo xtask dev restart -
Smart minimum rebuild:
cargo xtask dev refresh(reads SHA-256 markers; rebuilds only what changed) -
Run E2E tests:
cargo xtask e2e(checks health, starts if needed, rebuilds Playwright container, runs tests) -
Commands are idempotent: safe to re-run at any time
|
Never invoke Read-only |
When to use reload vs restart vs refresh
dev refresh (Plan K F-043 / Step 4) is the daily-driver third lifecycle command, alongside reload and restart. It reads the same .devstack/*.sha256 markers that dev start writes, works out what actually changed, and dispatches the cheapest compose action that covers the drift. reload and restart stay available as escape hatches.
| Situation | Command | Why |
|---|---|---|
I edited |
|
Cached rebuild of changed images only; reuses |
I edited static assets ( |
|
Cached rebuild; baked into image via Dockerfile COPY |
I changed |
|
No-cache rebuild (deps changed) |
I changed |
|
No-cache rebuild |
I changed |
|
Cached rebuild + |
I added a new migration |
|
Cached rebuild; new migration applies on startup |
I edited or deleted an existing migration |
|
Wipes volumes + rebuilds + reseeds (destructive migration) |
I changed |
|
Cached rebuild; the recreated seed one-shot re-runs and the convergent rules phase creates/updates rule sets in place (#1118 — no data wipe) |
I changed |
|
Triggers |
Nothing changed; I just want to check state |
|
Reports "already up to date"; no Docker action |
The |
|
Prints a |
I want to force a full rebuild regardless of markers |
|
Cached |
I want a clean slate (wipe data AND images) |
|
Full teardown + cold no-cache rebuild |
I only want to restart one service |
|
|
Every lifecycle verb that converges the stack rewrites the markers: dev start, dev refresh, dev reload, dev restart, and dev reseed. The blessing lives in bring_up’s success arm, after the health wait and seed verification, so a written marker always describes the running stack (#1405). `dev status shows what refresh would do without acting, and dev start --force bypasses the staleness check entirely. When the markers are absent, dev refresh no longer aborts (#955); it degrades to the conservative cached rebuild described above rather than forcing you back to a heavyweight dev start, which would reseed and churn the ephemeral ports.
Source: xtask/src/devstack_guard.rs::auto_refresh (refresh dispatch), xtask/src/devstack.rs::ensure_ready (start dispatch).
Seed integrity works in two layers. First, cargo xtask dev start/refresh (and the pre-push
validate battery) poll the craig-seed one-shot to a clean exit; the check is state-aware, so a
still-running seed is never misread as success. Second, the battery runs
cargo xtask verify-seed --expect keyed to assert the seeded PII is genuinely encrypted at rest.
The devstack has run field encryption end-to-end since epic &66 C7: the devstack/field-key.env key
is mounted into craig-cases and craig-seed (only those two), both with ENCRYPTION_MODE: required.
A key change or a missing .devstack/field-key.kcv marker forces the full-volume wipe and keyed
reseed automatically, and since ADR-064 the key itself is adopted from the repo-committed encrypted
store for recipients, so a committed rotation drives the same wipe-and-reseed on the next mutating
command. See the quality-gates "Field-encryption seed verification gate"
for the gate detail and how to run verify-seed manually.
Multi-backend identity stack (Authentik + ZITADEL)
Opt-in profile for the IdP-neutral integration tests. Brings up Authentik 2026.2.2 + ZITADEL 4.15.0 + a Redis dep alongside the existing devstack. Default cargo xtask dev start is unchanged — these containers are heavy and only needed when verifying the cargo xtask identity render output against real backends.
cargo xtask dev multibackend-up
# wait ~90s for Authentik first-boot blueprint apply (ZITADEL boots in ~30s)
cargo nextest run -p craig-test-lib --test identity_multibackend
cargo xtask dev multibackend-down
Per coding-conventions.md § Docker & DevStack, always activate through cargo xtask dev — never invoke docker compose --profile identity-multibackend directly. See docs/modules/ROOT/pages/plans/idp-multibackend-tests.adoc for the plan + devstack/authentik/README.md + devstack/zitadel/README.md for backend-specific notes (Authentik applies a declarative blueprint at boot; ZITADEL requires a one-time operator-side Terraform apply with a PAT extracted from the Console).
Devstack Staleness Guard
cargo xtask dev start and dev status run a staleness check backed by SHA-256 marker files in .devstack/ (gitignored). The guard hashes inputs at several granularities and picks the minimum rebuild action needed:
| Category | Paths hashed | Action on change |
|---|---|---|
Source code |
|
Cached rebuild |
Dependencies |
|
No-cache rebuild |
Dockerfile |
|
No-cache rebuild |
Compose config |
|
Cached rebuild + compose down |
Infrastructure |
|
Cached rebuild + compose down |
Rulesets |
|
Cached rebuild; seed re-run converges the rules phase (no wipe, #1118) |
Static assets |
|
Cached rebuild |
Migrations (additive) |
new |
Cached rebuild (applied on start) |
Migrations (destructive) |
modified/deleted |
Volume wipe + reseed |
Seed |
|
Reseed only |
Field-encryption key |
|
Volume wipe + keyed reseed |
Committed secrets store |
|
Adoption: teardown → publish new key → cold start + reseed |
Markers are (re)written by every lifecycle verb that converges the stack (dev start/reload/restart/reseed via bring_up’s success arm, `dev refresh via auto_refresh — #1405). Use cargo xtask dev status to see what would happen on the next dev start without acting. Use cargo xtask dev start --force to skip the staleness check and do a full rebuild.
Devstack lifecycle logic lives in xtask/src/devstack.rs (bring_up, tear_down, reseed, wait_for_health, verify_seed, ensure_ready). Garage bootstrap is NOT an xtask concern: it runs as the in-graph garage-init compose one-shot (devstack/garage-init/, C4-gate #1012), which every docker compose up path executes before the gated seed. devstack_guard owns only the staleness detection and marker files. Non-CLI callers (pre-push validate, etc.) that need a ready devstack should call devstack::ensure_ready(false). The cargo xtask dev CLI in cmd::dev is a thin shell that dispatches into devstack::*.
Postgres data volume + seed idempotency (ADR-048 §D1/§D2)
Postgres persists in a named postgres-data volume (mounted at /var/lib/postgresql, the path
postgres:18-alpine declares). A plain docker compose down && up KEEPS the data; only dev clean /
dev reseed / dev restart (which pass down -v) wipe it.
One-time transition (pre-1.0 break). If your devstack predates this change, its data lives in the image’s
anonymous volume. Run cargo xtask dev clean --confirm once before the next dev start to drop it
cleanly. A bare dev start also works (it re-migrates and re-seeds onto the fresh named volume) but leaves
the old anonymous volume dangling; reclaim that with docker volume prune. Note that dev clean, like every
down -v path, clears the .devstack/ staleness markers but PRESERVES .devstack/snapshots/
(#1013), so your migrate snapshot pg_dump
backups survive every lifecycle command. The wiped data volumes are a separate matter: restore a snapshot
with cargo xtask migrate rollback after the reseed if you want the old data back.
Idempotent, fail-loud seed. The one-shot craig-seed container writes a per-database
public._seed_marker (a content-fingerprint identity covering the generated SQL, the seed and family
counts, and the on-disk tests/fixtures/ and seed.sh) and runs psql under ON_ERROR_STOP.
A re-run against a current fixture therefore skips cleanly, while a genuine SQL error or duplicate key
fails loud instead of silently rolling back.
If you edit tools/craig-seed/src/, seed.sh, or tests/fixtures/, StalenessReport.seed flips to
Reseed. Both dev start (via decide_action’s `StartAction::SeedOnlyReseed,
#1374) and dev refresh (via
auto_refresh) detect this and drive a tear_down(false) + bring_up reseed cycle themselves, wiping
just the data volumes so the rebuilt seed image converges against a clean slate in one call; no manual
cargo xtask dev reseed is needed. (Before #1374, dev start consulted only .volume and .rebuild,
so seed-only drift reused the stale volume and craig-seed’s identity guard refused with "run cargo
xtask dev reseed`", looping forever under `cargo xtask e2e’s start-and-retry fallback. `dev refresh
was never affected, since auto_refresh already consulted .seed.)
rulesets/ is the exception (#1118).
The rules phase carries its own fingerprint in the craig_rules rules marker and is convergent: the
re-run creates or updates each rule set via the craig-rules API (JDM-validated, cache-swapped,
change-notified) to the fixture’s (name, version), verifies a fixture-versus-DB receipt, and only
then rewrites its marker. A ruleset version bump therefore lands on dev refresh with no volume wipe.
A rules-phase failure is fatal — the seed exits non-zero and the C4 gate refuses the stack — never a
silently kept stale version.
Every local path that runs the seed checks its exit code: dev start/reload/reseed/restart and
validate via bring_up, and dev refresh via auto_refresh. Since the C4 gate
(#1012) the compose graph itself
enforces the same thing declaratively: craig-web gates on craig-seed:
service_completed_successfully, and the seed gates on the in-graph garage-init one-shot. So even a
raw docker compose up — CI’s path, with no xtask involved — fails loud on a bad seed, and the seed’s
attachment upload can never race an uninitialized bucket on a first start.
Source: xtask/src/devstack_guard.rs (guard — unit-tested per dimension), xtask/src/devstack.rs (lifecycle).
Storage layer (#1125, rescoped by #1148)
The devstack postgres runs with synchronous_commit at its default (on), plus modest
shared-buffer and checkpoint easing. This applies to the devstack and CI only; production
deployments own their own postgres tuning.
The history behind that default is worth knowing.
#1125 originally set
synchronous_commit=off after measuring one WAL fdatasync at 1.1 ms idle but
5,068 ms under concurrent I/O (k6 create_referral p95: 8 ms idle rising to
9.5 s loaded). The #1129
root-cause analysis then reframed that measurement: fsync on this box is ~718 µs/op even at
load average 25 (pg_test_fsync, 2026-07-26), and the multi-second events are episodic host
page-cache transients from co-tenant memory pressure, not a chronic function of load.
#1148 restored the default:
muting a durability semantic in a system-of-record test environment to absorb a
minutes-per-day transient was the wrong trade, and it masked exactly the commit-path behavior
the battery exists to observe. The transient class is absorbed where it belongs, in the e2e
retry policy (retries: 1, unconditional since the #1129 analysis).
Connection ceiling (#1160):
the devstack postgres also raises max_connections to 250. The stock 100 was under water
for this stack — 8 services with pool size 10 each, around 14 detached advisory-lease sessions
outside pool accounting, plus the api suites' live-DB read pools — which surfaced as 5-second
acquire-timeout cliffs during the pre-push battery. Since
#1403 the battery’s scratch-DB
churn pools live on the dedicated postgres-test instance instead (with its own 150-connection
floor). The full arithmetic is recorded next to the knob in docker-compose.yml; the floor is
pinned by crates/craig-db/tests/connection_budget.rs, and the production sizing formula lives
in the deployment guide’s "Database connection budget" section.
Optional host-side mitigation for btrfs boxes: Docker named volumes live under
/var/lib/docker/volumes and inherit COW + compression, which amplifies database fsync cost. The
standard exemption (the same technique commonly applied to build-artifact dirs like a cargo
target/): make volumes/ a dedicated subvolume marked chattr +C (NoCOW — applies to newly
created files only, so recreate volumes after flipping it; note NoCOW also disables btrfs data
checksums for those files). This is a per-machine choice, not repo state.
Healthchecks and resource limits (#1135): every devstack healthcheck carries a start_period
(probes during it don’t count toward unhealthy) sized for build-saturated cold starts — keycloak’s is
120 s because --import-realm under concurrent image-build I/O routinely exceeds its JVM-boot window.
Compose-level CPU/memory limits were considered and REJECTED for now: dev boxes vary too widely for
one set of numbers, limits would slow the builds that share the box, and the async-commit change above
removed the sensitivity that made contention visible. Revisit if healthcheck flakes return.
Secrets: the repo-committed encrypted store + the field-encryption key (ADR-064, ADR-048 §D5)
The devstack field-encryption key has TWO sources, resolved in this order on every mutating
lifecycle command (dev start/reseed/refresh/reload/restart/restart-service, plus
e2e’s and validate’s ensure_ready):
-
The repo-committed encrypted store (
secrets/dev.yaml, sops + age, policy in.sops.yaml) — for store recipients (maintainers + CI). If your age identity (~/.config/sops/age/keys.txt) decrypts the store, the store’s key is adopted into the gitignoreddevstack/field-key.envautomatically; if the store key CHANGES (a rotation commit), the next mutating command tears the stack down (volumes wiped FIRST), publishes the new key atomically, and cold-starts + reseeds under it. Rotation is a git commit — no manual key handling. -
Generate-once fallback — for contributors WITHOUT an identity (positively classified: no
keys.txt), and for identities in the joining window (not yet added as a recipient): a random key is generated once-if-absent, written atomically, hardened to0600(best-effort
a warning on native Windows — use WSL2). An existing file is validated and reused, never re-keyed; symlinks and loose permissions are refused/repaired. Contributor devstacks are fully functional on this path — the store only ADDS shared-key semantics.
The key is live (epic &66 C7): compose env_file-mounts it into craig-cases + craig-seed —
only those two — and both run ENCRYPTION_MODE: required, so every devstack seed and every cases
write path is genuinely encrypted, and a missing key file fails docker compose up outright. At
boot, craig-cases verifies the mounted key against the seeded data’s crypto_key_lineage anchor
and refuses to start on a mismatch (ADR-048 §D3; the refusal names the remedy). The KCV
fingerprint (never key material) rides .devstack/field-key.kcv; an absent/changed KCV escalates
to the volume wipe + keyed reseed, exactly as before. cargo xtask dev status reports store
drift and in-flight adoptions read-only (no docker/sops invocation).
Workflows
-
First run (recipient): nothing extra — the first
dev startadopts the store key. If your identity is not yet a recipient, you get the fallback path plus the joining handoff below. -
First run (contributor, no identity): nothing extra — generate-once, exactly as always.
-
Daily: nothing — the warm-path marker (
.devstack/secrets-adoption.v1.json) makes the check hash-cheap; sops runs only when the store or your key actually changed. -
Joining (becoming a recipient): run
cargo xtask secrets initand post the printed PUBLIC key; an existing recipient runscargo xtask secrets add-recipient <age1…>and commits. Until then your devstack uses the fallback key (and the seed data differs from store-keyed boxes — expected). -
Leaving (revoking a recipient): another recipient runs
cargo xtask secrets remove-recipient <age1…>— this is REAL revocation: the store re-encrypts from scratch (fresh sops data key) AND the field-encryption key value rotates in the same pass. Git history remains readable to the removed identity forever (removal protects only future values). Self-removal is refused; the recipient floor is 3. -
Rotating the secret:
cargo xtask secrets edit→ commit → any mutating devstack command consumes the transition (CI picks it up on its next pipeline viaCRAIG_CI_AGE_KEY). A crash mid-adoption resumes from the root-level.craig-pending-adoption.v1.jsonrecord at the next mutating command. -
Edit limits:
secrets editopens$EDITORINSIDE the devtools container (terminal editors only — vi is the in-container fallback; GUI editors do not work). A no-change close is a clean no-op. -
Forks: a fork can never rewrap the upstream store — run
cargo xtask secrets fork-rebootstrap --forceonce and mint fork-own identities.
Hygiene
-
Never paste an
AGE-SECRET-KEY-1…line into a shell command (argv + history leak) — the tooling only ever reads identities from files. Keep~/.config/sops/age/at0700/0600. -
CI logs never carry key material by design (the store tooling prints KCVs — HKDF-derived check values — and paths only); treat any plaintext key sighting in a log as an incident.
-
The dev identity is SHARED with canopy (
keys.txtis one file) — compromise or rotation of that identity couples both projects (accepted threat model, ADR-064; ratified on #1031).
Source: xtask/src/field_key.rs (generate-once provisioning, hardened load),
xtask/src/field_key_reconcile.rs (store adoption: warm marker, pending record, fail-closed
matrix), xtask/src/devstack.rs::consume_store_transition (lifecycle wiring),
xtask/src/cmd/secrets.rs (the operator command family), xtask/src/devstack_guard.rs (KCV
staleness). Decision record: ADR-064.
Local DB snapshot/rollback (Plan K F-045)
cargo xtask migrate wraps pg_dump/pg_restore for the 8 stateful CRAIG service databases: craig_cases, craig_composition, craig_exchange, craig_financial, craig_placement, craig_reporting, craig_rules, and craig_security. The list derives from the ONE typed registry in xtask/src/registry.rs (since #1308, which also fixed craig_composition having been silently skipped); craig_intake is the stateless edge per ADR-017 and is excluded. The postgres binaries run inside the devstack postgres container via docker exec, so the host does not need a matching pg_dump installed. Since #1276 the same command family carries cargo xtask migrate apply [--service <name>], which builds and runs the ADR-063 one-shot migration gates with per-service reporting.
| Subcommand | What it does | When to use |
|---|---|---|
|
|
Before applying a risky/experimental migration locally. |
|
|
When a migration breaks something and you want your data back. |
|
Prints available snapshots: timestamp, entry count, total bytes, git short SHA, per-DB schema version. |
Inventory between sessions. |
Since #1310 the devstack runs
the ADR-063 privilege boundary: each service database is owned by craig_<svc>_owner (which the
migration gates connect as) while serving containers connect as craig_<svc>_app, which can run
DML only — DDL refuses with error 42501. devstack/postgres/init.sql provisions the 16 roles on
a FRESH data directory only, so upgrading an existing devstack across #1310 requires
cargo xtask dev reseed. The craig superuser stays the devstack admin principal for the seed,
the test-plane scratch/template databases, and xtask tooling.
The schema-version-ahead bail (Plan K cross-cutting invariant #4) catches the silent-corruption case where the local devstack has applied a migration after the snapshot was taken — restoring older data against a newer schema can violate freshly-added CHECK constraints or NOT-NULL columns. --force overrides for the "I know my migration just broke and want the old data back" case; the operator owns the consequences.
Snapshots live under .devstack/snapshots/ (gitignored) and SURVIVE dev clean/reseed/restart
(#1013: clear_markers preserves the snapshots/ subdirectory while clearing the staleness markers
around it). pg_dump/pg_restore invocations are gated by the same exclusive xtask lock as
dev start/dev reload/etc., so a concurrent rebuild can’t race a snapshot.
Source: xtask/src/cmd/migrate.rs (port of canopy xtask/src/cmd/migrate.rs, with single-shared-postgres adaptation + CRAIG-side schema-version invariant; 11 unit tests).
Container Management
Infrastructure Services
| Service | Image | Ports | Healthcheck |
|---|---|---|---|
postgres |
postgres:18-alpine (custom Dockerfile) |
5432 |
pg_isready -U craig |
rabbitmq |
rabbitmq:4.2-management-alpine (custom) |
5672, 15672 |
rabbitmq-diagnostics -q ping |
keycloak |
quay.io/keycloak/keycloak:26.5 (custom) |
8180 |
HTTP GET /realms/craig |
garage |
dxflrs/garage:v2.2.0 (custom) |
3900 (S3), 3903 (Admin) |
/garage status |
Application Services
Each stateful service depends on its ADR-063 migration gate (service_completed_successfully) plus postgres, rabbitmq, and keycloak (service_healthy). The rules, cases, exchange, reporting, and security services also depend on garage, and everything but craig-rules depends on craig-rules itself, whose authz engine must be up first. craig-intake is the exception: as the stateless edge (ADR-017) it has no postgres, rabbitmq, or garage edges, and depends only on keycloak, craig-cases, and craig-security. docker-compose.yml is authoritative.
| Service | Port | Database | Extra Env |
|---|---|---|---|
craig-rules |
8001 |
craig_rules |
JURISDICTION=georgia |
craig-cases |
8002 |
craig_cases |
RULES_ENGINE_URL, CRAIG_STORE__*, BODY_LIMIT=50MiB |
craig-placement |
8003 |
craig_placement |
RULES_ENGINE_URL=http://craig-rules:8001 |
craig-exchange |
8004 |
craig_exchange |
CRAIG_STORE__*, BODY_LIMIT=50MiB |
craig-financial |
8005 |
craig_financial |
JURISDICTION=georgia |
craig-reporting |
8006 |
craig_reporting |
— |
craig-security |
8007 |
craig_security |
— |
craig-intake |
8008 |
— (stateless, ADR-017; the pre-created |
CASES_URL, SECURITY_URL, CLIENT_ID/SECRET, IP_HASH_SECRET, TRUSTED_PROXIES |
craig-composition |
8009 |
craig_composition |
composition layer engine (ADR-035; Plan X) |
craig-intake-standalone |
8010 (container 8009) |
— |
MODE=standalone, forwards to craig-intake:8008 |
craig-intake-standalone-shines |
8011 (container 8009) |
— |
MODE=standalone, BACKEND_PROFILE=shines, forwards to the SHINES mock (ADR-042) |
Keycloak URL Pattern
-
OIDC_ISSUER: the public URL (http://localhost:8180/realms/craig), used for JWTissclaim validation -
OIDC_INTERNAL_URL: the internal Docker URL (http://keycloak:8080/realms/craig), used for JWKS fetching -
These must differ because the CLI/tests reach Keycloak at localhost:8180 but services reach it at keycloak:8080
Object Storage (Garage)
-
The Garage image is distroless: no shell, just the binary at
/garage. Healthcheck:["CMD", "/garage", "status"] -
Config:
devstack/garage/garage.tomlis baked into the image via the Dockerfile (project convention: no bind mounts)-
db_engine = "lmdb",compression_level = 2,replication_factor = 1(single-node devstack)
-
-
Init is the in-graph
garage-initcompose one-shot (devstack/garage-init/, the C4 gate from #1012), NOT xtask-
alpine + curl/jq against the Garage admin API on
garage:3903(bearer token from the committed devstackgarage.toml); the distroless garage container is never exec’d -
Idempotent per step: layout applied only at
layoutVersion == 0, key imported only whenGetKeyInfo404s, bucket created only whenGetBucketInfo404s, andAllowBucketKeyALWAYS re-granted (it is idempotent), so any partial state repairs on re-run -
craig-seed gates on it (
service_completed_successfully), so the bucket exists before the seed’s attachment upload on every path
-
-
Devstack credentials: access key
GKdeadbeefdeadbeefdeadbeef, secret0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-
Access key format:
GK+ 24 hex chars (12 bytes). Non-hex chars are rejected by Garage v2.
-
-
Bucket:
craig-dev, region:garage -
Volumes:
garage-meta,garage-data(named Docker volumes, ephemeral withdocker compose down -v) -
Services receive
CRAIG_STORE__*env vars +BODY_LIMIT=52428800(50 MiB)-
CRAIG_STORE__BACKENDis explicit-required (#1362) — compose setss3via thex-garage-credentialsanchor for every store-consuming service; a service booted outside compose without it refuses startup with the remedy (there is no silentlocalfallback;localis an explicit dev/test opt-in)
-
Multi-replica devstack
cargo xtask dev start --replicas SERVICE=N[,SERVICE=N…] brings up the devstack with the named CRAIG application services scaled to N containers each. Loads docker-compose.scale.yml to drop the host-port pins on scaled services so docker-compose can assign ephemeral host ports per replica.
# 2 craig-cases replicas + 2 craig-exchange replicas; rest scale=1.
cargo xtask dev start --replicas craig-cases=2,craig-exchange=2
Reaching scaled replicas:
-
From inside the compose network: by Docker DNS —
craig-cases-1,craig-cases-2. -
From the host: via
docker compose port --index N <svc> <internal-port>(each replica gets a distinct ephemeral host port).
Limitations:
-
Postgres / RabbitMQ / Keycloak / Garage stay scale=1 — sharing state requires external replication this devstack doesn’t model.
-
craig-web(BFF) is not indocker-compose.scale.yml; its 8080 host port pins to the first replica. Usecraig-web=1. -
.ports.envreflects the first replica’s ports only; tests that need per-replica ports useMultiReplicaCluster(seetesting.md).
MultiReplicaCluster (in craig-test-lib) is the test-side analogue — it spawns a dedicated cluster with a unique compose project name so its stop() cannot wipe the developer’s persistent devstack volumes.
Seed Data
-
Generated by
craig-seed, the deterministic seed generator (tools/craig-seed/) -
Pinned defaults:
CRAIG_SEED=42,CRAIG_FAMILIES=12(pinned in docker-compose.yml; CI inherits 12 through compose’s:-12fallback — there is no separate.gitlab-ci.ymlpin). The generator binary’s OWN default stays--families 9— the reproducibility baseline pinned by theseed42/families9byte-identity fixture. The 12-vs-9 split is intentional two-tier layering (#1006): operational dataset vs baseline corpus; the compose env line carries the authoritative comment. -
SQL: auto-generated at container startup, seeded via
craig-seedbinary in Docker -
TypeScript manifest:
tests/e2e/lib/seed.ts— auto-generated viacraig-seed --manifest -
With seed=42, families=12: 50 persons, 15 referrals, 12 investigations, 10 cases, 6 plans, 6 contacts, 5 court orders, 3 foster homes, 12 placements, 5 partners, 6 agreements, 1 ICPC, 5 rate tables, ~36 payments, 1 claiming record
Common Tasks
| Task | Command |
|---|---|
Build workspace |
|
Unit tests |
|
All tests (needs devstack) |
|
E2E tests |
|
Reseed DB (wipe volumes, keep images) |
|
Format check |
|
Lint |
|
License/advisory check |
|
Stop containers (keep data) |
|
Wipe containers + data |
|
Dockerfile (Multi-Stage Build)
-
Build stage:
rust:1.97-alpine, pinned to the toolchain per.claude/CLAUDE.md§ Project overrides. It installs musl-dev, pkgconfig, and curl;cargo chefcooks the dependency layer, then a singlecargo buildinvocation builds every workspace binary in one pass -
13 named runtime targets (all
alpine:3.23base):craig-rules,craig-cases,craig-placement,craig-exchange,craig-financial,craig-reporting,craig-security,craig-composition,craig-intake,craig-intake-keyring,craig-web(includesstatic/),craig-mock-server,craig-seed(includesrulesets/, psql client, jq) -
Build a single target:
docker build --target craig-rules -t craig-rules:tag . -
CI builds + pushes to GitLab Container Registry with tags
$CI_COMMIT_SHORT_SHAandlatest
Serial devstack image builds — cache-key grouping (#1347)
docker-compose.yml declares 16 build: blocks across those 13 targets — one
target, craig-intake, is built for FOUR services: craig-intake,
craig-intake-standalone, and craig-intake-standalone-shines share
identical build args and image tag (one real build satisfies all three);
craig-intake-standalone-shines-tls builds the same target with a distinct
build arg (CRAIG_INTAKE_FEATURES=self-signed-tls) and a distinct image tag,
so it needs its own build. All of them share the SAME builder
stage, which compiles every workspace binary in ONE cargo chef cook
cargo build invocation — so the layer that step produces is a pure function
of its build args, not of which final target you asked for. Those args boil
down to three distinct recipes across the whole compose file:
| Recipe | Build args | Targets |
|---|---|---|
1 |
|
|
2 |
|
|
3 |
|
|
docker compose build and up --build schedule all requested targets
CONCURRENTLY by default, and BuildKit only commits a layer to its cache once
the build that produced it finishes. So when two same-recipe targets start
close together, the second starts before the first’s cargo chef cook and
cargo build layer has landed in the cache, sees nothing to reuse, and
redundantly re-runs the full-workspace cargo build itself. In the worst
case this stacks up to N concurrent full-workspace builds, each with its own
set of rustc and linker processes, instead of 1 real build plus N-1 cache
hits. That multiplication lands at the exact moment memory is already
highest (musl static-linking ring/aws-lc-sys for rustls is a heavy step
on its own), and it is a confirmed source of OOM-killed builds on
memory-constrained dev machines and the smaller self-hosted CI runners
(RUNNER_SMALL).
The fix: xtask/src/docker.rs’s `build_devstack_images_serial (backing
BUILD_SPECS, the table above encoded in Rust) issues one
docker build --target <X> per distinct (target, build-args, image-tag)
tuple, ONE AT A TIME, ordered so every recipe’s builds run back-to-back —
group 1’s targets first, then group 2’s, then group 3’s. Every xtask call
site that could trigger a docker compose build/up --build goes through
this instead and drops --build from its subsequent up: cargo xtask dev
(start/reload/restart/reseed), dev refresh’s auto-rebuild path,
`cargo xtask migrate apply, and cargo xtask validate’s Docker build phase
(CI-covered). Building via `docker compose directly (as the raw-compose CI
jobs still do — out of scope for #1347) reintroduces the race.
Adding a 14th image target: add its BuildSpec entry to the group
matching its (CARGO_PROFILE, CRAIG_WEB_FEATURES, CRAIG_INTAKE_FEATURES)
build args — inside that group’s contiguous run, not appended after a
different group — and do NOT reach for docker compose build/up --build
as a shortcut; that is exactly the scheduling this section fixed.
Infra images are a separate, disjoint set. BUILD_SPECS only covers the
13 targets sharing the root cargo-chef Dockerfile — it deliberately excludes
postgres, rabbitmq, keycloak, garage, and garage-init, which each
declare build: as a bare string pointing at their own standalone
devstack/<x>/Dockerfile (no shared layer, so no race to serialize around).
Dropping --build from up without also covering these left them frozen at
whatever image was last built — editing devstack/postgres/init.sql (or any
other infra Dockerfile/config) silently had no effect, since up alone
never rebuilds a service’s image. xtask/src/docker.rs’s
`infra_build_services discovers this set dynamically (services whose
build: value is a plain string, not a mapping — the same signal
build_specs_match_docker_compose_yml uses to rule them OUT of
BUILD_SPECS, so the two lists can never silently overlap or gap) and
build_infra_images hands them to a single docker compose build <services>
call — concurrent scheduling is fine here since there’s no cargo-chef layer
to race. Every call site above runs it alongside (never instead of)
build_devstack_images_serial. A name-based filter (e.g. a craig- prefix)
was considered and rejected: mock-server is a BUILD_SPECS target with no
craig- prefix, and compose’s own default image naming makes the unrelated
craig-postgres/craig-rabbitmq/etc. images ALSO start with craig- — the
heuristic is wrong in both directions on this exact compose file.
Infrastructure Dockerfiles
devstack/postgres
-
Dockerfile:
FROM postgres:18-alpine, COPYinit.sql -
init.sql creates 9 CRAIG databases (craig_rules, craig_cases, craig_placement, craig_exchange, craig_financial, craig_reporting, craig_security, craig_composition, and craig_intake — the last pre-created but never populated; stateless edge per ADR-017), the multibackend-profile IdP stores (authentik, zitadel), and the per-service migration-owner/runtime role pairs (#1310, ADR-063)
-
init.sqlonly ever runs once, against a FRESH/empty data directory — upstream Postgres entrypoint behavior, not a CRAIG choice. The devstack’spostgres-datavolume is a persistent named volume that survives a plaindown/up(ADR-048 §D1), so a rebuilt image alone does nothing: editinginit.sqlon an already-initialized volume silently has no effect until the volume is wiped.xtask/src/devstack_guard.rs’s `check_postgres_init_stalehashesdevstack/postgres/separately from the rest ofdevstack/for exactly this reason — a change there escalates straight toVolumeAction::Wipe(+ reseed) instead of the plain cached rebuild the rest ofdevstack/gets. Same escalation mechanism as a modified migration file or a rotated field-encryption key.
devstack/rabbitmq
-
Dockerfile: copies
definitions.json+rabbitmq.conf -
rabbitmq.conf:
management.load_definitions = /etc/rabbitmq/definitions.json -
definitions.json (#1202) declares 10 users with SHA-256 password hashes:
craig(the admin/operator),craig-test(the broad AMQP test identity), and 8 per-service least-privilege accounts (craig-<svc>, password equal to the account name — a public dev ACL fixture). It also declares the/vhost and the two durable topic exchangescraig.eventsandcraig.dlx, which are operator-owned; services no longer declare them -
WARNING: RABBITMQ_DEFAULT_USER env var does NOT work when rabbitmq.conf is COPYed (overrides entrypoint config)
CI vs Local Differences
Pre-push hook runs the full test battery locally — it is the sole functional-correctness gate. CI carries security/supply-chain scans, the no-devstack ci-tests subset, secrets/docs policy jobs, Docker promotion, Pages, and the scheduled pentest/perf/cluster jobs.
| Aspect | Local (pre-push) | CI |
|---|---|---|
Lint/clippy/tests |
|
Not run (pre-push gate) |
E2E (Playwright) |
|
Not run (pre-push gate) |
Security scans |
Not run |
SAST, secret detection, dep scanning, cargo-audit |
Docker images |
Not built |
|
Docs |
Not built |
Antora review apps + Pages |
CI Helper Scripts (devstack/ci/)
-
write-field-key.shmaterializesdevstack/field-key.envby DECRYPTING the repo-committed store (secrets/dev.yaml) with the CI age identityCRAIG_CI_AGE_KEY, inside the pinned devtools container (ADR-048 §D5 as amended by ADR-064 U7, #1385). The output is validated before an atomic publish, and the prior file is retained on failure. An unset variable is a hard error: a CI devstack must never come up keyless, and a store rotation reaches CI on the next run. -
Two scripts were retired by the C4 gate (#1012):
garage-init.sh, because Garage bootstrap now runs as the in-graphgarage-initcompose one-shot on every path, andwait-healthy.sh, because the CI jobs usedocker compose up -d --wait --wait-timeout 900, which the seed and garage-init completion gates make fail-loud.