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 replicator 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.replicator, 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.replicator"]
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": {
"replicator": {
"$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:
replicator 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.
- The file describes the workspace only. A repository’s staging, production, sites and storage live in its environments file.
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 replicator does with them
| Field | The replicator |
|---|---|
image | Required. A prebuilt image your own CI publishes; the replicator 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 replicator-managed container (ssh in and run it, or move it to postStartCommand) |
name, $schema, otherPortsAttributes, updateRemoteUserUID, overrideFeatureInstallOrder | Ignored |
Refused
The replicator 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 replicator 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 replicator 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 replicator’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.replicator.env.secrets |
A top-level field the engine does not know — the standard evolves — is ignored with a warning naming it.
customizations.replicator
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 |
engine | The engine versions the file was written for, such as >=1.25.0 <2.0.0; an engine outside the range refuses the file, naming it. replicator doctor checks it (Modular repositories) |
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 — or, on a gated port, the gate’s own redirect to the app’s sign-in; / when omitted), auth (github, the default: the port’s hostname is behind the same GitHub sign-in as the app; none: open, for a webhook or callback target that must be reachable without a browser sign-in — the port services.s3.public names defaults to none, a presigned URL being its own authorisation), 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.optionalSecrets | Like env.secrets, but a name the store lacks is skipped instead of refusing the provision — for an integration wired up later |
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, http lists HTTP MCP servers keyed by a secret the container holds (configured only while that env var is set); http[].connect lets a developer CONNECT that key from the app with one click instead of the host owner seeding it — scopes (optional), an optional mintUrl the gateway calls once with the sign-in’s bearer for the durable credential it stores per developer, and an optional revokeUrl it calls on Disconnect; {} is enough; connect.app[] adds connections to the same server that only the app uses — each {name, label?, scopes, appUrl?} with a slug of its own (never another agents.http name), its own consent and scopes, no mint or revoke URL, and where the server’s own app answers (appUrl, for links to what the app started there): the gateway keeps it per developer, acts with it itself, and never gives it to a pod (the chat’s “Run on url and names itself by a public document at its own address. The service must publish its protected-resource and authorization-server metadata (the MCP authorization spec’s discovery), accept a client metadata document as the client id, and support PKCE with S256. The gateway makes every one of those calls, never the container. The server is ALWAYS wired in every member, connected or not: until the developer connects it (and the store holds no NAME__<LOGIN>), the pod’s proxy refuses its requests with a 403 and sends nothing on; a connect or a disconnect reaches the RUNNING pod within seconds with nothing restarted, and the connected value wins over the store’s NAME__<LOGIN>, which stands only while nothing is connected (“How a connected key reaches a running pod” below). connect needs bearer declared perDeveloper: true under egress.proxy.secrets (a connected key is a person’s key). repositoryHeader (a header name; off by default) tells the server which repository an agent is working in — Claude Code sends <owner>/<name> in that header on its calls (“Telling a server which repository an agent is in” below). chat (on by default under coordination) is the agent in the app’s chat — one per pod, in the pod.default member, at the pod’s root, signed in from the chat itself (no ssh): harness claude or codex, listen owner (only your messages drive it; anyone else’s wait for you) or workspace, enabled: false leaves it out |
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 outbound proxy, on by default with a baseline allow-list: declare it to list your own domains and the secrets the pod holds as tokens; enabled: false opts out |
egress.proxy.secrets[].perDeveloper | The secret is the pod owner’s own: read from the host’s store as NAME__<LOGIN> (replicator secrets set NAME --developer <login>), never the shared NAME — for a key that carries a person’s identity |
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.
Who may open a public port
A public port’s hostname is behind the same GitHub sign-in as the app. The
host’s ingress asks the coordination server before every request; a browser
that has not signed in on that hostname is sent to the app, signs in
there (if it has not already), and comes back with a cookie that opens that
hostname and no other. The sign-in lasts twelve hours and ends early when
the person loses push access to the fleet’s repositories. The container
itself sees the request with an X-Forwarded-User header naming the login.
What that gate cannot serve is a caller with no browser: a webhook a
provider delivers, a callback a third party posts, a sandbox fetching its
inputs. Declare such a port auth: none and its hostname is open:
"ports": {
"web": { "public": true, "default": true },
"hooks": { "public": true, "auth": "none" }
}
The port services.s3.public names is none unless it says otherwise: a
presigned URL is its own authorisation, and the browsers and sandboxes that
carry one hold no cookie. auth on a private port changes nothing — nothing
routes it. After provision, a gated default port is probed through the
public name like any other, and the gate’s own redirect to the app counts
as the route answering; whether the app behind it is alive is checked
directly inside the pod.
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).
How a connected key reaches a running pod
A key a developer connects from the app (agents.http[].connect) is used by
their pod at once, without a provision and without restarting anything:
- The server is wired from the start. Every member holds the pod’s stand-in token for the key, so the MCP server is in every member’s agent configuration even before anything is connected. Until it is, the pod’s proxy answers its requests with a 403 and forwards nothing — closed, never open.
- The pod’s network container watches. It holds the pod’s broker
credential (no member does) and waits on the gateway for the developer’s
connections to change. When they do, it writes the new key where only the
proxy reads it — or the host store’s
NAME__<LOGIN>while nothing is connected, or nothing at all — and tells the proxy to reload its files. The members keep running; their open connections are untouched. - Disconnecting is the same path backwards. The key is removed and the proxy reloaded, so the very next request is refused again; the proxy never keeps serving a key that was taken away.
- A provision does the same thing once, through the same program in the same container, so a provision and the live path always write the same files. If the gateway cannot be asked, that is a warning and the store’s value stands.
Shared library skills in a pod
A pod whose agents.http[] has a connect server also keeps its workspace’s
SHARED library skills where the coding agents read them. An owner or admin of
the workspace chooses which skills to share; the pod copies those, and only
those, into the shared root:
- Where.
/workspace/.claude/skills/ce-home--<slug>/, one directory per skill, read by Claude Code in every member.homeis the workspace the developer’s key was made in; a key that covers several workspaces syncs that one. - When. At the end of a member’s boot, and then about once a minute (a
librarywindow in thepod.defaultmember,factory-pod-library --watch; the Procfile can declare alibraryentry of its own). While the key is not connected, or the service refuses it, nothing changes: what is installed stays. - How. The sync runs as the developer inside a member and asks the service through the pod’s proxy with the same stand-in token the server entry uses, so no member holds the key. Every file is checked against the manifest’s size and SHA-256 before anything is installed; one mismatch refuses that skill and its old copy stays. Files are written read-only, a skill is swapped in as a whole, and a skill that is no longer shared is removed. The limits are 100 items, 20 files of at most 64 KB each per skill, and 2 MB in all.
- What is left alone. A directory without the
.clanker-managedmarker file is never touched; a symlink is never followed. Agents the workspace shares are listed in/workspace/.claude/library.statusand not installed.
factory-pod-library --once runs one pass by hand; library.status holds the
state, the counts and the last error (never a credential).
Telling a server which repository an agent is in
An agents.http[] server is written once, for the whole pod, into the shared
root’s .mcp.json: a session started at /workspace sends the bearer and
nothing else. A server that can pick a workspace by repository (so an agent in
one repository’s checkout acts in that repository’s workspace) asks for the
repository by naming the header it wants it in:
"http": [
{ "name": "tools", "url": "https://tools.example.com/mcp/", "bearer": "TOOLS_KEY", "repositoryHeader": "x-repository" }
]
For Claude Code, every member then also writes the server into the agent’s own
private configuration (~/.claude.json, under each repository’s path) once for
every repository of the pod whose URL is GitHub’s: its checkout at
/workspace/<name>, and this member’s own checkout at its workspaceFolder.
That entry is the same URL and the same Authorization: Bearer ${NAME} plus one
header, x-repository: <owner>/<name> — the repository’s GitHub owner
and name, lowercased, taken from the pod record and never from a file in a
checkout. Claude Code prefers its own configuration over the shared root’s
file, and a session anywhere inside a repository’s checkout matches that
repository’s path, so the header follows the repository the session started
in. Nothing is written into any checkout (nothing to commit or ignore), the
credential is still only the ${NAME} reference, the entry exists exactly while
the bearer has a value, and a repository that leaves the pod, or a secret that
goes, removes it. What the server does with the name is its own business; the
header is advisory.
- A session at
/workspaceitself, a repository whose URL is not GitHub’s, and a server withoutrepositoryHeadersend no header. - Codex does not send it: the server entries written for Codex are user-wide, and a header there would be the same in every directory.
- It is opt-in because the repository name goes to the server. Each member’s file declares it for itself (a member’s agents read its own configuration).
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.