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 at https://clanker.net/schema/devcontainer-customizations.v1.json. Set it as the block’s $schema and an editor validates it in place; the upstream dev-container schema leaves customizations.* free-form.
  • Validate: node packages/engine/bin/factory.js validate .devcontainer/devcontainer.json --host <id> from a checkout of clankerlabs/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

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; 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].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
portsAttributes[port].protocol, onAutoForwardAccepted and ignored — editor concerns
onCreateCommandRuns once, right after the clone
postCreateCommandRuns once at provision, after onCreateCommand; the object form gives each command a name the run summary reports
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, verbatim, 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 spec’s server type at validate time; a container that asks for more than the host has is refused
remoteUser, containerUserMust be absent or developer — the base image’s user is fixed and the volume layout is keyed on it
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

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:

FieldWhy
buildImages are prebuilt by the repo’s own CI — point image at your registry
mounts, workspaceMountThe factory owns the volume layout; the clone lives on its own volume at workspaceFolder
runArgs, privileged, capAdd, securityOpt, init, overrideCommandThe 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, runServicesThe factory GENERATES the container definition; a repo-supplied one would bypass the volume, 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; hooks 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 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.

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 entrypoint 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} — 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.postgresThe image-provided database: which databases to create and which env names export their URLs
services.primary, services.tmuxSessionWhich Procfile process is the primary one, and the session name it runs in (default dev)
resourcesHost-provided resources the container consumes — a cache database, an object-storage bucket — bound by name to env values
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 secrets provider; 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
coordinationWhether the container joins the coordination gateway, and as whom
sync, teardownWhat sync does to the working tree, and what teardown keeps
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.

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.