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

FieldThe replicator
imageRequired. A prebuilt image your own CI publishes; the replicator 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 replicator-managed container (ssh in and run it, or move it to postStartCommand)
name, $schema, otherPortsAttributes, updateRemoteUserUID, overrideFeatureInstallOrderIgnored

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:

FieldWhy
buildImages are prebuilt by the repo’s own CI — point image at your registry
mounts, workspaceMountThe replicator 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 replicator 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 replicator’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.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.

KeyWhat it says
$schemaThe published schema URL, for editor validation
engineThe 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.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 — 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.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.optionalSecretsLike env.secrets, but a name the store lacks is skipped instead of refusing the provision — for an integration wired up later
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, 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 ” action uses one). Nothing is registered at the service and nothing is stored to set it up: the gateway finds the service’s sign-in from the server’s 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
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 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[].perDeveloperThe 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.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.

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.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).

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. home is 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 library window in the pod.default member, factory-pod-library --watch; the Procfile can declare a library entry 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-managed marker file is never touched; a symlink is never followed. Agents the workspace shares are listed in /workspace/.claude/library.status and 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 /workspace itself, a repository whose URL is not GitHub’s, and a server without repositoryHeader send 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.