The dev-container file — .devcontainer/devcontainer.json
One per repository, owned by the team that builds in it. It is the
standard dev-container file — the same file that opens the repository in any
editor that understands the format — and the factory reads it as-is. The
standard fields say what the container is: the image, the ports, the
lifecycle commands, the environment. What the standard cannot express lives
under customizations.clankernet, the standard’s own extension point, so
there is one place to write each thing.
flowchart LR
file["<b>devcontainer.json</b><br/>image · ports · commands · env<br/>+ customizations.clankernet"]
file -- "read at the ref the fleet pins" --> member
subgraph pod["The engineer's pod"]
member["<b>A member</b><br/>your image, your clone,<br/>its own ssh port"]
db[("database")]
member --- db
end
member -- "public ports" --> name["https://web-dev-alice.your.zone"]
member -- "secrets, by name" --> store["the host's secrets store"]
{
"image": "ghcr.io/acme/hello:factory", // prebuilt by the repo's own CI
"forwardPorts": [3000, 5432],
"portsAttributes": {
"3000": { "label": "web" },
"5432": { "label": "db" },
},
"onCreateCommand": "git lfs pull",
"postCreateCommand": { "deps": "npm ci", "seed": "npm run db:seed" },
"updateContentCommand": "npm ci",
"containerEnv": { "NODE_ENV": "development" },
"customizations": {
"clankernet": {
"$schema": "https://clanker.net/schema/devcontainer-customizations.v1.json",
"ports": {
"web": { "public": true, "default": true, "health": "/healthz" },
},
"services": {
"postgres": {
"databases": ["hello"],
"exports": { "DATABASE_URL": "hello" },
},
},
"env": {
"secrets": ["OPENAI_API_KEY"],
"templated": { "PUBLIC_URL": "https://{hostname}" },
},
"lifecycle": { "bestEffort": ["seed"] },
"procfile": "Procfile",
},
},
}
- Schema for the block:
https://clanker.net/schema/devcontainer-customizations.v1.json. Set it as the block’s$schemaand your editor validates it in place. - Validate:
factory validate .devcontainer/devcontainer.json --host <id>, or through the fleet file (The fleet file), which reads every listed repository’s file at its pinned ref. Errors name the field in this file’s terms. - The file is JSONC: comments and trailing commas are fine.
What happens when
flowchart LR
clone["clone at the pinned ref"] --> oc["onCreateCommand"] --> pc["postCreateCommand"] --> start["start:<br/>the Procfile, or postStartCommand"]
sync["every sync:<br/>fast-forward"] --> uc["updateContentCommand"] --> start
A command that fails ends the provision, unless its name is listed in
lifecycle.bestEffort. Every command form the standard allows is accepted —
a string, an array, or an object of name: command (the names appear in the
run summary).
Standard fields, and what the factory does with them
| Field | The factory |
|---|---|
image | Required. A prebuilt image your own CI publishes; the factory pulls it and never builds on the host |
features | Allow-listed: the standard docker-in-docker feature switches on container-in-container (in a pod it is a warning and the member runs without it); the standard sshd feature is a no-op (the image ships it). Any other feature is refused — features install at build time, and the image is prebuilt |
forwardPorts + portsAttributes[port].label | The container’s ports, by number; the label NAMES each port (routes, {port:NAME} templates and the block’s ports key off it). Every forwarded port needs a label. In a pod every declared port is a port of the whole pod, so a number another member declares is refused; declare every listener |
portsAttributes[port].protocol, onAutoForward | Accepted and ignored — editor concerns |
onCreateCommand | Runs once, right after the clone |
postCreateCommand | Runs once at provision, after onCreateCommand |
updateContentCommand | Runs at every sync, after the fast-forward |
postStartCommand | Runs at every start, in the shell window — unless the block names a procfile, in which case the Procfile’s processes run instead |
containerEnv, remoteEnv | Literal environment values, in every shell. ${localEnv:…} is refused (there is no local machine); ${containerEnv:…} is refused (write $NAME in the command that needs it) |
hostRequirements | Checked against the host at validate time; a container that asks for more than the host has is refused |
remoteUser, containerUser | Must be absent or developer — the image’s user is fixed |
workspaceFolder | Must be absent or an absolute path; default /home/developer/<repo> |
customizations.vscode, other customizations.* | Ignored — other tools’ business |
postAttachCommand | Ignored with a warning: nothing attaches to a factory container (ssh in and run it, or move it to postStartCommand) |
name, $schema, otherPortsAttributes, updateRemoteUserUID, overrideFeatureInstallOrder | Ignored |
Refused
The factory owns the runtime — the storage layout, the network, the routes, the startup — so a field that would bypass it is refused with a one-line reason naming where the thing goes instead:
| Field | Why |
|---|---|
build | Images are prebuilt by the repo’s own CI — point image at your registry |
mounts, workspaceMount | The factory owns the storage layout; the clone lives on its own volume at workspaceFolder |
runArgs, privileged, capAdd, securityOpt, init, overrideCommand | The container is started from a generated definition; capabilities and security options are the host owner’s; the startup must run — it is what clones, provisions and starts the Procfile |
dockerComposeFile, service, runServices | The factory generates the container definition; a repo-supplied one would bypass the storage, network and route contract |
appPort | Ports are published through the reverse proxy, never host-mapped — use forwardPorts + a label |
shutdownAction, userEnvProbe, waitFor | A container’s lifetime is the factory’s (teardown / purge); every process sources the env file; commands run in order |
initializeCommand | Runs on the LOCAL machine before the container exists, and there is no local machine |
secrets | Secrets are delivered by NAME from the host’s store — list them under customizations.clankernet.env.secrets |
A top-level field the engine does not know — the standard evolves — is ignored with a warning naming it.
customizations.clankernet
Strict: an unknown key is an error. Everything here is what the standard cannot say.
| Key | What it says |
|---|---|
$schema | The published schema URL, for editor validation |
repo.name, repo.url, repo.defaultBranch | The repository’s identity, for a standalone validate; the fleet entry’s values win when the file is read through a fleet |
image.base, image.pull | The base image the container expects, and a read token by name if the image you pull is private |
hostnames.default, hostnames.port | Templates for the container’s public names — {user}, {repo}, {zone}, {port} — under the host’s DNS zone. In a pod the fleet’s pod.hostnames are authoritative: leave the block out, or repeat the pod’s templates exactly |
ports.<label> | Per port, by label: public (reachable at its rendered hostname; default private), default (the one the bare hostname answers with), health (the path that must answer 200 after provision; / when omitted), paths (prefix / pattern routing), watchdog (watch the process behind this port and restart it when it stops answering; on by default), build (sources and output globs, so the watchdog also notices a process still answering with output it stopped rebuilding) |
services.postgres | The built-in database: which databases to create and which env names export their URLs |
services.redis | A built-in cache on loopback (redis://localhost:6379; a REST proxy on http://localhost:8079 when exports.restUrl/restToken are named) |
services.s3 | A built-in object store (http://localhost:9000, path-style): which buckets to create and which env names export the endpoint, the key pair and each bucket’s name — the same four names a shared bucket’s exports take. public: <port label> publishes it on a declared public port on 9000, and the endpoint becomes that https:// hostname — what an app needs when it hands presigned URLs to browsers or remote sandboxes |
services.primary, services.tmuxSession | Which Procfile process is the primary one, and the session name it runs in (default dev) — in a pod member the session is named by the repo instead, one per repo of the pod, and ssh lands in workspaces |
resources | Host-provided resources the container consumes — a cache database, an object-storage bucket SHARED with every developer on the host — bound by name to env values. Prefer the built-in services.*: nothing shared, nothing to clean up after someone else |
env.file | The env file every process sources (default .env) |
env.templated | Values rendered from {hostname}, {port:NAME} and the other placeholders |
env.secrets | Secret NAMES delivered from the host’s store; the values never enter this file or any repository |
env.computed | Values computed at provision from other values |
env.git | The token, by name, the container clones and pushes with |
hooks.env | The allow-list of env names the lifecycle commands may see |
lifecycle.bestEffort | Names of postCreateCommand / updateContentCommand entries whose failure does not end the run |
procfile | Path of the Procfile whose processes run at every start |
agents | Which agent tooling the container comes with. Claude Code and Codex are on every machine; codex: false leaves Codex unconfigured, plugins adds Claude Code plugins, claude.mcp names extra servers |
coordination | Whether the container joins the coordination server, and as whom |
sync, teardown | What sync does to the working tree, and what teardown keeps |
egress | A pod’s opt-in outbound proxy: the allowed domains and the secrets the pod holds as tokens (factory-yml.md) |
features.mosh | Open mosh (the image ships it) |
Keys that belong to the standard are refused inside the block, each naming
the standard field to use: env.static (→ containerEnv), hooks.postClone
/ hooks.provision / hooks.sync (→ the three lifecycle commands),
features.innerDocker (→ the standard feature), image.ref (→ image),
services.procfile (→ procfile), repo.path (→ workspaceFolder),
repo.user (→ remoteUser). Two keys belong to the host owner and are
refused here because a repository cannot grant itself a host: access (who
may hold a container) and host (which host) — both live in the fleet file.
Keeping a process up
Each entry of your procfile runs in its own window, and a port’s label
is the name of the window that serves it — that is the whole mapping. The
container watches every labelled port whose label is a process it started,
and restarts that process when it stops answering. Two things can go wrong,
and the second is the one nobody sees:
- The process dies and the port closes. Its window survives, sitting at a finished shell, so simply starting the missing windows again never brings it back.
- The process keeps answering and stops rebuilding. A dev server that builds in a separate child process goes on serving the last thing it built when that child is killed for memory; every edit after that silently does nothing.
The first needs nothing from you. For the second, say what the window builds from and what it writes:
"ports": {
"sandbox": {
"public": true,
"build": {
"sources": ["workers/sandbox/src/**", "workers/sandbox/wrangler.toml"],
"output": "workers/sandbox/.wrangler/tmp/dev-*/index.js.map"
}
},
"expo": { "public": true, "watchdog": false }
}
Point output at the source map, not the bundle: a build that produces
identical bytes is not written again, so a comment-only edit leaves the bundle
file untouched while its map always moves. Sources are compared by content,
never by timestamp — the container fast-forwards your checkout on a timer and
rewrites the timestamps of files whose bytes did not change.
A check must fail twice in a row before anything restarts, each window then
sits out about ten minutes, and every restart is one line in the watchdog
window. watchdog: false leaves a window alone entirely — for a process that
manages its own lifetime, or one whose port is deliberately not always open.
In a pod
When the fleet that lists this repository has a pod: block
(The fleet file), a container of it is
one member of the engineer’s pod: it shares one network, one public name
and one private-network address with the other members, and runs its own
ssh service on its own port. What that changes for this file:
- Hostnames come from the pod.
pod.hostnamesname every member; the block here is optional and must match if present. The apex (the pod’s one public name) shows this member only while it is the selected one; this member’s own port names ({port}-…) answer whichever member is selected — a public port label belongs to one member of the pod, and what is behind it (a worker, a callback endpoint, a published object store) is not a preview. - Ports are pod-wide. A port number another member declares, or a
member’s ssh port (
2201,2202, …), is refused. So is a second member withservices.postgres,services.redisorservices.s3: one database, one cache, one object store per pod. - No container-in-container. The standard
docker-in-dockerfeature is a warning in a pod, and the member runs without it. repo.nameis the member’s name in the pod, so it must be unique across the fleet’s members and workspaces, must not benet,redis,redis-rest,s3,s3-init,routesoregress, must not be all digits, and must not equal any member’s public port name.- Siblings are not this file’s business. Every repository of the fleet
is a directory of the pod’s shared root: this member’s own checkout at
its
workspaceFolderAND at/workspace/<repo.name>, every other member’s checkout at/workspace/<its name>(the same files that member runs, read-write), and a clone for each entry without a dev-container file. The fleet decides it (pod.siblings,repos[].pod.sibling); nothing here clones or mounts a sibling repository, andremoteUsermust be the same across the pod (it is:developer).
Two files, two owners
The dev-container file says what a container of this repository is. The
fleet file — one per host, in the host owner’s repository — says
which repositories the host runs, at which ref, and who may hold a pod with
each. The host spec says where. A repository never names a host,
and a host never edits a repository’s file; at every sync the host
re-reads each listed repository’s file at its pinned ref and converges.