This document describes the runtime model, the core pipeline, the network topology, the module breakdown, and the cross-cutting concerns that no single component owns. Component detail lives in docs/specs/.
devstack is a stateless command-line tool with no background daemon (for v1 — see the daemon trade-off in OPEN-QUESTIONS). Every invocation:
- loads configuration and the global state ledger,
- takes a coarse cross-process lock before mutating any shared state,
- drives Docker (via the
docker composeCLI and the Engine SDK read-only), - and exits.
Consequences of "no daemon":
- There is no automatic autostop of shared services when their last consumer goes down. Reference-count drift is reconciled lazily on the next command (and via
devstack shared gc/devstack doctor --fix). - Long-running behaviors (health-gated
up, log streaming, the dashboard) run only for the duration of the command.
The binary is a single static, CGO-free executable (CGO_ENABLED=0) cross-compiled to darwin/{amd64,arm64} and linux/{amd64,arm64}. WSL2 runs the ordinary linux build — it is not a separate build target, but it is a distinct runtime-behavior target (see §7).
| Location | What | Committed to git? |
|---|---|---|
| Per-project, in the repo | workspace.yaml (workspace root) + each repo's devstack.yaml — the declarative source of truth. |
✅ Yes |
| Machine-global, under XDG dirs | A SQLite ledger keyed by Docker context: shared-service reference counts, port-allocation registry, per-project DB/role/bucket provisioning ledger, a rolling event log; plus the alias registry, template cache, and the generated artifacts under .devstack/ in the project. |
❌ No |
Keying the ledger by Docker context (not just hostname) avoids the WSL2 "two daemons" problem (Docker Desktop's daemon vs an in-distro dockerd) where ref-counting would otherwise enumerate the wrong containers.
The SQLite store uses modernc.org/sqlite (pure-Go, preserves the static binary), opened with WAL + busy_timeout=5s + foreign_keys=ON, with a versioned migrations table and a backup-before-migrate step. Full detail: spec 08.
There is exactly one ordered pipeline from config to running containers. It must have a single owner and one set of golden integration tests; the four "stages" are not independently designed subsystems.
discover workspace walk up from CWD for workspace.yaml (git/go.mod style)
│
▼
load + validate config goccy/go-yaml parse (source positions)
│ → jsonschema/v6 structural
│ → validator/v10 semantic + custom resolver (cross-refs, cycles)
▼
resolve template `extends` render each layer, then deep-merge (explicit list strategy)
│
▼
render artifacts text-template the Dockerfiles / proxy conf / scripts;
│ build the compose model programmatically
▼
resolve ${ref:...} cross-service refs resolved against the WORKSPACE graph
│ (not a single project) — this is what makes sharing work
▼
resolve secret:// refs one batched pass per provider; values never written to disk
│
▼
build + validate compose typed model → compose-go/v2 validate/normalize
│
▼
writeIfChanged + atomic write each generated file only on diff (tmp + os.Rename);
│ SHA-256 each build context into the ledger
▼
selective rebuild + up `docker compose build --no-cache <changed>` then `up -d`
Key principle: the compose document is built as a typed model and validated through compose-go/v2, not produced by string-templating YAML. Text templating is reserved for unstructured artifacts (Dockerfiles, proxy/entrypoint configs) and user-authored templates. See spec 02 and the open templating decision in DECISIONS.
Determinism is a hard requirement: identical inputs must produce byte-identical generated files (sorted keys, fixed newline handling), because the rebuild-hash and any "commit generated artifacts" decision depend on it. CI verifies this.
┌─────────────────────── devstack_shared (external bridge network) ──────────────────────┐
│ pinned name:, created idempotently by devstack (NEVER by compose) │
│ │
┌────────────────┴───────────────┐ ┌───────────────────────┐ ┌───────────────────────┐ │
│ shared stack │ │ project stack: api │ │ project stack: web │ │
│ (compose project │ │ (compose project │ │ (compose project │ │
│ `devstack-shared`) │ │ `devstack-api`) │ │ `devstack-web`) │ │
│ │ │ │ │ │ │
│ shared-postgres (alias) │◄────┤ api ──uses──► postgres │ │ web ──uses──► postgres │ │
│ shared-redis (alias) │◄────┤ worker │ │ (its own internal net │ │
│ shared-minio (alias) │ │ (api's own internal │ │ for web-internal svcs)│ │
│ caddy (reverse proxy) │ │ network for private │ │ │ │
│ cloudflared (optional) │ │ services) │ │ │ │
└────────────────────────────────┘ └─────────────────────────┘ └─────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────────────┘
- One tool-owned external bridge network (
devstack_shared, name pinned). Both the shared stack and every project stack join it. Compose refuses to createexternal: truenetworks and won't remove them ondown, so devstack owns create and cleanup (docker network create … || trueidempotently; removal only onworkspace destroy). - Shared services are reached by stable DNS aliases (
shared-postgres,shared-redis,shared-minio) over that network — not by bare service name (two projects might each have an internal service literally namedpostgres; theshared-alias is the collision guardrail, enforced by a generate-time lint). - In the common case shared services publish no host ports — projects talk to them over the network by DNS. Host ports are allocated (from a registry, inside the lock) only when the developer wants host reachability (psql, a GUI). See port allocation in spec 03 and spec 08.
- Lifecycle verbs shell out to
docker compose(-p <project>+ tool-owned labels, require v2.20+). The Engine SDK (moby/moby/client) is used read-only for network ensure, label-filtered container enumeration, health polling, and ref-count reconciliation. devstack never runs containers via the SDK — Compose owns container lifecycle; the SDK is plumbing only. This split avoids ownership drift.
Cross-platform note: on Docker Desktop (macOS/WSL2) containers run in a VM, so bridge IPs are not routable from the host — you must publish ports for host reachability. On native Linux the bridge subnet is host-reachable. The DNS-over-shared-network path behaves identically on all three.
One shared Postgres serves many projects via provision-on-demand, not initdb.d scripts (those run only on a first-init empty volume, so they can't onboard a project added later):
- After the shared Postgres is healthy, devstack connects as superuser via
jackc/pgx/v5and runs idempotent, existence-guarded SQL:CREATE ROLE <proj>/CREATE DATABASE <proj_db> OWNER <proj>/REVOKE+GRANTso each project role sees only its own DB.CREATE DATABASEis not idempotent — guard withpg_database/pg_roleschecks. ⚠️ Postgres 18+ volume path: PGDATA moved to/var/lib/postgresql(was/var/lib/postgresql/data). Mounting the old path on PG18 silently loses persistence. Templates must mount the correct path for the pinned version.- Redis: per-project logical DB index (0–15), or key prefix beyond 16 projects. MinIO: per-project bucket + scoped access key.
- An ownership ledger ties each provisioned DB/role/bucket to a project so a
db gcreaper can reclaim orphans (with explicit data-loss confirmation) when a project is removed.
Standard Go layout: /cmd/devstack/main.go (thin), /internal/* (all implementation), /pkg/pluginsdk (public plugin contract, deferred to v2), /templates (built-ins via go:embed).
| Module | Responsibility |
|---|---|
cmd/devstack/main.go |
argv[0] alias dispatch (--as override), build the cobra tree, fang.Execute; inject version/commit/date via ldflags. |
internal/cli |
Cobra command tree, tech-gated dynamic subcommand registration, ValidArgsFunction completions resolving service/project names, global --json/--quiet/--debug/--verbose and unified error rendering. |
internal/config |
Workspace discovery (walk-up), goccy parse with positions, JSON-Schema + validator/v10 + custom cross-ref/cycle resolver, profile/env overlay merge, immutable loaded model, embedded schemas, the ${env/self/ref/profile} grammar. (v2 additive schema: the resources/platform block — see spec 18; the templates:/verify: lockfile blocks — see spec 19; the sanitize: profile — see spec 15; telemetry.* lives in a separate user file — see spec 20.) |
internal/template |
Text-template engine + ported filters; renderText/renderYAML modes; extends-chain resolution; TemplateSource interface (embedded for v1; git/OCI later); golden-test harness. (v2: the OCI/git registry workflow + digest pinning is layered on by internal/registry — see spec 19.) |
internal/merge |
Layered deep-merge with explicit list strategy (replace default, $merge: append opt-in); guards shared-reference mutation. |
internal/generate |
Compose-model build + compose-go validate/normalize, ${ref} resolution against the workspace graph, secret-key emission, writeIfChanged + atomic rename, SHA-256 rebuild-hash ledger. (v2: emits deploy.resources/mem_limit/platform: from the resources block — see spec 18; IDE artifacts ride the same deterministic pipeline via internal/ide — see spec 17.) |
internal/docker |
moby/moby/client wrapper (read-only) + docker compose CLI driver with explicit -p and tool-owned labels; compose/version/context preflight. |
internal/workspace |
The differentiator: shared-stack lifecycle, ref-counting + self-healing reconcile, port allocation, per-(engine,major-version) shared instances, profile/selective-up interaction (the reference-graph walk behind --profile, spec 12). |
internal/state |
SQLite ledger (keyed by Docker context), WAL+busy_timeout, versioned migrations + backup, rolling event log, doctor --rebuild-state from live Docker labels. |
internal/lock |
gofrs/flock cross-process advisory lock wrapping all global mutations; detects 9p/networked state dirs and warns. |
internal/provision |
pgx/v5 idempotent per-project Postgres role/db; redis index/prefix; minio bucket+key; ownership ledger + orphan gc. |
internal/git (gitx) |
Hardened system-git exec wrapper, --porcelain=v2 status parse, shorthand expansion, GIT_ASKPASS token shim, errgroup parallel clone/sync/status. |
internal/secrets |
Provider interface + Registry + Factory, secret:// ref parser, batched post-render resolution; v1 providers + OS-keyring (optional, in-memory fallback). |
internal/proxy |
Caddy shared-container orchestration via labels; Proxy interface (Traefik/nginx pluggable later). |
internal/trust |
mkcert (or owned CA) host/Firefox trust install; WSL2 certutil.exe interop; trust status/install/uninstall. |
internal/dns |
*.localhost guidance + idempotent marker-fenced /etc/hosts edits (sudo), removable on uninstall. |
internal/tunnel |
cloudflared shared container, named-tunnel create/route/up/down, Tunnel interface; secret-bearing-service refusal guard. |
internal/doctor |
Cross-platform capability-probe matrix with one-line remediations and --fix; also owns recovery (--rebuild-state) and teardown (workspace destroy/uninstall/db gc). See spec 13. |
internal/hooks |
Lifecycle hooks (preUp/postUp/preDown/firstRun/postPull) with an idempotency ledger; powers migrations/seeding. See spec 11. |
internal/health |
Typed healthchecks + dependsOn: healthy graph gating (cross-project read-only poll + compose-native ordering). See spec 10. |
internal/orchestrate |
The multi-phase up/bootstrap saga: named resumable phases with durable phase-state + compensating rollback; TUI checklist + plain fallback. See spec 09. |
internal/xdg |
XDG path resolution; WSL2 detection; refuse /mnt/* working dirs; template-cache GC/TTL. |
internal/migrate |
devstack import reading an old devdock project.yaml → new workspace.yaml + devstack.yaml split (optional; high-leverage for the existing user base); plus the three-artifact versioning policy + self-update choreography. See spec 14. |
internal/version |
Build-stamped identity (Version/Commit/Date via ldflags); consumed by --version and the update notifier. See spec 14. |
internal/db (v2) |
Post-1.0. Per-project (tenant-scoped) data lifecycle on the shared engines — db snapshot/restore/reset/list/pull, content-addressed dump store, engine dumpers (pg_dump/redis/mc) behind a Dumper interface; reuses internal/provision + the flock. See spec 15. |
internal/dashboard (v2) |
Post-1.0. Read-only bubbletea cockpit + multiplexed logs: fans in label-filtered ContainerList/ContainerLogs/ContainerStats/Events + the ledger snapshot; lock-free, never mutates. See spec 16. |
internal/ide (v2) |
Post-1.0. Editor-artifact generation sink (ide gen): per-repo .devcontainer/devcontainer.json, multi-root .code-workspace, debugger launch.json, $schema modeline; rides the deterministic pipeline + managed-block merge; debug-port allocation through the workspace flock. See spec 17. |
internal/telemetry (later) |
Post-1.0. Strictly opt-in (default OFF), allowlist-only, best-effort pure-Go OTLP/HTTP export of command/outcome/os/arch; reuses the spec-14 notifier suppression discipline; deliberately off the lock/state spine. See spec 20. |
internal/registry (v2) |
Post-1.0. Versioned template registry around the TemplateSource seam: OCI (oras-go/v2) + git pull, digest-pinned templates: lockfile, push/add/update/diff/verify, optional cosign verification; cache-write under the flock. Extends internal/template. See spec 19. |
internal/docker Backend seam (later, exploratory) |
Post-1.0. Remote/cloud shared-services backend abstraction: Local/Remote backends selected by Docker context, generalizes the local flock to a DistLock (Postgres advisory lock / coordinator), per-user tenant isolation, ViaProxy reachability. Extends internal/workspace + internal/docker. See spec 21. |
pkg/pluginsdk |
Public plugin contract — deferred to v2 (out-of-tree secrets/tunnel providers). |
Every fast-moving or risky dependency (docker client, template engine, secrets provider, git, trust store) sits behind an internal interface so it can be swapped or vendored.
These emerged from adversarial review and are the things most likely to bite. Each needs a named owner.
Two simultaneous up invocations (two terminals; IDE + terminal; a watch script) race on network-ensure (TOCTOU between inspect and create), port allocation, ref-count rows, and CREATE ROLE. SQLite file locking is unreliable on WSL2/9p. Mitigation (build first, in M0): a coarse gofrs/flock advisory lock around every operation that mutates the ledger or the shared stack; WAL + busy_timeout; idempotent guarded SQL; port allocation done inside the lock; doctor warns on 9p state dirs. Reads are lock-free snapshots. See spec 08.
up runs ~8 phases (clone → network → shared services → provision DB → secrets → CA → generate → compose up). A failure in phase 4 after phase 3 started the shared stack and inserted ref rows leaves a half-up workspace and a lying ledger. Mitigation: model up as a saga with durable phase-state and compensating actions; doctor --rebuild-state reconstructs the ledger from live Docker labels. The saga's named phases, the saga_phase resumability table, and the lock-granularity rule are detailed in spec 09.
devstack creates artifacts everywhere: the external network, shared volumes (Postgres data!), the SQLite DB, root CA in host + Firefox + Windows trust stores, alias symlinks, cloudflared creds, keyring entries, the cache. devstack workspace destroy / devstack uninstall must reverse all of it, with explicit data-loss confirmation for volumes, and must never orphan a CA (a security artifact) or dangling symlinks. The teardown order and the --fix-vs-destroy boundary are owned by spec 13.
- Config schema —
apiVersion: devstack/v1; define the unknown-key /additionalPropertiesforward-compat policy. - State-DB schema — versioned migrations table, backup-before-migrate, strictly additive within a major.
- Generated compose format —
compose-gore-normalization causes churny diffs; decide whether generated artifacts are committed (deterministic, reviewable) or gitignored (regenerated freely). Either way, golden output controls the churn. The compatibility guarantees for all three artifacts (configapiVersion, stateschema_version, generated-compose byte-stability) and the self-update choreography tying them together are specified in spec 14.
The "host env auto-propagates into compose" assumption is false (verified). Each secret name must be emitted per service into the generated compose (as a valueless environment: [NAME] key) and the value passed via exec.Cmd.Env — so secrets and generation are coupled and need one owner plus a CI test asserting no secret value ever lands in any generated file. Resolved secrets also live as plain Go strings (GC'd, swappable, visible via /proc/<pid>/environ to same-user processes). Decision: document the threat model honestly rather than over-engineering mlock for a local dev tool. See spec 04.
~10 subsystems shell out to external tools. One error model: wrap every external-command failure with the exact command + exit code + captured stderr; map known signatures (daemon down, compose too old, port in use, git auth prompt, missing certutil) to actionable remediations; render config errors as file:line:col with a caret (goccy positions); --debug logs every external command run (structured via log/slog). This is what actually closes GitHub issues.
- Unit: golden-file tests for templating/merge/config/parse.
- Integration: real daemon via
testcontainers-goor a dind CI lane (network/ref-count/provisioning/health), behind a build tag so unit tests stay fast. - Race: mock the docker client behind
internal/dockerto test concurrent-invocation logic. - Manual matrix: a documented release checklist for the paths that can't run in hosted CI (macOS trust/resolver, WSL2 dual trust store, cloudflared). "Verified on my machine" must become a regression suite.
goreleaser cross-compiles from one Linux runner (easy). The hard part is the test matrix: a macOS arm64 runner (trust/resolver/Desktop-VM behavior), a Docker-enabled Linux lane (dind) for integration, govulncheck (go-git CVE class), a single enforced Go 1.25 toolchain floor, and a JSON-Schema↔Go-struct drift check. Deterministic-generation must be asserted in CI.
The headline TUIs (git status, up checklist, dashboard) must each have a non-TTY fallback with a defined --json/--quiet contract so up/status are scriptable in CI. Detect TTY via golang.org/x/term.
One instance for many tenants needs explicit policy: tenant name collisions (two projects want db app), redis DB-index exhaustion beyond 16, MinIO global bucket-name uniqueness, and version conflicts — a single shared Postgres cannot serve project A pinned to 16 and project B pinned to 15. Policy: one shared instance per (engine, majorVersion) pair, keyed in the ledger; fall back to a dedicated instance when a project needs a unique version.
WSL2 is the weakest path across nearly every dimension simultaneously, and it's a primary target. A single WSL-detection utility (uname contains microsoft / /proc check) feeds capability branches; doctor exercises the real branch logic, not docs. Hard rules: state/cache live on the Linux side; refuse /mnt/* working dirs; pin/select the Docker context loudly; keyring is optional everywhere with in-memory fallback; trust install drives certutil.exe interop for the Windows store (browsers run on Windows, not in the distro).
Build only the genuinely novel layer (shared-services orchestration, workspace model, the generation pipeline). Buy/reuse where mature tools dominate: the docker compose CLI (don't embed docker/compose/v2 — it drags in all of BuildKit), the system git binary, mkcert for trust, compose-go for compose validation, Caddy/cloudflared as managed containers. Re-implementing any of these worse is a primary failure mode.