Skip to content

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:

nginx
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:

  1. /data/* is no-cache. Pipeline writes the volume; the next browser request must revalidate so refreshes propagate. The PWA SW's existing NetworkFirst strategy on /data/**/*.json (vite.config.ts:90) handles client-side caching; nginx just has to not lie about freshness.
  2. /_app/immutable/* is immutable-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.yml runner version (so CI and docker produce bit-identical build/ output). Debian-bookworm-slim because glibc is the path of least resistance — alpine + musl breaks gdal-async and sharp install-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 preview in 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-debian12 with 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-node instead of adapter-static for 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.yml and exercise it with the existing Playwright suite — closes a known gap where vite preview doesn'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 dev is 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 .dockerignore missed .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 builder stage's npm ci triggered gdal-async + sharp + canvas to compile against system libs inside the Linux VM. That was wasted work — the host's npm run build already runs vite build in ~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:

yaml
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:ro

No 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:

  1. try_files $uri $uri.html $uri/index.html /index.html (instead of the original $uri $uri/) — adapter-static emits build/missions.html (file at root) rather than build/missions/index.html, so the bare $uri/ fallback hit a 403 on the directory.
  2. include /etc/nginx/mime.types; before the custom types { application/manifest+json webmanifest; } block — a bare types {} block replaces the default mime mappings (a known nginx footgun), which clobbered application/json for .json files.
  3. Cache-Control: no-cache is set on /data/, /sw.js, and /manifest.webmanifest; public, max-age=31536000, immutable is 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."

Orrery — architecture documentation · MIT · No tracking