Skip to content

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-static output 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 a gdal-async-capable system layer, tsx + Node ≥ 20, the same static/data/ write surface, and the same .image-cache/ reuse — but now invoked as docker compose run --rm pipeline <script> instead of npm run. (3) The shared /data volume 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 Dockerfile with two stages: web (nginx serving build/) and pipeline (Node + tsx + gdal system libs + repo source for scripts).
  • One docker-compose.yml at repo root with two services: web + pipeline-runner.
  • One named volume orrery-data mounted into both services at /data (or at a path that maps to where static/data/ lives in the web container — see §3).
  • One named volume orrery-cache for .image-cache/ reuse across pipeline invocations.
  • A make/npm script 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 against http://localhost:8080 (or whatever port web maps).
  • A docs/guides/docker-stack.md operator 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.yml continue publishing to GH Pages exactly as today. The docker stack is additive infrastructure.
  • No replacement of the existing refresh-launches.yml cron. That workflow still commits refreshed launches.json to 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/amd64 only 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 │    │
│                                                └────────────────┘    │
└──────────────────────────────────────────────────────────────────────┘
  • web is always-on (docker compose up -d). It's nginx serving the static bundle; no Node runtime at the web tier.
  • pipeline-runner is not a long-running service. Its docker-compose.yml entry uses profiles: ['manual'] so docker compose up does not start it. It only runs via docker compose run --rm pipeline-runner <script>.
  • The orrery-data volume is the integration seam: pipeline writes, web reads, no rebuild in between. PWA SW's existing NetworkFirst strategy on /data/**/*.json (vite.config.ts:90) handles client-side freshness so refreshes show up on next request.
  • The orrery-cache volume 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:

yaml
volumes:
  - orrery-data:/usr/share/nginx/html/data:ro

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

yaml
volumes:
  - orrery-data:/repo/static/data
  - orrery-cache:/repo/.image-cache
  - .:/repo:ro   # source code for tsx, read-only

The 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/.

dockerfile
# ─── 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

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

jsonc
{
  "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.

yaml
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 .env template.
  • 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 web serves the bundle at localhost:8080. docker compose run --rm pipeline-runner scripts/fetch-launches.ts writes 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 the playwright.config.ts change. CI green = ADR-066 closed.
  • Slice 3 — operator guide. docs/guides/docker-stack.md + a one-line pointer from AGENTS.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.yml that builds + pushes ghcr.io/<owner>/orrery:<sha> and :latest on 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.yml override file. systemd --user unit 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.yml GH Actions cron with a host-side systemd.timer that runs docker 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/arm64 if 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 for application/json and text/* types, Cache-Control: no-cache on *.json under /data/ (so pipeline refreshes propagate without surprise), Cache-Control: public, max-age=31536000, immutable for hashed assets under /_app/immutable/.

  • .dockerignore lives at repo root and excludes node_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.ts short-circuit:

    ts
    webServer: process.env.PLAYWRIGHT_BASE_URL ? undefined : { /* …existing config */ },
    use: { baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:4173', … },

    When PLAYWRIGHT_BASE_URL is set, Playwright skips spawning its own preview and points at whatever's at that URL (the docker web in CI; whatever the operator decides locally).

  • The pipeline-runner ENTRYPOINT is ["npx","tsx"] so the invocation surface is docker 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-01 etc.).

  • The validate-data script (scripts/validate-data.ts) is unchanged; it just runs inside the container the same way. Its existing git ls-files fallback (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

Orrery — architecture documentation · MIT · No tracking