RFC-024 · Containerized local stack — docker compose web + on-demand pipeline runners + docker-e2e CI gate
Status: Closed (v0.7.x) · 2026-05-22 · Closes into: ADR-063 / 064 / 065 / 066 · Future: VPS production deploy (deferred, see §10)
Why this is an RFC. Three things bind here and a fourth follows. (1) The web tier becomes a real container image with its own Dockerfile, so its filesystem layout, base image, port + healthcheck, and the way it serves
adapter-staticoutput are now load-bearing decisions instead of "whatever GH Pages does." (2) Pipelines that previously ran inside GH Actions runners (npm run fetch:launches,npm run images:hotspots,npm run fetch-assets,npm run build-image-provenance) need a runtime story with agdal-async-capable system layer,tsx+ Node ≥ 20, the samestatic/data/write surface, and the same.image-cache/reuse — but now invoked asdocker compose run --rm pipeline <script>instead ofnpm run. (3) The shared/datavolume is the contract that connects (1) and (2): pipeline writes JSON files there, nginx reads JSON files there, and no rebuild is needed in between. Get that wrong and either pipeline refreshes don't show up live or the web container has to be torn down on every refresh. (4) Once the local stack works, a CI workflow that stands up the same compose file inside GH Actions and runs the existing Playwright suite against it becomes the validation gate for the eventual VPS migration — without it, "it works on my laptop" doesn't survive the move. GH Pages, GHCR publishing, and the actual VPS deploy are explicitly out of scope; they become follow-ups once this stack is proven.
1 · Scope + non-scope
In scope (this RFC)
- One
Dockerfilewith two stages:web(nginx servingbuild/) andpipeline(Node + tsx + gdal system libs + repo source for scripts). - One
docker-compose.ymlat repo root with two services:web+pipeline-runner. - One named volume
orrery-datamounted into both services at/data(or at a path that maps to wherestatic/data/lives in the web container — see §3). - One named volume
orrery-cachefor.image-cache/reuse across pipeline invocations. - A
make/npmscript surface so the on-demand invocations are short and consistent (npm run docker:up,npm run docker:fetch-launches, etc.). - One new GH Actions workflow (
docker-e2e.yml) that stands up the compose stack and runs the existing Playwright suite againsthttp://localhost:8080(or whatever port web maps). - A
docs/guides/docker-stack.mdoperator guide covering local bring-up, volume reset, and pipeline invocation.
Explicitly out of scope (deferred follow-ups)
- No GHCR / Docker Hub publishing workflow. Images stay local. CI builds them only inside the e2e runner.
- No VPS deploy automation. No Caddy/Traefik, no Let's Encrypt, no
watchtower, no systemd units, no IaC. The VPS migration is its own RFC once this one ships and beds in. - No GH Pages retirement.
preview.yml+release.ymlcontinue publishing to GH Pages exactly as today. The docker stack is additive infrastructure. - No replacement of the existing
refresh-launches.ymlcron. That workflow still commits refreshedlaunches.jsonto git → GH Pages picks it up. The docker pipeline runner is for local invocation of the same scripts, not a cron replacement. - No multi-arch image build (arm64).
linux/amd64only for now. Add arm64 when (and only when) a VPS target requires it. - No production hardening (non-root user, read-only rootfs, seccomp profiles, distroless base). Tracked under §10 for the future VPS RFC.
The shape of this scoping is: the docker stack is real, but it's a local-dev + CI-validation artefact, not a deploy target yet. GH Pages remains canonical.
2 · Architecture overview
┌─────────────────────────── docker compose ───────────────────────────┐
│ │
│ ┌─────────────────────────┐ ┌──────────────────────────┐ │
│ │ web │ │ pipeline-runner │ │
│ │ nginx:alpine │ │ node:20-bookworm-slim │ │
│ │ ports: 8080→80 │ │ + libgdal-dev │ │
│ │ serves /usr/share/ │ │ + repo source │ │
│ │ nginx/html (build/) │ │ entrypoint: tsx <script>│ │
│ │ reads /usr/share/ │ │ no exposed port │ │
│ │ nginx/html/data/ │ │ exits when script done │ │
│ │ (mounted volume) │ │ │ │
│ └──────────┬──────────────┘ └────────┬─────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────── orrery-data ──────────────────┐ │
│ │ (named volume mounted into both services) │ │
│ │ launches.json launches-historic/*.json │ │
│ │ surface-hotspots.json image-provenance.json … │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌─ orrery-cache ─┐ │
│ │ HiRISE JP2s │ │
│ │ GCAT TSV │ │
│ │ LL2 responses │ │
│ └────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘webis always-on (docker compose up -d). It's nginx serving the static bundle; no Node runtime at the web tier.pipeline-runneris not a long-running service. Itsdocker-compose.ymlentry usesprofiles: ['manual']sodocker compose updoes not start it. It only runs viadocker compose run --rm pipeline-runner <script>.- The
orrery-datavolume is the integration seam: pipeline writes, web reads, no rebuild in between. PWA SW's existingNetworkFirststrategy on/data/**/*.json(vite.config.ts:90) handles client-side freshness so refreshes show up on next request. - The
orrery-cachevolume is pipeline-only; web never reads it.
Why two services and not three or one: one service can't because the web nginx image cannot run tsx; three or more is premature — we don't need a scheduler service, a builder service, or a proxy service for local dev. If/when those become real needs at the VPS stage, they get added then.
3 · Volume mount strategy
The thing that has to be exactly right is where static/data/ lives in the image vs where the volume mounts vs where the Vite build output references it. SvelteKit's adapter-static copies static/ into build/ at build time, so the production layout is build/data/launches.json served at /data/launches.json. nginx serves build/ from /usr/share/nginx/html/, so the URL contract /data/<file>.json resolves to /usr/share/nginx/html/data/<file>.json in the container.
Therefore the volume mount on the web side is:
volumes:
- orrery-data:/usr/share/nginx/html/data:roThe :ro is intentional — the web container never writes; only the pipeline-runner does. This is a defence-in-depth detail, not a hot security boundary.
On the pipeline side the same volume mounts at /repo/static/data/, which is where the scripts already write today:
volumes:
- orrery-data:/repo/static/data
- orrery-cache:/repo/.image-cache
- .:/repo:ro # source code for tsx, read-onlyThe third mount — repo source read-only — is what makes the pipeline runner not need rebuilding every time a script changes. The image carries node_modules + system libs; the source comes from a bind-mount.
Trade-off. Bind-mounting .:/repo:ro means the pipeline runner is tightly coupled to the host repo layout. That's fine for local dev (the whole point); when this graduates to a VPS, the pipeline image will instead bake the source in (per the future VPS RFC). The compose file will get an override file then; the base contract here doesn't change.
Initial seed. On first docker compose up, orrery-data is empty — nginx would serve 404 for every /data/*.json request. The web Dockerfile copies the current state of static/data/ into /usr/share/nginx/html/data/ at build time, so the volume mount overlay strategy applies: an initialised volume keeps the image's seed data; subsequent pipeline writes update it in place. This mirrors how postgres images seed /var/lib/postgresql/data on first run.
4 · The Dockerfile
A single multi-stage Dockerfile produces both images. Multi-stage because the system deps (gdal, libvips, libheif if vision pipeline grows into it) only need to land in the pipeline stage; the web stage is just nginx + a copy of build/.
# ─── Stage 0: builder — produces the static bundle ──────────────────
FROM node:20-bookworm-slim AS builder
WORKDIR /repo
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ─── Stage 1: web — nginx serving the static bundle ─────────────────
FROM nginx:alpine AS web
COPY --from=builder /repo/build /usr/share/nginx/html
COPY ops/docker/nginx.conf /etc/nginx/conf.d/default.conf
HEALTHCHECK CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
EXPOSE 80
# ─── Stage 2: pipeline-runner — Node + tsx + gdal system layer ──────
FROM node:20-bookworm-slim AS pipeline
RUN apt-get update && apt-get install -y --no-install-recommends \
libgdal-dev gdal-bin \
ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /repo
COPY package.json package-lock.json ./
RUN npm ci
# Source is bind-mounted at runtime; image only carries deps.
ENTRYPOINT ["npx", "tsx"]The web and pipeline stages are independent — docker compose build produces both. Build cache is shared via builder.
Why node:20-bookworm-slim rather than alpine: gdal-async + sharp builds against glibc; alpine's musl gives us hours of "why is this segfaulting" debugging time we don't need. Slim debian is ~80 MB vs alpine's ~5 MB, but the pipeline image is already > 1 GB once gdal lands, so the base image size is rounding error.
Why nginx:alpine for web: web stage has no Node, no glibc dependency, no gdal-async. Nginx alpine is a 25 MB image and the right tool for "serve a static bundle on port 80."
5 · docker-compose.yml
services:
web:
build:
context: .
target: web
ports:
- '8080:80'
volumes:
- orrery-data:/usr/share/nginx/html/data:ro
restart: unless-stopped
pipeline-runner:
build:
context: .
target: pipeline
profiles: ['manual'] # not started by `docker compose up`
volumes:
- orrery-data:/repo/static/data
- orrery-cache:/repo/.image-cache
- .:/repo:ro
environment:
- LL2_PATREON_KEY=${LL2_PATREON_KEY:-}
- NODE_OPTIONS=--max-old-space-size=4096
# ENTRYPOINT ["npx","tsx"] in the Dockerfile means
# `docker compose run --rm pipeline-runner scripts/fetch-launches.ts`
# works without further glue.
volumes:
orrery-data:
orrery-cache:profiles: ['manual'] is the key — it's what makes pipeline-runner "on demand": docker compose up -d brings up web only; docker compose run --rm pipeline-runner … brings up a one-shot pipeline container that exits when the script does.
6 · Pipeline invocation surface (npm scripts as the user-facing CLI)
Operators shouldn't need to remember docker compose run --rm pipeline-runner …. New package.json scripts wrap the long forms:
{
"scripts": {
"docker:build": "docker compose build",
"docker:up": "docker compose up -d web",
"docker:down": "docker compose down",
"docker:logs": "docker compose logs -f web",
"docker:reset-data": "docker volume rm orrery_orrery-data orrery_orrery-cache || true",
"docker:fetch-launches": "docker compose run --rm pipeline-runner scripts/fetch-launches.ts",
"docker:images:hotspots":"docker compose run --rm pipeline-runner scripts/fetch-hotspot-imagery.ts",
"docker:fetch-assets": "docker compose run --rm pipeline-runner scripts/fetch-assets.ts",
"docker:validate-data": "docker compose run --rm pipeline-runner scripts/validate-data.ts",
"docker:build-image-provenance": "docker compose run --rm pipeline-runner scripts/build-image-provenance.ts",
"docker:build-link-provenance": "docker compose run --rm pipeline-runner scripts/build-link-provenance.ts"
}
}Every pipeline that's currently exposed via an npm run … gets a npm run docker:… mirror. The two coexist — npm run fetch:launches continues to run on the host (current behaviour); npm run docker:fetch-launches runs the same script inside the container. Operators choose; CI standardises on the docker form.
.env file. Pipelines that need secrets (LL2_PATREON_KEY, future API keys) load them from a .env at repo root, which docker-compose reads automatically. The file is gitignored. The CI workflow loads secrets directly from ${{ secrets.* }} into compose env vars.
7 · The docker-e2e CI workflow
New .github/workflows/docker-e2e.yml. Runs on pushes to main and on PRs that touch the docker/compose/build surface. Standalone — separate from ci.yml (lint + test + build) and e2e.yml (Playwright against vite preview). The existing two are kept; this is the third leg.
name: Docker stack e2e
on:
push: { branches: [main] }
pull_request:
paths:
- 'Dockerfile'
- 'docker-compose.yml'
- 'ops/docker/**'
- '.github/workflows/docker-e2e.yml'
workflow_dispatch:
jobs:
docker-e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build images
run: docker compose build
- name: Bring up web
run: docker compose up -d web
- name: Wait for web to be healthy
run: |
for i in $(seq 1 60); do
if curl -sf http://localhost:8080/ >/dev/null; then exit 0; fi
sleep 2
done
docker compose logs web
exit 1
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'npm' }
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Run Playwright against docker web
env:
PLAYWRIGHT_BASE_URL: http://localhost:8080
run: npx playwright test --project=desktop-chromium
- name: Capture web logs on failure
if: failure()
run: docker compose logs web
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/Playwright's playwright.config.ts already honours PLAYWRIGHT_BASE_URL via the use.baseURL pattern — we just thread it through. The webServer block in the config has to be made conditional so this workflow doesn't try to start a second vite preview on top of the docker web container; either via process.env.PLAYWRIGHT_BASE_URL short-circuit, or a separate playwright.docker.config.ts. The former is simpler and lives in §11 of the implementation plan.
Why a separate workflow rather than extending e2e.yml: the docker build is 3-4 minutes of CPU on a cold cache; bolted onto e2e.yml it'd double the per-PR CI cost on every change. As a separate workflow it can be paths:-gated to only run when docker surface changes, while e2e.yml continues to run on every PR. The two cover different things: e2e.yml validates the SvelteKit app against vite preview; docker-e2e.yml validates the production-shape nginx-served bundle.
8 · Operational guide (the doc that ships with this)
docs/guides/docker-stack.md covers:
- First-time bring-up:
npm run docker:build && npm run docker:up && open http://localhost:8080. - How to run a pipeline:
npm run docker:fetch-launches(and the full list). - How to view live logs:
npm run docker:logs. - How to reset volumes when you want to test a cold first-run:
npm run docker:reset-data. - The
.envtemplate. - Known limitations (no production hardening, no arm64, no GHCR push).
- Troubleshooting (port 8080 already in use, gdal build failures, volume permission weirdness on macOS).
The guide is the operator-facing surface; this RFC + ADRs are the architecture record.
9 · Migration plan + rollout
- Slice 1 — Dockerfile + compose + npm scripts. Web stage + pipeline-runner stage.
docker compose up -d webserves the bundle atlocalhost:8080.docker compose run --rm pipeline-runner scripts/fetch-launches.tswrites to the volume. Manual validation: refresh launches in the running container, observe the calendar shows the new data. Closes ADR-063, ADR-064, ADR-065. - Slice 2 — docker-e2e CI workflow. New
docker-e2e.yml. PR introducing it carries theplaywright.config.tschange. CI green = ADR-066 closed. - Slice 3 — operator guide.
docs/guides/docker-stack.md+ a one-line pointer fromAGENTS.md(under a new "Docker stack (local + CI)" sub-section).
Total: ~3-5 days of focused work. No data migration. No user-visible change. No GH Pages disruption.
10 · Future work (not this RFC)
These are the slabs that fall out of "now let's actually deploy this to a VPS." Each gets its own RFC when the user actually wants to ship.
- GHCR publishing pipeline. New
docker-publish.ymlthat builds + pushesghcr.io/<owner>/orrery:<sha>and:lateston every merge to main. Adds image-signing (cosign) and SBOM (syft). Requires a GHCR PAT in the VPS-side puller. - VPS host config. Caddy as reverse proxy + TLS via Let's Encrypt.
compose.prod.ymloverride file.systemd --userunit to bring the stack up on boot. Webhook receiver (smee.io or self-hosted) to pull the new image on push. - Scheduled pipelines on the VPS. Replace
refresh-launches.ymlGH Actions cron with a host-sidesystemd.timerthat runsdocker compose run --rm pipeline-runner …. Decouples the refresh cadence from GH Actions minutes and lets us schedule heavier hotspot/imagery refreshes that don't fit in 6h cron windows. - Production hardening. Non-root user in both stages, read-only rootfs on web, seccomp default profile, distroless base for web. Memory limits + restart policy tuning. Log rotation.
- Multi-arch builds.
linux/arm64if the VPS choice ends up arm-based (Hetzner CCX-ARM, Ampere instances, Pi cluster, etc.). - Observability. Prometheus scrape on
web(nginx-prometheus-exporter), optional Loki for log aggregation. Out of scope until a real outage motivates it. - GH Pages retirement. Only after VPS has carried production for a quarter without incident. Until then, GH Pages is the canonical site and the VPS is a parallel deploy. This sequencing is per the user's direction in the scoping pass.
11 · Implementation notes (drop-ins for the closing ADRs)
nginx.conf(ops/docker/nginx.conf): standard adapter-static config —try_files $uri $uri/ /index.html, gzip on forapplication/jsonand text/* types,Cache-Control: no-cacheon*.jsonunder/data/(so pipeline refreshes propagate without surprise),Cache-Control: public, max-age=31536000, immutablefor hashed assets under/_app/immutable/..dockerignorelives at repo root and excludesnode_modules,.svelte-kit,build,test-results,playwright-report,.image-cache,static/data(we don't want the data-volume-seed bypassed by including the live data in the build context).playwright.config.tsshort-circuit:tswebServer: process.env.PLAYWRIGHT_BASE_URL ? undefined : { /* …existing config */ }, use: { baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:4173', … },When
PLAYWRIGHT_BASE_URLis set, Playwright skips spawning its own preview and points at whatever's at that URL (the dockerwebin CI; whatever the operator decides locally).The pipeline-runner ENTRYPOINT is
["npx","tsx"]so the invocation surface isdocker compose run --rm pipeline-runner <script.ts>. Scripts that take args do so naturally (run --rm pipeline-runner scripts/fetch-launches.ts --since 2026-05-01etc.).The
validate-datascript (scripts/validate-data.ts) is unchanged; it just runs inside the container the same way. Its existinggit ls-filesfallback (see CLAUDE.md note) works because the source bind-mount is the repo, with git history intact.
12 · Open questions
- Is bind-mounting the host repo into the pipeline container acceptable on Windows? WSL2 + bind mounts work but have known I/O perf cliffs. Punt: Windows isn't supported as a dev host today; if it becomes one, switch to baked-in source for the pipeline image.
- Does the data volume need backup tooling at this stage? No —
static/data/is regenerable from the source pipelines (LL2 + GCAT for launches, HiRISE for hotspots, etc.). The volume is a cache, not a database. Restated at the VPS RFC. - Should we image-pin pipeline runs? For determinism across "the launches refresh produced different numbers this week" debugging, we may want the pipeline-runner image tagged + the run logged with the image SHA. Tracked as a §10 follow-up — not Slice 1 work.
RFC-024 · 2026-05-22 · Marko + Claude · v0.7.x slice gate · ADRs 063-066