Dev Containers
Every project ships a Dev Container so you get an identical, fully-provisioned environment in VS Code, IntelliJ IDEA, and WebStorm — with no host setup beyond Docker and an editor. The container matches CI and the deploy target exactly, so "works on my machine" stops being a category of bug.
To scaffold or align a project to this standard, use the
vssw-scaffold-devcontainer AI skill — it carries the templates
and the checklist below.
The standard toolchain
| Piece | How it's provided | Why |
|---|---|---|
| Node | fnm, driven by .node-version / .nvmrc |
Not corepack (deprecated / dropped in newer Node); fnm auto-selects on cd |
| pnpm | standalone installer (get.pnpm.io), pinned to packageManager |
Version-independent of Node; an fnm patch bump can't lose it |
| OpenSpec CLI | @fission-ai/openspec, npm i -g in the image |
The openspec/ skills (/opsx:*) require it. Not the bare openspec package — that's an empty placeholder |
| Claude Code | official Dev Container Feature | claude in the container |
| GitHub CLI | official Dev Container Feature | gh for PRs + HTTPS git auth |
| JDK | the java:1-<N> base image (or the java Feature) |
Kotlin/Gradle backends |
| Gradle | the checked-in ./gradlew wrapper |
Nothing to install |
| Playwright | playwright install --with-deps chromium in post-create |
E2E; kept in sync with the pinned browser |
The whole JS toolchain (fnm + Node + standalone pnpm + OpenSpec) is built in
.devcontainer/Dockerfile; the binaries above come from Features layered on top.
Base image by stack
| Stack | Base image | Extra |
|---|---|---|
| Frontend only | devcontainers/base:debian-12 |
— |
| Kotlin/Gradle (± frontends) | devcontainers/java:1-<JDK>-bookworm |
Remove the base image's broken yarn apt source (expired key breaks apt-get update) |
| docker-in-docker / Supabase | devcontainers/base:ubuntu-24.04 |
dind Feature + --privileged |
Shared repositories
Nothing large lives in the container's disposable layer. It goes in named Docker volumes, split into two kinds.
Shared across every dev container (download once per machine)
One volume — devcontainer-cache mounted at /cache — is shared by
every project's container. A package or Gradle dependency downloaded by one
project is already present for the next, and it survives rebuilds.
- pnpm store →
/cache/pnpm-store. Set in post-create withpnpm config set --global store-dir /cache/pnpm-store(pnpm ignores every equivalent env var, sodevcontainer.jsoncan't set it). - Gradle cache →
/cache/gradle, viaGRADLE_USER_HOME=/cache/gradleincontainerEnv. Gradle locks the shared home, so multiple projects/daemons share it safely — exactly like sharing~/.gradleon a host.
The source name is the sharing mechanism
devcontainer-cache / /cache must be identical across projects. A
project-scoped name or a different mount path silently un-shares the cache.
Measured effect as projects join the shared pnpm store: reused 0, downloaded
979 (first project) → reused 448, downloaded 50 (a later one); a full rebuild
dropped from ~3m to ~1m.
Per-project (isolated, disposable)
| Volume | Mounted at | Notes |
|---|---|---|
<name>-node-modules |
node_modules |
Keeps host (macOS/Windows) native binaries out of the Linux container |
<name>-playwright |
~/.cache/ms-playwright |
Browsers. Never shared — one project's playwright install GC's another's |
<name>-dind |
/var/lib/docker |
Only with docker-in-docker; persists the nested Supabase images |
<name>-claude, <name>-gh |
~/.claude, ~/.config/gh |
Optional; persist CLI logins across rebuilds |
Never mount a volume at ~/.local/share/pnpm (PNPM_HOME)
The standalone pnpm binary is baked into the image there; a volume would
shadow it. The store lives in /cache, never under PNPM_HOME.
Identity and editors
- Static name via
runArgs: ["--name", "<name>-dev", "--hostname", "<name>"]— stabledocker ps/ attach, and avscode@<name>prompt. Only one instance at a time; a stale container 409s a rebuild — clear it withdocker rm -f <name>-dev. - Both editors, one container:
customizations.vscodefor VS Code,customizations.jetbrainsfor Gateway. BackendWebStormfor frontend projects,IntelliJfor Kotlin/Gradle.
Gotchas (each found the hard way)
@fission-ai/openspec, notopenspec— the bare name is a 0.0.0 empty placeholder with no binary.- Don't shadow PNPM_HOME with a volume (see above).
pnpm config set --globalfrom$HOME, install withCI=true— run elsewhere it litters a.pnpm-storein the repo; withoutCI=truethe no-TTY store-migration prompt is fatal.- Chown
~/.cachefirst — the Playwright mount makes Docker create it root-owned, breaking corepack/pnpm/installers if they run before the chown. - java base:
rm /etc/apt/sources.list.d/yarn.list— its expired key breaks allapt-get update, and thus Playwright--with-deps. SQLite disk I/O errorusually means Docker Desktop's disk is full, not a config bug.docker system df; reclaim withdocker builder prune -afanddocker image prune -af(neither touches named volumes).- Verify in an interactive shell (
bash -ic) — a non-loginbash -cdoesn't source.bashrc, so fnm/pnpm won't be on PATH and everything looks "not installed".
Verification checklist
Build with devcontainer up --workspace-folder ., then inside the container:
- [ ]
node/pnpmversions match the pins;openspec --versionis 1.6.x. - [ ]
gh,clauderesolve;java -version(backend); nesteddocker run hello-world(dind). - [ ]
pnpm store path→/cache/pnpm-store/v..., recorded innode_modules/.modules.yaml; no.pnpm-storein the repo. - [ ] The project's own lint / build / unit tests pass; Chromium launches.
- [ ] Then
docker rm -f <name>-devso the IDE owns the container on next open.