ADR-063 — Web container: multi-stage Dockerfile, nginx serves adapter-static bundle
Status · Accepted Date · 2026-05-22 Closes into · RFC-024 §4, §5 Related ADRs · ADR-005 (nginx + docker-compose · superseded by ADR-014, now partially revived for local stack), ADR-014 (GH Pages deploy), ADR-029 (PWA + service worker) Supersedes · none
Context
Orrery is published as a static SPA via @sveltejs/adapter-static. Today the only deploy path is GH Pages (per ADR-014). RFC-024 introduces a local docker-compose stack that wraps the same static bundle in an nginx container, so a contributor can docker compose up -d web && open http://localhost:8080 and exercise the production-shape artefact (gzipped, hash-asset-cached, no Vite dev server) without paying GH Pages deploy latency. The same image is what the new docker-e2e.yml CI workflow stands up to run Playwright against.
The decision is how to package this — one image vs many, what base, what stage layout, where the build runs, what nginx serves and how, and how this image relates to the eventual VPS deploy story (deferred to a separate RFC). Getting it wrong looks like: bloated images that ship node_modules at runtime; rebuild-on-every-data-change patterns; image-vs-volume seam confusion that makes pipeline refreshes invisible to the web tier.
ADR-005's original "nginx + docker-compose" framing was retired when the project moved to a SvelteKit + GH Pages model (ADR-014). RFC-024 partially revives it — but as local + CI infrastructure, not as the production-deploy target. The VPS story is its own future RFC; this ADR is scoped to the local stack.
Decision
Ship a single multi-stage Dockerfile at the repo root. Stage 0 (builder) runs npm ci && npm run build on node:20-bookworm-slim. Stage 1 (web) is nginx:alpine, copies build/ from the builder, copies an ops/docker/nginx.conf, and exposes port 80. Stage 2 (pipeline) is covered by ADR-064. The compose file wires web to host port 8080 and mounts the orrery-data named volume read-only at /usr/share/nginx/html/data.
Image layout
nginx:alpine
└── /usr/share/nginx/html/
├── index.html
├── 404.html
├── _app/immutable/ ← hashed assets, immutable cache
├── data/ ← VOLUME MOUNT POINT (read-only)
│ ├── launches.json
│ ├── missions/*.json
│ ├── i18n/*.json
│ └── … ← initially seeded from builder, then volume-overridden
├── manifest.webmanifest
├── sw.js
└── …The image carries a seed of static/data/ (whatever was in the repo at build time). On first docker compose up, the empty orrery-data volume is initialised from this seed (standard docker named-volume-on-first-mount behaviour, same mechanism postgres uses for /var/lib/postgresql/data). Subsequent pipeline writes update the volume contents in place; the seed copy in the image is masked.
nginx.conf shape
ops/docker/nginx.conf lives in the repo. Key directives:
location / {
try_files $uri $uri/ /index.html; # adapter-static SPA fallback
}
location /data/ {
add_header Cache-Control "no-cache"; # pipeline-refresh path; freshness matters
add_header Access-Control-Allow-Origin "*"; # PWA SW already fetches with credentials='omit'
}
location ~* ^/_app/immutable/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
gzip on;
gzip_types application/json application/javascript text/css text/html text/plain image/svg+xml;
gzip_min_length 1024;The two key choices:
/data/*isno-cache. Pipeline writes the volume; the next browser request must revalidate so refreshes propagate. The PWA SW's existingNetworkFirststrategy on/data/**/*.json(vite.config.ts:90) handles client-side caching; nginx just has to not lie about freshness./_app/immutable/*isimmutable-cached for a year. Vite hashes every asset filename, so the URL itself encodes the version; aggressive caching is correct and matches GH Pages behaviour.
Build context + .dockerignore
Repo root .dockerignore excludes node_modules, .svelte-kit, build, test-results, playwright-report, .image-cache, static/data (we don't want the live data in the build context — the volume-mount on first run is the seeding mechanism, not the image bake; see §3 of the RFC for why). The exclude list keeps the build context under ~50 MB; without it, hotspots imagery + cache bloats it past 1 GB.
Stage choice (node:20-bookworm-slim builder, nginx:alpine web)
- Builder:
node:20-bookworm-slim. Node 20 matches the.github/workflows/ci.ymlrunner version (so CI and docker produce bit-identicalbuild/output). Debian-bookworm-slim because glibc is the path of least resistance — alpine + musl breaksgdal-asyncandsharpinstall-from-source and we've already lost hours to it elsewhere. Slim variant trims ~150 MB off the standard image. - Web:
nginx:alpine. 25 MB image, has no Node, no glibc dependencies, no userland complexity. Right tool for "serve a static bundle on port 80." We accept that web and pipeline use different base distros (alpine vs debian); it's not a code-sharing problem because web runs no Node code.
Healthcheck
HEALTHCHECK CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 — alpine images have wget by default (not curl). 30s interval. Lets docker compose ps and CI's until curl …; do sleep 2; done block converge cleanly.
Rationale
A single multi-stage Dockerfile (rather than two separate Dockerfiles) shares the builder stage between web + pipeline images — npm ci happens once, layer cache is shared, image build time is roughly half what two independent Dockerfiles would cost.
Nginx-serves-static rather than vite preview-in-a-container because: (a) vite preview is not a production server — its own docs say so; (b) vite preview doesn't honour our Cache-Control policies for _app/immutable; (c) nginx is what every realistic VPS deploy will use anyway, so testing locally + in CI against nginx surfaces real production behaviour. Using vite preview would deliver test results that don't generalise.
Read-only mount on the web side is defence-in-depth: the web container shouldn't write to the data volume even by accident. Pipeline-runner has read-write access to the same volume.
Volume-overrides-seed is the only mechanism that lets fresh deploys (empty volume) self-bootstrap with whatever data was committed at build time, then stay fresh as pipeline refreshes layer on top. The alternative (always-empty volume + run pipeline before first useful render) introduces a chicken-and-egg state where the first request after deploy 404s on every /data/*.json.
Alternatives considered
- Caddy instead of nginx for the web container. Caddy is friendlier (automatic HTTPS, declarative Caddyfile) but its strengths only matter when TLS + auto-cert are in scope, which they aren't for local + CI. Nginx is the dominant choice for static-bundle serving in production VPS deployments and matches what the future VPS RFC will likely also pick — using it here means we test what we'll deploy. Reconsider for the VPS RFC if TLS-handling pushes toward Caddy.
vite previewin a node container. Rejected. Not a production server. Doesn't honour cache-control policy. Adds a Node runtime to the web tier for no benefit.- Distroless nginx (
gcr.io/distroless/static-debian12with a sidecar serving binary). More secure (no shell, no package manager). Premature for local + CI; revisit in the production-hardening pass under §10 of RFC-024. - Single-stage Dockerfile that builds everything in one image. Bloats the runtime image with
node_modules+ the entire builder toolchain (~1 GB for nothing). Multi-stage is the standard pattern; this isn't a case where the standard pattern is wrong. - Don't bake a
static/data/seed into the image; require operators to run pipelines before first use. Rejected for the chicken-and-egg reason above. The seed in the image is the same data that's in the repo at build time — it's not a privacy/secrecy issue, and it's the same data GH Pages already serves. - Use
adapter-nodeinstead ofadapter-staticfor the docker stack. Would add an SSR Node runtime to the web container, opens up routes/server endpoints. Out of scope — Orrery is a static app today and there's no driver to change that. Adding adapter-node would also break the GH Pages publish path (no Node runtime there).
Consequences
Positive:
- Production-shape artefact reachable locally in one command (
npm run docker:up). - CI can stand up the same image in
docker-e2e.ymland exercise it with the existing Playwright suite — closes a known gap wherevite previewdoesn't exhibit production nginx behaviour. - The image is small (~25 MB nginx + the static bundle, ~30-50 MB total depending on data volume) and starts in < 1 second.
- Forward-compatible with the future VPS RFC: the same image is what gets pushed to GHCR; only the compose file diverges (TLS proxy override, etc.).
Negative:
- One more thing to maintain — Dockerfile, nginx.conf, .dockerignore, the npm script wrappers. Roughly +100 LOC of ops surface.
- Image rebuild on every code change (no live-reload through the container). For active dev,
npm run devis still the canonical loop; the docker stack is for "test the production artefact" + CI. - Bumping Node 20 → 22 (or nginx → next major) requires synchronising the Dockerfile, the GH Actions runner versions, and the e2e baseline. Tracked as routine maintenance, not a blocker.
Decision status
Accepted on 2026-05-22. Shipped in commit 4789855ee (Slice 1 of RFC-024) — but with one deliberate divergence from the original Draft: the web tier does NOT use a custom Dockerfile. It runs stock nginx:alpine with three read-only bind-mounts (./build, ./static/data, ./ops/docker/nginx.conf). The multi-stage Dockerfile that originally also built the web image was abandoned because rebuilding the SvelteKit bundle inside a linux/arm64 Docker VM on Apple Silicon triggered ~30 min of native-module compilation (gdal-async, sharp, canvas) for an artefact the host's npm run build already produces in 1-2 min. The bind-mount approach delivers the same nginx-serving contract — same nginx.conf, same cache headers, same SPA fallback — at zero build cost. See the Amendment — 2026-05-22 section below.
Amendment — 2026-05-22 (during Slice 1 implementation): web container drops its Dockerfile
The Draft prescribed a multi-stage Dockerfile with a web target that copied build/ from a builder stage. First attempt to build that image on the implementer's machine (Apple Silicon, Docker Desktop) revealed two compounding problems:
- Build context bloat (3.48 GB). The original
.dockerignoremissed.linux-node-modules(parallel-agent linux-binary cache, 429 MB),.xdg-cache(1 GB), and.xdg-data(1.3 GB — argos-translate models from the retired wave23 toolchain). BuildKit's context transfer alone took 165 seconds. Fixed by extending.dockerignore. - Native-module compile inside linux/arm64 VM (~30 min and counting). Even with the slim context, the
builderstage'snpm citriggeredgdal-async+sharp+canvasto compile against system libs inside the Linux VM. That was wasted work — the host'snpm run buildalready runsvite buildin ~1-2 min, and none of those native modules are needed for SvelteKit's adapter-static build (they're scripts-only deps).
The fix is structural, not tuning: the web container doesn't need a build step at all. nginx's job is to serve files; the files already exist on the host after npm run build. So the docker-compose.yml was changed to:
web:
image: nginx:alpine
volumes:
- ./build:/usr/share/nginx/html:ro
- ./static/data:/usr/share/nginx/html/data:ro
- ./ops/docker/nginx.conf:/etc/nginx/conf.d/default.conf:roNo custom Dockerfile for web. Pulling nginx:alpine (25 MB) is a one-time 5-second download; subsequent docker compose up -d web invocations start in under a second. The repo-root Dockerfile is reduced to the pipeline-runner only (ADR-064).
Trade-off — the web stack now requires npm run build to be run on the host before docker compose up -d web. That mirrors what operators already do for GH Pages (same npm run build), so it's not new cognitive load — but it is one more step compared to "everything inside docker compose." The Dockerfile-builds-the-bundle approach we abandoned would have been self-contained but is fundamentally the wrong tool for an Apple Silicon dev host. When the future VPS RFC materialises, the prod image will bake the bundle in (built either on the VPS or in CI), at which point the local-dev bind-mount and the prod baked-in artefact diverge cleanly. The contract this ADR documents — nginx serves the adapter-static bundle with the cache-header + SPA-fallback policy in ops/docker/nginx.conf — holds either way.
The nginx.conf itself acquired three small adjustments during Slice 1:
try_files $uri $uri.html $uri/index.html /index.html(instead of the original$uri $uri/) — adapter-static emitsbuild/missions.html(file at root) rather thanbuild/missions/index.html, so the bare$uri/fallback hit a 403 on the directory.include /etc/nginx/mime.types;before the customtypes { application/manifest+json webmanifest; }block — a baretypes {}block replaces the default mime mappings (a known nginx footgun), which clobberedapplication/jsonfor.jsonfiles.Cache-Control: no-cacheis set on/data/,/sw.js, and/manifest.webmanifest;public, max-age=31536000, immutableis set on/_app/immutable/*.
All three landed in ops/docker/nginx.conf in the same Slice 1 commit. None change this ADR's overall contract; they're implementation details that this Amendment records so future maintainers see "yes, we considered the directory-vs-file thing."