feat(infra): add self-hosted container stack#117
Note
🤖 Claude Opus 5.5 on behalf of Oliver
ELI5
This packages the dashboard as a container that runs on a machine the team controls. People open it through an SSH tunnel from their own computer. Nothing is exposed to the network.
Why
The team needs a self-hosted dashboard that is separate from the dev and Cloudflare deployments. The pipeline already writes SQLite, so running the dashboard next to that data avoids loading it into D1.
Flows to exercise
Setup: a Docker host you can reach with ssh <host> docker info without a password prompt, a data directory on it with <dataset-id>/netflow.sqlite, and CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID exported (Alchemy stores state in Cloudflare and creates no Cloudflare resources).
| 1 | export ATLANTIS_SELF_HOSTED_DOCKER_HOST=ssh://user@host.example.com # add :2222 for a non-default SSH port |
| 2 | export ATLANTIS_SELF_HOSTED_DATA_DIR=/data/netflow/example |
| 3 | bun run deploy:self-hosted --stage <name> |
- Deploy: the image builds on the host and the output prints a tunnel command.
ssh <host> docker ps --filter name=atlantis-self-hosted-webshows the container ashealthy. - Connect: run the printed command, for example
ssh -N -L 8080:127.0.0.1:8080 user@host.example.com. With a port in the URL, it prints-p 2222 user@host.example.com. Openhttp://localhost:8080and the datasets from the data directory load. - Redeploy: running the deploy again without changes does nothing. After a change under
apps/web, the deploy replaces the container. - Teardown:
bun run destroy:self-hosted --stage <name>removes the container, the current image, and the Docker context. The data directory is not touched.
Decisions and edge cases
- Access: the container publishes only on
127.0.0.1:<port>on the host. There is no app auth or TLS, and access goes through SSH port forwarding. Users' SSH keys must allow port forwarding. - Config validation:
ATLANTIS_SELF_HOSTED_DOCKER_HOSTmust be anssh://URL.ATLANTIS_SELF_HOSTED_DATA_DIRmust be an absolute path other than/.ATLANTIS_SELF_HOSTED_DATA_USERmust be<uid>:<gid>(default1000:1000).ATLANTIS_SELF_HOSTED_PORTdefaults to8080. Invalid values stop the deploy before anything changes. - Stage names: the self-hosted stack applies the same stage-name rule as the Cloudflare stack (#116): lowercase letters, digits, and single hyphens.
--stage alice_devfails before anything is created, so two spellings can never share a container or Docker context. - Writable data mount: SQLite needs to create
-shm/-walfiles next to WAL-mode databases, even to read them. The app still opens every database read-only. The container runs as the data owner. - Image tag: the tag is a content hash.
Dockerfile.dockerignoreis an allowlist, and the hash walks only the allowlisted paths. Captures,target, andnode_modulesare never read, so an unreadable or huge directory elsewhere in the checkout cannot slow down or break a deploy. An ignore file that does not start with*is rejected. - Toolchain: Bun 1.3.11 is used everywhere (
packageManager,shell.nix, the Dockerfile, CI). Node 24.18.1 is pinned in.node-versionfiles, the Dockerfile, and the CI web job, because 24.19+ aborts when better-sqlite3 statements are garbage-collected (nodejs/node#65446).shell.nixand the CI e2e job stay on an earlier Node 24: Playwright 1.52 hangs while loading its config on Node 24.18.1, and moving to Playwright 1.53+ also means moving the nixpkgs browser pin. - Resource limits: memory is capped at
1gwith a 768 MB heap.patches/alchemy@2.0.0-beta.79.patchadds onlymemorytoDocker.Container, because upstream has no memory option. - Leftover images: images from earlier deploys keep their hash tags after destroy. The docs show how to prune them.
- Not included: the pipeline image. The docs cover running
scripts/netflow-db-docker.shon the host instead. - CSRF: the app has no POST, form, or remote endpoints, so SvelteKit's CSRF check never runs. The dev docs say to set
paths.originorPROTOCOL_HEADERbefore adding any.
Verification
Automated (CI and local): format:check, lint and typecheck (web and infra), test:web, the new test:infra suite, build:web, test:e2e, landing lint, and build:landing. The infra tests cover tunnel-command generation (SSH aliases, user@host:port, IPv6, shell quoting, rejecting non-SSH URLs) and build-context hashing (allowlisted changes and build args change the hash; ignored subtrees and unreadable directories outside the allowlist don't). CI does not build or run the container.
Manual (container, done before the rename and hashing changes): the image built on two Linux Docker hosts. A test stage served a CSV-derived dataset through ssh -L. Pages and five API routes returned 200 and matched local SQLite byte for byte. The port was reachable only on the host's loopback (a request from another machine failed). An unchanged redeploy did nothing, and a source change replaced the container. Destroy left the host as it was and the stack state empty.
Remaining manual verification: run flows 1–4 once more under the new atlantis-self-hosted stack name. Include one ssh://user@host:port URL to check the printed tunnel command.
Claude Opus 5.5 · Claude Code (T3 Code)
Rebased onto the merged MAAD stack
- Commits applied cleanly.
- New regression test:
LOCAL_DATA_DIRscanning goes through the MAAD stack's schema gate. - The adapter-node build serves every new route.
- The MAAD stack's locality example used
campusas a dataset ID; it's renamed todataset-a, sogit grep -i campusis empty. - Local checks: 201 web tests, 29 infra tests, 214 Rust tests, 17/17 e2e, landing lint/build pass.
Checks
- SuccessfulPlaywright E2EGitHub
- SuccessfulWeb ChecksGitHub
- SuccessfulRust Pipeline ChecksGitHub
Added 5 commits
- feat(infra): add self-hosted container stack2c079d4
- test(web): skip obsolete products found under LOCAL_DATA_DIR7282da0
- docs: use neutral dataset IDs in locality examplesa8f21a4
- fix(ci): keep e2e paths on the prior Node until Playwright is upgradedd723b28
- fix(infra): rename self-hosted stack, bound build hashing, align toolchaindff26e1