ARD-0007: preset: django-node, multi-service compose, schema versioning¶
- Status: Accepted
- Date: 2026-05-23
- Deciders: Tom (Claude facilitating)
- Amends: ARD-0004 — implementation order step #8 is replaced by the order in this ARD; ARD-0002 — secret URI resolution at container start moves from "deferred" to "shipped in v0.2"
- Extended by: ARD-0008 (reframes "v0.2 slice" within v0.3 → v1.0 plan), ARD-0014 (adds
preset_version:schema), ARD-0023 (Proposed — addstasks:as fifth primitive alongside services/volumes/setup/restore) - Related: [[ard-0001-v1-architecture]], [[ard-0002-dbx-as-runtime-dependency]], [[ard-0004-shopify-first-as-dogfood-path]], [[ard-0005-security-model-inversion]], [[ard-0006-profile-is-the-trust-anchor]]
Context¶
~/code/work/content-infrastructure is the natural second dogfood profile after shop-theme. It's a Django + Django Ninja backend (Python 3.14, uv) + React/Vite frontend + Postgres 17 sidecar. Today the project's CLAUDE.md instructs developers to run Postgres with docker run -d --name pg17 -p 5432:5432 ... — exactly the orchestration step boring should be replacing.
This is the v1.x Django/sidecar slice ARD-0004 deferred. Validating it against a real, in-use Django app — not a synthetic test bed — exercises the parts of boring that the Shopify-first slice deliberately skipped. Picking it as the second dogfood target is the same logic ARD-0004 used for picking Shopify first: aligned dogfooding on real work, not contrived test cases.
v0.1.0-dev cannot host this profile yet. Four gaps:
- No multi-service compose.
lib/compose.shonly handlesservices: [](the Shopify case). The Postgres sidecar needs a realservices:block with persistent volume, healthcheck, and a wiredDATABASE_URL. - No polyglot preset.
theme: shopifyis the only preset that exists. content-infrastructure needs a Python 3.14 + uv + Node 20 + libpq + claude-defaults image. - Secret URI resolution at container start is deferred.
lib/secrets.shresolves URIs but isn't wired intocmd_open. content-infrastructure cannot shipOPENROUTER_API_KEY(and the other four prod secrets) as literal env values in a checked-in profile — so this stops being deferred. - No first-run lifecycle hook. Django needs
migrate+bootstrap_dataonce Postgres is healthy. There's no profile field for that today.
Adding a second preset also forces a naming question: the current field is theme:, which is Shopify-jargon. Reading theme: django-node is awkward. Rather than carry the awkwardness forever (or break the existing shop-theme profile silently), this ARD also lands the boring profile schema versioning mechanism — modeled on docker-compose's historical version: field — so this rename and every future one can ship as a soft deprecation rather than a hard break.
Decision¶
1. Combined preset + primitives, not preset-only or primitives-only¶
The right shape is generic schema primitives that anyone can use directly, plus curated presets that compose those primitives with sensible defaults.
- Primitives (added or extended in this ARD):
services:,volumes:,setup:, secret URI resolution at start. - Presets (curated):
preset: shopify(existing, renamed fromtheme:),preset: django-node(new). A preset is just a name that triggers a bundled Dockerfile path lookup and injects default values for the primitives (e.g., the django-node preset seeds apostgres:17sidecar entry intoservices:).
A preset is never required — a user with an exotic stack can author their own Dockerfile via stack.dockerfile: (already supported), declare their own services:, and skip presets entirely. The preset exists to make the common case one-line, not to gate the uncommon one.
This rejects two pure alternatives (see Alternatives below).
2. Rename theme: → preset: with soft-deprecation¶
theme: is renamed to preset: in the v1 schema. theme: is accepted with a deprecation warning and rewritten in-memory to preset: so downstream logic only sees one shape. A future v2 schema removes theme: entirely.
This is exercised immediately: shop-theme's profile keeps working with theme: shopify (warns), and the new content-infrastructure profile uses preset: django-node.
3. Profile schema versioning — profile_version: "1"¶
New top-level field. Mechanism modeled on docker-compose's historical version: field (which exists for exactly this purpose: schema evolution with clear deprecation paths).
- Missing →
[warn] profile_version not set; assuming "1" (add 'profile_version: "1"' to silence). - Known (
"1") → no message. - Unknown future version (
"2", etc.) → hard error:[error] this profile requires boring vX.Y or later (declares profile_version "2"). - Granularity: major-only (string
"1","2", ...). Not semver. Schema breakage that's small enough to warrant a minor bump is small enough to soft-deprecate without a version bump.
A small deprecation table lives in lib/profile.sh:
# Map of {old_field → new_field}. Walked by _profile_rewrite_deprecated
# before validation. Each rename also logs a warning to stderr.
_BORING_PROFILE_DEPRECATIONS_V1=(
"theme:preset"
)
Future renames (forbid_branches: → branch_deny:, etc.) use the same table. Removed-not-renamed fields use a similar pattern but error instead of warn.
4. services: as structured sidecar entries¶
Schema upgrade. Today services: is validated as "must be an array" but no item shape is enforced (the Shopify case uses []). New shape:
services:
- name: postgres # required, slug-shaped, becomes the compose service name + DNS hostname
image: postgres:17 # required
env: # optional, literal env values only (no secret URIs for sidecars in v1)
POSTGRES_DB: content_infra
POSTGRES_PASSWORD: postgres
volumes: # optional; "named:/path" or "/host:/container"
- postgres-data:/var/lib/postgresql/data
healthcheck: # optional; passed through to compose verbatim
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 5s
retries: 10
depends_on: [] # optional; sidecar-to-sidecar dependencies (rare)
volumes: # top-level; named volumes referenced above
- postgres-data
lib/compose.sh emits these as additional compose services, declares any top-level named volumes, and automatically adds depends_on: { <name>: { condition: service_healthy } } (or service_started when the sidecar has no healthcheck) on the dev service for every declared sidecar. The wrapped app should never see the dev service start before its data dependencies are reachable.
Sidecars are not eligible for secret URI resolution in v1. Sidecar credentials (DB passwords, etc.) are literal values — dev sidecars don't carry real secrets in v1's Shopify+Django scope, and the sidecar-secret path would require teaching compose to do indirection it doesn't natively support. Revisit when dbx-restore-into-sidecar lands (ARD-0002).
5. setup: lifecycle hook — devcontainer-native + boring-verified¶
New top-level field: setup: is a list of shell commands run once after the container first comes up.
setup:
- uv sync --dev
- uv run python backend/manage.py migrate
- cd frontend && npm install
- uv run python backend/manage.py bootstrap_data
Two enforcement points, per the user's "both" preference:
- Devcontainer-native:
lib/compose.shconcatenates the list into a single shell expression and emits it aspostCreateCommandindevcontainer.json. This fires when the container is first created viadevcontainer upOR via VS Code's "Reopen in Container" — the user gets the lifecycle regardless of how they enter. - boring-verified: the emitted
postCreateCommandalso writes/var/lib/boring/setup-completeon success.cmd_openchecks for this marker afterdevcontainer upreturns; if missing, it re-runs setup viadevcontainer exec. Belt-and-suspenders against the "setup partially failed and the marker silently isn't there" case.
Why both: VS Code "Reopen in Container" users never invoke boring open, so the boring-only path would silently miss them. devcontainer-only would silently miss "the setup script started, the postCreate didn't error out, but step 3 of 4 broke partway through" — a real Django failure mode (e.g., bootstrap_data racing Postgres readiness despite the healthcheck).
6. Secret URI resolution at container start — required for this milestone¶
The README's "deferred" status for secret resolution flips to "shipped in v0.2." content-infrastructure forces it: shipping OPENROUTER_API_KEY: literal-value in a checked-in .boring/profile.yaml is a non-starter.
Implementation:
cmd_openwalks the normalized profile'senv:entries afterprofile_load.- For each
{kind: secret, uri: ...}entry, callsecret_resolvefromlib/secrets.sh(which already supportsop://,keychain:,dbx-vault:,vault://,aws-sm:,env:,file:). - Pass each resolved pair to
devcontainer upvia--remote-env KEY=VALUE(repeated). ThedevcontainerCLI injects these into the container env without writing them anywhere on disk. - Literal env values continue to be emitted in
docker-compose.yml'senvironment:block (unchanged). - Resolved secret values are never written to
docker-compose.ymlordevcontainer.json. Even though.devcontainer/is git-ignored, on-disk secrets are a backup/sync exfil channel boring shouldn't open. - Failure to resolve any URI: hard error, name the URI, name the install hint (
secret_resolvealready does this for missing CLIs;cmd_openadds the URI context). The container is not brought up if any required secret can't be resolved.
boring doctor already reports presence of op, vault, aws, security/secret-tool. Users who declare URIs for schemes whose CLI they don't have installed get a clear boring doctor warning + a clear runtime error.
Consequences¶
Positive¶
- Django case unlocks. content-infrastructure can be a first-class boring profile, validating the multi-service path end-to-end on real work.
- Primitives are reusable. Any future stack (Rails+Postgres, Next+Redis, etc.) consumes the same
services:/setup:/ secret-resolution machinery. The marginal cost of a new preset becomes a Dockerfile + a defaults map. - Versioning gives a clean deprecation path.
theme:→preset:is the first rename; the mechanism that handles it handles every future schema change. No silent breakage of in-the-wild profiles. - Secret model finally exercised. Wiring
lib/secrets.shto the open flow validates ARD-0002's "pure URI resolver" claim end-to-end against a real profile with real 1Password URIs.
Negative¶
- More upfront schema. Three new fields (
profile_version:,setup:, the structured-services:shape) plus a rename. Validation surface roughly doubles. - Secret resolution adds CLI dependencies for users. A user opening content-infrastructure now needs
opinstalled (or whatever scheme their profile declares). Mitigated byboring doctoralready reporting these, and by the clear runtime error. postCreateCommandis concatenated as a single shell line. Longsetup:lists become long shell strings. Acceptable in practice (devcontainer's own convention), but harder to read incat .devcontainer/devcontainer.json.
Neutral¶
- Egress allowlist enforcement and dbx integration stay deferred. Both remain in v1.x scope per ARD-0005 and ARD-0002. The django-node profile runs against an empty Postgres seeded by
bootstrap_datafor now; real-data restore via dbx is a later slice. - shop-theme's profile gets a deprecation warning until it migrates. One-line edit (
theme: shopify→preset: shopify+ addprofile_version: "1"), out-of-tree for this ARD.
Alternatives Considered (rejected)¶
- Pure (a) — preset-only. Ship
templates/django-node/with no underlying primitives schema; hard-code the Postgres sidecar and DATABASE_URL wiring inside the compose generator's preset branch. Rejected: the multi-service machinery has to exist anyway (the preset is a multi-service compose); making it user-addressable costs ~no extra code and unlocks the long tail of unsupported stacks. - Pure (b) — primitives-only. Skip the
django-nodepreset; require content-infrastructure users to hand-author astack.dockerfile:+services:block themselves. Rejected: contradicts boring's "non-engineer friendly" thesis (ARD-0001). The dogfood case is exactly where ergonomic defaults pay off; if the dogfooder has to author 60 lines of compose, the demo evaporates. - Keep
theme:, addtheme: django-node. Zero migration cost. Rejected: "theme" is Shopify-product jargon; the word reads wrong for non-Shopify presets. Pre-v1 is the cheapest moment to fix the name; carrying the awkwardness costs every future reader. - Add
preset:as a soft alias totheme:(both work forever). Rejected: doubles the parser surface and the docs surface in perpetuity for the single benefit of shop-theme avoiding a one-line edit. The deprecation-warning path achieves the same backward compatibility without the perpetual cost. - Profile versioning as a separate mini-ARD. Rejected: the rename is the forcing function for versioning, and the two decisions inform each other. Splitting them makes either ARD harder to read without the other.
- Sidecar secret URI resolution in v1. Rejected: sidecar env in compose doesn't have a clean indirection point. Revisit when dbx-restore-into-sidecar lands (ARD-0002) — that's the natural moment because dbx will be writing real secrets into sidecar configuration anyway.
- boring-managed
setup:only (skip devcontainer'spostCreateCommand). Rejected: silently breaks the "Reopen in Container" flow that VS Code users rely on. - Devcontainer-native
setup:only (skip the marker verification). Rejected: doesn't catch partial-failure modes (Django'sbootstrap_dataracing the Postgres healthcheck is a real one).
Implementation Order (replaces ARD-0004 step #8)¶
- Profile schema v1 + versioning (
lib/profile.sh). Addprofile_version:parsing + missing/unknown handling. Add the deprecation table; warn + rewritetheme:→preset:in-memory. Validation accepts both keys. Fixtures + tests for: missing version, v1, unknown version,theme:form,preset:form. services:schema (lib/profile.sh). Parse structured service entries. Top-levelvolumes:declaration. Validation: name slug-shaped, image required, volumes well-formed.setup:schema (lib/profile.sh). List of shell-command strings. Normalize into JSON output.- Multi-service compose emit (
lib/compose.sh). Whenservices:non-empty: emit each as compose service, declare top-level volumes, adddepends_on(withcondition: service_healthywhere applicable) on the dev service. Preserve single-service Shopify path untouched. setup:codegen (lib/compose.sh+ devcontainer emit). List →postCreateCommandshell concatenation that also writes/var/lib/boring/setup-completeon success.cmd_openre-verifies the marker post-up; re-runs setup viadevcontainer execif missing.- Secret URI resolution wiring (
boringdispatcher /cmd_open+lib/devcontainer.sh). Walk env entries; resolve secret URIs vialib/secrets.sh; pass todevcontainer upvia repeated--remote-env KEY=VALUE. Hard error on resolution failure. templates/django-node/Dockerfile(new preset).python:3.14-slim-bookwormbase.uv(pinned), Node 20 (NodeSource),libpq5,postgresql-client, git, gh, sudo, tini.devuser UID/GID 1000 with NOPASSWD sudo. Pre-create/workspaceand/home/dev/.config. xdg-open shim verbatim. ARD-0006 trust-anchor enforcement verbatim.COPY --from=common --chown=dev:dev claude/ /home/dev/.claude/.preset: django-noderesolution (lib/profile.sh+lib/compose.sh). Default-seed:services:withpostgres:17sidecar (namepostgres,POSTGRES_DB=content_infra,POSTGRES_PASSWORD=postgres, named volumepostgres-data,pg_isready -U postgreshealthcheck);forward_ports: [8000, 5173];env.DATABASE_URL=postgres://postgres:postgres@postgres:5432/content_infra. User-authored values win on conflict.- content-infrastructure dogfood profile + end-to-end smoke. Author
~/code/work/content-infrastructure/.boring/profile.yaml(preset: django-node, secret URIs for the five prod secrets,setup:for migrate + bootstrap_data + npm install,guardrails.forbid_branches: [main]). Smoke:boring openbrings up Django on :8000 + Vite on :5173 + Postgres 17 sidecar withcontent_inframigrated and seeded, with secrets resolved from 1Password. Thebootstrap_dataadmin-user creation stays a documented post-open manual step (admin creds don't belong in the profile or any 1Password item the profile points at).
Egress allowlist enforcement remains deferred (ARD-0005, v1.x). dbx integration remains deferred (ARD-0002, v1.x — content-infrastructure runs on bootstrap_data-seeded Postgres for now).