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 $schema and 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

FieldThe factory
imageRequired. A prebuilt image your own CI publishes; the factory pulls it and never builds on the host
featuresAllow-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].labelThe 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, onAutoForwardAccepted and ignored — editor concerns
onCreateCommandRuns once, right after the clone
postCreateCommandRuns once at provision, after onCreateCommand
updateContentCommandRuns at every sync, after the fast-forward
postStartCommandRuns at every start, in the shell window — unless the block names a procfile, in which case the Procfile’s processes run instead
containerEnv, remoteEnvLiteral environment values, in every shell. ${localEnv:…} is refused (there is no local machine); ${containerEnv:…} is refused (write $NAME in the command that needs it)
hostRequirementsChecked against the host at validate time; a container that asks for more than the host has is refused
remoteUser, containerUserMust be absent or developer — the image’s user is fixed
workspaceFolderMust be absent or an absolute path; default /home/developer/<repo>
customizations.vscode, other customizations.*Ignored — other tools’ business
postAttachCommandIgnored with a warning: nothing attaches to a factory container (ssh in and run it, or move it to postStartCommand)
name, $schema, otherPortsAttributes, updateRemoteUserUID, overrideFeatureInstallOrderIgnored

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:

FieldWhy
buildImages are prebuilt by the repo’s own CI — point image at your registry
mounts, workspaceMountThe factory owns the storage layout; the clone lives on its own volume at workspaceFolder
runArgs, privileged, capAdd, securityOpt, init, overrideCommandThe 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, runServicesThe factory generates the container definition; a repo-supplied one would bypass the storage, network and route contract
appPortPorts are published through the reverse proxy, never host-mapped — use forwardPorts + a label
shutdownAction, userEnvProbe, waitForA container’s lifetime is the factory’s (teardown / purge); every process sources the env file; commands run in order
initializeCommandRuns on the LOCAL machine before the container exists, and there is no local machine
secretsSecrets 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.

KeyWhat it says
$schemaThe published schema URL, for editor validation
repo.name, repo.url, repo.defaultBranchThe repository’s identity, for a standalone validate; the fleet entry’s values win when the file is read through a fleet
image.base, image.pullThe base image the container expects, and a read token by name if the image you pull is private
hostnames.default, hostnames.portTemplates 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.postgresThe built-in database: which databases to create and which env names export their URLs
services.redisA built-in cache on loopback (redis://localhost:6379; a REST proxy on http://localhost:8079 when exports.restUrl/restToken are named)
services.s3A 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.tmuxSessionWhich 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
resourcesHost-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.fileThe env file every process sources (default .env)
env.templatedValues rendered from {hostname}, {port:NAME} and the other placeholders
env.secretsSecret NAMES delivered from the host’s store; the values never enter this file or any repository
env.computedValues computed at provision from other values
env.gitThe token, by name, the container clones and pushes with
hooks.envThe allow-list of env names the lifecycle commands may see
lifecycle.bestEffortNames of postCreateCommand / updateContentCommand entries whose failure does not end the run
procfilePath of the Procfile whose processes run at every start
agentsWhich 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
coordinationWhether the container joins the coordination server, and as whom
sync, teardownWhat sync does to the working tree, and what teardown keeps
egressA pod’s opt-in outbound proxy: the allowed domains and the secrets the pod holds as tokens (factory-yml.md)
features.moshOpen 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.hostnames name 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 with services.postgres, services.redis or services.s3: one database, one cache, one object store per pod.
  • No container-in-container. The standard docker-in-docker feature is a warning in a pod, and the member runs without it.
  • repo.name is the member’s name in the pod, so it must be unique across the fleet’s members and workspaces, must not be net, redis, redis-rest, s3, s3-init, routes or egress, 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 workspaceFolder AND 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, and remoteUser must 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.