Designing devcontainer.json as the Reproducible Backbone of a GitHub Codespace
Treat devcontainer.json as the versioned source of truth for a Codespace: pin the image, split lifecycle commands, respect storage and trust boundaries, and verify against creation logs.
24 Dec 2025, 02:27 UTC

A team that adopts Codespaces usually hits the same wall: the first codespace works, but the second developer's environment drifts because setup lives in a README instead of in versioned configuration. The fix is to treat .devcontainer/devcontainer.json as the single source of truth for the environment — image, tooling, extensions, ports, and startup behavior — and to design it with explicit trust and storage boundaries in mind. This note covers the smallest design that achieves parity, where data actually persists, and the failure modes that should change your design.
Requirements the design must satisfy
Before writing any JSON, pin down four requirements:
- Parity: any developer opening the repo in a codespace (or locally in VS Code with the Dev Containers extension) gets the same toolchain versions.
- Fast cold start: creation time is dominated by image pull and build, so the base image choice matters more than post-create scripting.
- Bounded trust: anything running at container start executes with the codespace's credentials, including the repository-scoped
GITHUB_TOKEN. - Known persistence: the team must know which directories survive a container rebuild and which vanish with codespace deletion.
The smallest suitable design
Start with a prebuilt Microsoft dev container image and declarative features rather than a custom Dockerfile. Features are versioned, cached layers that install common tools, and they keep the config reviewable in a pull request.
{\n \"name\": \"orders-api\",\n \"image\": \"mcr.microsoft.com/devcontainers/base:ubuntu-24.04\",\n \"features\": {\n \"ghcr.io/devcontainers/features/node:1\": { \"version\": \"20\" },\n \"ghcr.io/devcontainers/features/docker-in-docker:2\": {}\n },\n \"customizations\": {\n \"vscode\": {\n \"extensions\": [\"dbaeumer.vscode-eslint\", \"esbenp.prettier-vscode\"]\n }\n },\n \"forwardPorts\": [3000, 5432],\n \"portsAttributes\": {\n \"5432\": { \"label\": \"Postgres\", \"onAutoForward\": \"silent\" }\n },\n \"remoteEnv\": {\n \"NODE_ENV\": \"development\"\n },\n \"postCreateCommand\": \"npm ci\",\n \"postStartCommand\": \"docker compose up -d db\"\n}Three deliberate choices here:
- Pin the image tag (
ubuntu-24.04, notlatest) so a base-image refresh cannot silently change toolchain behavior. postCreateCommandfor one-time setup,postStartCommandfor per-start services. Conflating them means dependency installs re-run on every start, slowing resume.- Declare ports explicitly. Codespaces honors
forwardPortsthrough its network layer, but inbound visibility defaults to private; a teammate hitting a forwarded port needs it shared explicitly via the Ports panel.
When the design outgrows this — system packages not available as features, or a mirrored base image required by policy — switch to a build.dockerfile reference and, for repeated use, publish the built image to a registry and reference it in image. Prebuilding turns a 5–10 minute creation into an image pull. GitHub's prebuilds feature automates this per branch; that is the natural next step once creation time becomes a complaint.
Trust and data boundaries
Two boundaries deserve explicit decisions:
Execution trust. Every lifecycle command (postCreateCommand, postStartCommand) runs inside the codespace with access to its token. Codespaces prompts before running untrusted repository config in some flows, but do not rely on prompting as a control: treat any merged change to devcontainer.json like a change to CI. Require review on the .devcontainer path via CODEOWNERS.
Persistence. Within an active codespace, the home directory and workspace survive container restarts and rebuilds. Nothing survives codespace deletion — storage is allocated per plan with a GB cap, and the cap resets when the codespace is deleted. Design consequences:
- Never store secrets or uncommitted work only in a codespace; push branches and use Codespaces secrets or a secrets manager for credentials.
- Keep large generated artifacts (dependency caches, build outputs) out of the persisted tree where possible, or set a retention policy on idle codespaces so storage does not silently fill.
Operational checks
After creation, verify the environment matches the config rather than assuming it:
- In the codespace, run
devcontainer-infois not required — simpler: check the VS Code status bar remote indicator shows the configured container name, and open the Extensions view to confirm the declared extensions installed. - Run
node --version(or your toolchain equivalent) in the integrated terminal and compare against the feature version pinned in the config. - Open the Ports panel and confirm 3000 and 5432 are forwarded with the expected visibility.
- From the gear icon, open codespace settings and check storage usage against your plan's limit. Plan quotas and sizes change over time, so record the observed values in your team's runbook rather than hardcoding expectations.
Creation logs are the diagnostic of first resort when a codespace fails to build: in VS Code, run Codespaces: View Creation Log from the Command Palette. A feature/image mismatch (for example, a feature requiring glibc against an Alpine base) typically fails here with a build error, sometimes without a clear message in the UI.
Failure modes and when to change the design
| Symptom | Likely cause | Response |
|---|---|---|
| Creation fails during feature install | Feature incompatible with base image (e.g., musl vs glibc) | Switch base image or replace feature with Dockerfile steps |
| Extension missing on launch | Gallery rate limit or unpublished custom extension | Retry creation; publish custom extensions or bundle via a prebuilt image |
| Codespace slow after resume | Heavy work in postStartCommand | Move one-time work to postCreateCommand or a prebuild |
| Disk full mid-session | Plan storage cap reached | Prune Docker images/caches; delete idle codespaces; move to a larger machine type |
| Teammate cannot reach forwarded port | Port visibility is private | Change visibility in the Ports panel or document the constraint |
Change the design when: creation time regularly exceeds a few minutes (adopt prebuilds), the toolchain needs system-level packages (move to a Dockerfile), or multiple repos share the same environment (publish a common base image and reference it, keeping per-repo config limited to extensions and ports). Machine size selection remains a per-codespace choice constrained by plan quotas — do not try to force CPU/memory from devcontainer.json; document the recommended size instead.
Limitations
Storage quotas, machine sizes, and port-forwarding limits are plan-dependent and change as GitHub updates the product; verify current values in your organization's billing/docs before committing to numbers. Behavior described here assumes the current devcontainer specification and the default Codespaces VS Code experience as of late 2026; confirm against the creation log and settings panel in your own account, since that is the ground truth the design must satisfy.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.