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 describe the container: the image, the forwarded ports, the
lifecycle commands, the environment, the user, the workspace folder. What the
standard cannot express lives under customizations.clankernet, the
standard’s own extension point for tools, so there is one place to write each
thing and nothing is written twice.
{
"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:
schema/devcontainer-customizations.v1.json, published athttps://clanker.net/schema/devcontainer-customizations.v1.json. Set it as the block’s$schemaand an editor validates it in place; the upstream dev-container schema leavescustomizations.*free-form. - Validate:
node packages/engine/bin/factory.js validate .devcontainer/devcontainer.json --host <id>from a checkout ofclankerlabs/CLANKERNET, or through the fleet file (fleet.md), which reads every listed repository’s file at its pinned ref. Errors name the field in this file’s terms, never an internal one. - The file is JSONC: comments and trailing commas are accepted.
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; the standard sshd feature is a no-op (the base 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 |
portsAttributes[port].protocol, onAutoForward | Accepted and ignored — editor concerns |
onCreateCommand | Runs once, right after the clone |
postCreateCommand | Runs once at provision, after onCreateCommand; the object form gives each command a name the run summary reports |
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, verbatim, 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 spec’s server type at validate time; a container that asks for more than the host has is refused |
remoteUser, containerUser | Must be absent or developer — the base image’s user is fixed and the volume layout is keyed on it |
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 |
Every command form the standard allows is accepted — a string, an array, or
an object of name: command. A command that fails ends the provision unless
its name is listed in lifecycle.bestEffort.
Refused
The factory owns the runtime — the volume layout, the network, the routes, the entrypoint — 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 volume layout; the clone lives on its own volume at workspaceFolder |
runArgs, privileged, capAdd, securityOpt, init, overrideCommand | The container is started from a generated definition, not by hand; capabilities and security options are the host owner’s; the entrypoint 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 volume, 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; hooks 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 provider — 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 entrypoint 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} — rendered under the host’s DNS zone |
ports.<label> | Per port, by label: public (reachable through ingress at its rendered hostname; default private), default (the one the bare hostname answers with), health (the probe path), paths (prefix / regexp routing rules) |
services.postgres | The image-provided database: which databases to create and which env names export their URLs |
services.primary, services.tmuxSession | Which Procfile process is the primary one, and the session name it runs in (default dev) |
resources | Host-provided resources the container consumes — a cache database, an object-storage bucket — bound by name to env values |
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 secrets provider; 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 |
coordination | Whether the container joins the coordination gateway, and as whom |
sync, teardown | What sync does to the working tree, and what teardown keeps |
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.
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 container
for 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.