devstack
~ by xennui
devstack — write-up
An overview of every part of the system: what it is, why it's there, and (where the history
records it) how it got that way. For the full reasoning, DESIGN.md is authoritative; section
numbers below (§n) point into it. Diagram: structure.svg.
What it is, in one paragraph
devstack is a single Python CLI that turns one config file (devstack.toml) into one
hardened incus system container (Debian 13) on a Linux host. That container is both a
dev box (VSCode Remote-SSH in, project folder shared from the host) and a local copy of
production (Caddy with local TLS → hardened Node app → Postgres). The core idea is a layer
between untrusted code (npm packages, extensions, coding agents) and the host: the code can do
whatever it likes inside the container, while what reaches the host is narrow and enforced by
the OS rather than by convention.
How it got here (git history)
| Date | Commit | What changed |
|---|---|---|
| 2026-10-03 | 1d9ebdb |
v1. The tool, six phases, journal + snapshot undo, dnsmasq DNS, Caddy (with its own CA), the project scaffold, guide.html. |
| 2026-10-04 | 5a5d817 |
"security fixes": host-made name-constrained CA, devstack-requires.toml (config split), undo path checks, config risk review (typed yes), IANA TLD check, first tests, CI, README/SECURITY. guide.html → index.html. |
| 2026-10-04 | 8a77bb9 |
v2 "more container hardening", after testing a v1 container (§1.4): fixed service users, nftables egress profiles, runners, limited sudo + devstack-sync, incus network ACL, Caddy admin socket, app data volume, test DB. Trust zones and "decisions" tables added to DESIGN.md. |
| 2026-10-05 | 6271923 |
SBOM (syft in container, grype on host), devstack upgrade, AGENTS.md. Hand-written SBOM.md replaced by generated CycloneDX files. Pin verification job (verify_pins.py). |
| 2026-10-10 | b6a76bd |
"improve integrity": checkpoints, devstack browse (confined Chromium), dev servers, Caddy same-origin rule, all of .git read-only (was only hooks/config). |
| 2026-10-10 | 6515d4e |
gitleaks (leaks scan) and the project lint/type toolchain (ESLint, tsc, pnpm supply-chain settings). Trivy and Semgrep considered and rejected. |
| uncommitted | — | README owner's note, table reformatting; the TOML writer handles inline tables so the requirements diff can show runners (+ test). |
The big turn was v1 → v2. v1 relied on settings that the OS didn't actually enforce. The
clearest case is systemd's IPAddressDeny, which is silently ignored in an unprivileged
container. v2's rule: only rely on what the OS enforces, and never describe a protection that
isn't enforced (§1.4).
1. The repository (the tool itself)
| Path | What / why | Origin |
|---|---|---|
devstack |
Bash launcher. Finds a Python ≥ 3.11 (needed for tomllib), trying /usr/bin/python3 before PATH because pyenv/conda shims often shadow it. |
v1 |
devstack.py |
The whole tool, ~7.2k lines, stdlib only (no pip, so nothing extra to trust or install). Sections: helpers · config schema · logging · runner/state/journal · host+container helpers · phases · orchestration/undo · commands. | v1, grown every commit |
devstack.example.toml |
Every config key, with its default and help text. Generated from the SCHEMA table (./devstack init -n -c devstack.example.toml --force), so docs and code can't drift. |
v1 |
templates/project/ |
Scaffold copied into a new project (never overwrites existing files). | v1; toolchain files added in 6515d4e |
templates/system/ |
Files rendered into the container: sshd drop-in, app unit, runner socket/service, egress.nft, devstack-egress.service, devstack-sync, Postgres conf. |
v1; isolation files in 8a77bb9 |
data/iana-tlds.txt |
Snapshot of real TLDs. dns.tld may not be one, because address=/com/… in dnsmasq would hijack every .com name on the host. |
5a5d817 |
tests/test_devstack.py |
~215 unit tests that need no host, root or incus: config, rendering, quoting, journal/undo with a fake runner, CA constraints (real openssl), ACL rules, SBOM normalizer, gitleaks argv, checkpoints on a temp git repo. | 5a5d817, grown since |
tests/verify_pins.py |
Downloads every pinned artifact and checks its sha256. Network-only, so it runs weekly in CI and never on PRs. | 6271923 |
.github/workflows/ci.yml |
bash -n, py_compile, config check, unittest; plus the weekly pins job. This CI is for devstack only. It is not a template for projects. |
5a5d817, pins job 6271923 |
DESIGN.md |
Maintainer/agent doc and the authoritative spec. Includes "decisions (reopen only with new facts)" and "security details that look simplifiable but aren't" (§10.1). | v1, restructured in v2 |
README.md / SECURITY.md |
Quick start, security model summary, the trust-roots table / threat model and what's in or out of scope. | 5a5d817 |
index.html + .woff2 font |
End-user manual ("devstack Field Guide"): setup, phases, undo, security and troubleshooting. The font is self-hosted for it. | guide.html in v1, renamed 5a5d817 |
Template convention: templates hold content, devstack.py holds logic. Placeholders are
@@UPPER_SNAKE@@, because ${}/{} clash with TypeScript and Caddyfile syntax. Rendering fails
if any placeholder is left unreplaced.
2. Configuration model
| Piece | What / why |
|---|---|
SCHEMA (in devstack.py) |
Single source of truth. Each key has a type, default, help text and validator. init, config explain, validation and the example file all come from it. Unknown keys are errors, with "did you mean" suggestions. |
devstack.toml |
Private per-user config (git-ignored): host paths, ssh, DNS and mount sources. Treated like a script: risky settings (mounting $HOME/~/.ssh//etc, full sudo, open app profile, LAN exceptions, new runners) need a typed yes/--yes. |
| Derived names | Every name that could collide (container, TLD, volumes, ssh key, dnsmasq file, units, users…) defaults to {stack.name}…, so a second stack only needs a new name. Preflight flags conflicts with anything this stack doesn't own. |
<project>/infra/devstack-requires.toml |
The project's share of the config, committed with the project: container-side keys only (image, Node, Caddy, app, runners, Postgres, GPU on/off) plus required mounts by name and path, never by host source. It is read with an allowlist, so a cloned repo can't steer the host. Any change from what this stack last wrote needs approval. Added in 5a5d817 so configs (with host details) never land in public repos. |
3. Execution model
| Piece | What / why |
|---|---|
| Phases | base → app → proxy → standards → isolation → postgres. Each one adds a single concern (contain, serve, proxy, client-side standards, server-side isolation, data). Each is a list of steps with describe()/is_done()/apply(), followed by verify checks. Steps are idempotent, so up can be re-run safely. |
preflight / plan |
Read-only. They check host deps, incus, name conflicts and config risks, and list every host path that would be written. Run them first. |
| Undo: incus snapshots | A devstack-pre-<phase> snapshot is taken before each phase after base. Undoing a phase restores it, which reverts packages, files, users and devices without inverse logic for each command (§7.1). |
| Undo: journal | Everything outside the container is written to journal.jsonl before it's changed (write-ahead). Undo uses a fixed registry of entry kinds, never shell strings. Undo also re-checks each stored path against what the current config would produce, so a tampered journal can't redirect sudo rm (§7.2). |
| Failure handling | run.on_failure = rollback (default) undoes the failed phase; stop keeps it for debugging. fcntl lock: one run at a time. The design goal is "no half-finished state you can't get out of". |
Checkpoints (b6a76bd) |
Undo after provisioning (an agent's changes, a bad install). Taken with the container stopped: container snapshot + volume snapshots + project as a commit on a hidden ref refs/devstack/checkpoints/<name> + ignored files as a tar. Branches and HEAD are untouched. git runs hermetically, so project-written config can't execute on the host. |
| State dir | ~/.local/state/devstack/<stack>/: state.json, journal, lock, log, backups/, ca/, reports, browser profile. |
| Logging | Console at info; the file always gets debug (every command, exit code, output). Secrets only go through stdin or files, never argv, and a redaction filter is a second line of defence. |
| What survives what | Data volumes (Postgres, app data) survive undo and destroy unless --purge-data, with an auto-backup first. Re-running up reattaches them. |
4. On the host
| Component | What / why | Origin |
|---|---|---|
| dnsmasq drop-in | One file per stack: *.<tld> → container IP. This lets the app run at its real domain plus a dev suffix (app.example.com.webstack). The host must already resolve through dnsmasq (a one-time manual setup, since it changes host-wide DNS). devstack never touches resolv.conf. |
v1 |
| Local CA | Root + key are created on the host and X.509 name-constrained to *.<tld>. The container only gets an intermediate. A key stolen from the container can only sign dev names. (v1 used Caddy's own CA, whose key lived in the container and was trusted for every site.) |
5a5d817 |
| ssh | Per-stack ed25519 key, a marked block in ~/.ssh/config, and a per-stack known_hosts read via incus exec, so there's no trust-on-first-use. Key-only login, no agent or X11 forwarding. |
v1 |
| incus network ACL | Attached to the container's NIC and enforced on the host. Rejects traffic to the host and LAN except DNS/DHCP to the gateway. Added because in v1 nothing stopped container → host/LAN (§1.4). base verifies it with its own temporary host listener. |
8a77bb9 |
Read-only .git |
All of .git is a read-only disk device. The host runs git there, so hooks or core.fsmonitor would be host code execution, and refs/grafts decide what git log shows. Commits happen on the host. Started with hooks/config only (5a5d817); widened to all of .git in b6a76bd. |
5a5d817→b6a76bd |
| Data volumes | <stack>-pgdata and <stack>-appdata are incus custom volumes. They aren't part of instance snapshots on purpose: rolling back the container never rolls back data. |
v1 / 8a77bb9 |
devstack browse (b6a76bd) |
Chromium with its own profile and NSS db (the only browser that trusts the dev CA). Everything except the dev domain goes to a dead-end proxy, including loopback. The reason: container pages in your everyday browser could reach host loopback services and the LAN, which the ACL can't stop. tls.browser_trust therefore defaults to no. |
b6a76bd |
| grype, gitleaks | Run on the host against files the container produced: "the container is the artifact, the host is the auditor". The scanner stays out of the zone it audits, and the container never needs the ~1 GB vuln DB. | 6271923, 6515d4e |
5. Inside the container
Trust zones (§1.3). The login user and every agent share one uid, so they can't be told
apart. The rule is: nothing the login user can edit runs as root, and security-relevant
changes come from the host (devstack up).
| Component | What / why | Origin |
|---|---|---|
Login user dev |
uid = host uid, so files on the shared folder keep the right owner. Has internet access and DB access (as the app role) by design. No general sudo: in v1 the user had passwordless sudo, which made every agent root. The name is generic so it doesn't leak into committed files. | v1, limited in 8a77bb9 |
devstack-sync + sudoers |
The only command sudo allows. Fixed verbs: packages (reads names on stdin, so root never opens a user-controlled file; exact Debian names only; services prevented from starting and new units masked), restart app/runners, validate/reload caddy (as the caddy user), status. Lets agents install apt packages through a reviewable list in git. |
8a77bb9 |
| sshd drop-in | Key-only, AllowUsers dev, no root login, no agent/X11 forwarding. TCP forwarding stays on because Remote-SSH needs it. |
v1 |
| Node + pnpm | The Node tarball is verified with gpgv against the Node release keyring, pinned by commit + sha256. pnpm comes via corepack at a pinned version at least a week old. |
v1, pins 5a5d817 |
| Caddy | TLS (leaf certs from the intermediate), static files, /api/* → app. Two Caddyfiles: root-owned /etc/caddy/Caddyfile holds global options (admin API on a unix socket, not TCP 2019) and the same-origin snippet, then imports the project's infra/Caddyfile. A project file can't redefine global options because a second global block fails to parse (fails closed). |
v1; admin socket 8a77bb9; same-origin b6a76bd |
| Same-origin rule | Caddy returns 403 for requests a browser marks as cross-site/same-site or with a foreign Origin, so other sites can't call the app with your cookies. |
b6a76bd |
| App service | Hardened systemd unit (ProtectSystem=strict, syscall filter, no capabilities…). Fixed user <stack>-app; binds 127.0.0.1 only. It only accepts requests carrying X-Internal-Token, which only Caddy adds. v1 used DynamicUser, which left no stable uid to firewall or own files. |
v1, reworked 8a77bb9 |
| Egress profiles | egress.nft matches on socket owner uid (meta skuid). The app gets local (loopback only, no DNS, to prevent DNS tunnelling); runners get public internet only. Loaded by devstack-egress.service, and units Require= it, so there are no rules → no app (fails closed). It replaced systemd IPAddressDeny, which silently didn't work. |
8a77bb9 |
| Runners | Socket-activated jobs for tools that need the internet or parse untrusted files (fetchers, ffmpeg…). One short-lived process per connection; own user; no secrets; no access to app data except incoming/. |
8a77bb9 |
| ufw | Inbound deny by default; allows 22/80/443. Debian's nftables.service is deliberately not used, because its flush ruleset would wipe ufw's rules. |
v1 |
| Postgres | From the PGDG repo (key fingerprint pinned). Listens on localhost, scram auth, peer-only superuser. App role + DB + <db>_test. Data on the persistent volume; postgres uid fixed (500) so a rebuilt container still owns the data. db restore runs as the app role in one transaction. |
v1, test DB 8a77bb9 |
| Secrets | /etc/app-secrets/: internal token and postgres.env, generated once with secrets.token_hex and never passed on argv. The dev env file is written as the user, so root never writes through a path the user controls. |
v1 / 8a77bb9 |
| syft | Pinned binary in the container. Generates the SBOM where the real dpkg DB and node_modules are. |
6271923 |
6. The scaffolded project
Created only if missing; existing files are skipped and reported. The toolchain files (ESLint, pnpm workspace, lint script) are only added to new projects, because adding them under an existing lockfile could fail a rebuild.
| File | What / why | Origin |
|---|---|---|
server.ts, static/, error/ |
A zero-dependency TypeScript smoke-test app (/api/health, token middleware; fails closed in production without a token) and its static page and error page. A proof the stack works, not a framework. |
v1 |
package.json, tsconfig.json |
ESM, pnpm pinned through packageManager; strict + noUncheckedIndexedAccess. TypeScript is held at 6.0 until typescript-eslint supports 7. |
v1, 6515d4e |
eslint.config.js |
strict-type-checked, security + no-unsanitized plugins, no eval, child_process only in one wrapper. Stands in for a SAST tool; Semgrep was rejected because it can't be pinned as a single binary and phones home. |
6515d4e |
pnpm-workspace.yaml |
Supply-chain settings: 7-day minimumReleaseAge, blockExoticSubdeps, trustPolicy: no-downgrade, strictDepBuilds. |
6515d4e |
scripts/sync-env.sh |
sudo devstack-sync packages < infra/apt-packages.txt, then pnpm install --frozen-lockfile. |
8a77bb9 |
scripts/lint-changed.sh |
Lints what changed. A command run in the container, not a git hook: a hook would be project code executed on the host (§8.7). | 6515d4e |
infra/apt-packages.txt |
System packages the project needs, one per line. Each addition is a reviewable diff. | 8a77bb9 |
infra/Caddyfile |
Routes, CSP and headers. Generated (managed) by default; a project can own it. | v1 |
infra/compress-static.sh |
Writes .br/.gz copies so Caddy can serve precompressed files. |
v1 |
infra/sbom/{container,app}.cdx.json |
CycloneDX inventory, committed. Normalized (no timestamps, ids or file paths, sorted) so a diff always means a real dependency change. Replaced the hand-written SBOM.md from v1. |
6271923 |
infra/devstack-requires.toml |
See §2. | 5a5d817 |
AGENTS.md |
Tells coding agents what the container enforces (no root, .git read-only, how to add packages, secrets rules), so they don't learn it by hitting walls. |
6271923 |
README.md, .gitignore |
Human orientation and dependency policy; ignores .env, keys and precompressed copies. |
v1 |
7. Supply chain and auditing
| Piece | What / why |
|---|---|
| Trust roots | The Caddy and PGDG apt key fingerprints, the Node keyring commit + sha256, and syft/grype/gitleaks version + sha256 are constants in devstack.py. A unit test fails if a pin is missing from README's table. A plain sha256 was preferred over cosign as a much smaller trust root. |
sbom generate/check/scan |
syft in the container → normalize → commit. grype on the host scans it. The gate counts only fixable findings: on a real Debian system all 554 critical findings had no fix, and a permanently red gate gets switched off. Exit 5 = "gate says no", distinct from tool errors. Not a 7th phase (§8.7 explains why). |
upgrade |
Patches Debian packages from the host, not via devstack-sync, so the login user gains no new capability. Uses --force-confold, otherwise a caddy upgrade could restore the vendor Caddyfile and put the admin API back on TCP. It only lets already-running services restart and masks new units. |
leaks scan |
gitleaks on the host, over history plus a copy of the working tree and staged files. Every project-controlled input (.gitleaks.toml, .gitleaksignore, inline gitleaks:allow, .gitattributes -diff) is replaced or bypassed, because the container can write all of them. Fails closed on gitleaks' silent git errors. |
8. Deliberately not there
Recorded in DESIGN.md §1.5 so they aren't re-added without new facts:
- Git hooks (lint, SBOM, leaks): host-executed project code, not cloned, skippable with
--no-verify. - CI files for projects: devstack gives commands and exit codes; which CI a project uses is its own choice.
- Domain allow-lists / egress proxy: can't be enforced at the netfilter layer. Listed as an open item.
- Trivy: overlaps syft + grype, has weaker secret rules, and its release pipeline was compromised in 2026.
- Semgrep: pip-distributed (no single binary to pin) and sends metrics. To revisit as Opengrep with vendored rules.
- Automatic host DNS setup: it changes how the whole host resolves names, so it's documented as a manual step.
Known limits (accepted)
The login user (and so every agent) can read the real database and app data and has internet
access, so it can send that data out. An app that passes arbitrary URLs to a runner gets
internet through it. Membership of incus-admin is root-equivalent on the host. Per the
README's owner note, the generated code hasn't been fully reviewed yet; treat the project as a
reference, not a hardened product.