Quickstart

From nothing to a developer pod on your own host, in your own accounts: a standard dev-container file in the repository your team builds, two files in the repository of whoever holds the host, one workflow that calls ours, and the secrets your host spec names. Nothing else. What comes out is a mobile-first cloud development environment with Claude Code and Codex set up on every machine.

flowchart LR
  a["1 · Dev-container file<br/>in each repository"] --> c
  b["2 · Host spec + fleet file<br/>in the host owner's repository"] --> c
  c["3 · One workflow<br/>that calls ours"] --> d["host-up<br/><i>the owner, once</i>"]
  d --> e["provision<br/><i>each engineer, once</i>"]
  e --> f["A pod per engineer<br/>public name · private address · ssh per repo"]

What “your own accounts” means, and what we never hold, is Bring your own cloud — read its bill of materials first, because a host owner without a DNS zone stops at step 2.

1. The dev-container file — what a container of your repo is

.devcontainer/devcontainer.json, at the root of the repository that gets the containers, in the standard location, so the same file opens the repo in any editor that understands it. The standard fields describe the container; what the standard cannot express goes under customizations.clankernet. Every key is in The dev-container file; the schema for the block is published at https://clanker.net/schema/devcontainer-customizations.v1.json.

{
  "image": "ghcr.io/acme/hello:factory", // prebuilt by your own CI
  "forwardPorts": [3000],
  "portsAttributes": { "3000": { "label": "web" } },
  "postCreateCommand": "npm ci",
  "customizations": {
    "clankernet": {
      "$schema": "https://clanker.net/schema/devcontainer-customizations.v1.json",
      "ports": { "web": { "public": true, "health": "/healthz" } },
      "services": { "postgres": { "databases": ["hello"], "exports": { "DATABASE_URL": "hello" } } },
      "env": { "secrets": ["OPENAI_API_KEY"] }
    }
  }
}

image is required and prebuilt — the factory never builds on the host. forwardPorts plus a label name the ports; the block says which are public, how they are probed, which built-in services to start and which secrets to deliver by name.

2. The host spec and the fleet file — where it runs, and what runs there

Both live in the repository of whoever holds the cloud accounts — the host owner. The host spec (Host specs) says where; every credential in it is a secret:NAME into the secrets store the spec names, and no value is ever in the file.

# hosts/acme.yml
id: acme-hel1
namePrefix: acme-dev
compute:
  provider: hetzner
  hetzner: { serverType: cpx22, location: hel1, tokenFrom: secret:hetznerToken }
ingress:
  provider: cloudflare-tunnel
  accountId: <your account id at the DNS and edge provider>
  tokenFrom: secret:cloudflareApiToken
dns:
  zones: [acme.example]
  tokenFrom: secret:cloudflareApiToken
access:
  provider: tailscale
  tailscale:
    tag: tag:dev
    oauthFrom:
      {
        clientId: secret:tailscaleClientId,
        clientSecret: secret:tailscaleClientSecret,
      }
secrets:
  provider: pulumi-config # encrypted values in the committed stack file; or pulumi-esc
state:
  provider: pulumi-cloud
  pulumiCloud: { org: acme }
image:
  pull: { tokenFrom: secret:ghcrPullToken } # only if the images your host pulls are private

The fleet file (The fleet file) says which repositories the host runs, at which ref, and who may hold them. With pod: present, every engineer gets ONE pod on the host: a container for each repository that has a dev-container file, a shared clone for each one that does not, one public name and one private-network address for all of them:

# factory.yml
# yaml-language-server: $schema=https://clanker.net/schema/factory-fleet.v2.json
version: 2
host: ./hosts/acme.yml
pod: {} # one pod per engineer; the defaults name it dev-<login>
repos:
  - url: https://github.com/acme/hello
    ref: main # a branch, resolved at every sync; or a 40-character sha
    access: { github: { users: [octocat] } } # who may hold a pod with this repo
  - url: https://github.com/acme/docs
    ref: main
    devcontainer: false # no container — a clone at /workspaces/docs, seen by every member
    access: { github: { users: [octocat] } }

factory validate factory.yml tells you what the files refuse — including what each listed repository’s dev-container file refuses — and factory plan --user <login> lists every secret NAME the host’s store must hold before the first run. (Both run from a checkout of clankerlabs/CLANKERNET: node packages/engine/bin/factory.js ….)

3. The workflow — one job that calls ours

In the host owner’s repository. Pin the 40-character SHA on the uses: line and in factory_ref, and let your dependency bot move both.

name: Factory
on:
  workflow_dispatch:
    inputs:
      action:
        type: choice
        default: provision
        options:
          [provision, select, sync, teardown, purge, fleet-sync, host-up, host-converge, host-down]
      entry:
        description: "select: the repository (owner/name) the public name should show"
        default: ""
permissions:
  contents: read
jobs:
  factory:
    uses: clankerlabs/CLANKERNET/.github/workflows/factory-deploy.yml@0123456789abcdef0123456789abcdef01234567 # v1.0.0
    permissions:
      contents: read
      packages: read
    with:
      action: ${{ inputs.action }}
      entry: ${{ inputs.entry }}
      host: ./hosts/acme.yml
      factory_ref: 0123456789abcdef0123456789abcdef01234567 # the same SHA
    secrets:
      PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule: { interval: weekly }

What each action does

flowchart TB
  subgraph owner["Host owner"]
    hu["host-up<br/>create the host — once"] --> hc["host-converge<br/>after a spec change"]
    fs["fleet-sync<br/>bring every pod up to the fleet file — daily or by hand"]
    hd["host-down<br/>when it is over"]
  end
  subgraph dev["Each engineer"]
    p["provision<br/>my pod — once"] --> s["select<br/>which repo the public name shows"]
    p --> sy["sync<br/>fast-forward every repo"]
    p --> t["teardown<br/>stop; keep my data"]
    t --> pu["purge<br/>remove my data too"]
  end
WhoActionWhat happens
host ownerhost-upCreates the host in your accounts, once. host-converge re-applies the host after a spec change or an image bump; host-down removes it (it refuses while the host still provides a database or bucket)
host ownerfleet-syncConverges every pod on the host to the fleet file: a repository added to the fleet joins every existing pod; a removed one leaves. It never restarts a running container — a change that needs one is reported stale for the engineer’s own provision. Runs daily from sync.schedule, or on demand
engineerprovisionOnce. The person who clicks owns the pod; a repository their login is not allowed is simply left out. The onboarding card — public name, private address, an ssh port per repository, a ready-made ~/.ssh/config block — lands in the job summary
engineerselectMoves the public name (and plain ssh on port 22) to the repository named in entry, in seconds, restarting nothing. The same switch is factory select <name> from a shell inside the pod
engineersyncFast-forwards every repository in the pod at its ref and re-runs its update command
engineerteardown / purgeStops the pod and keeps its data; purge removes the data too
org adminteardown / purge with user:Offboarding: an admin of the organisation named under access removes a departed member’s pod. The dispatcher’s role is checked, never the target’s membership

Nobody provisions twice; a repository added later reaches every pod through fleet-sync.

What “provision succeeded” actually verifies (2026-09-23): every member’s container booting and its sshd answering is not the same claim as its app working — a member can boot cleanly and never start its own dev stack. So provision also curls every member’s own default port at its declared health path, from inside the pod’s shared network namespace, unconditionally (not just the repository select will show publicly); a member that never answers HTTP 200 fails the run by name, not silently. You can run the same check yourself at any time: curl https://<port-label>-<your-pod>.<zone>/<health-path> for a member’s own labelled port, or https://<your-pod>.<zone>/<health-path> for whichever member is currently selected.

If something the workflow reads is private

A host spec in a private repository, or a private copy of the engine: register a GitHub App with Contents: read, install it on your account, keep its id and private key as two more encrypted values on the host stack you already hold, and point checkout_app_from at them as <dir>:<stack>:<idKey>,<keyKey>. Each run exchanges them for a read-only token that lives an hour at most and is revoked when the run ends. A public engine and a host spec in your own repository need none of this.

pulumi config set --secret githubAppId --cwd pulumi --stack acme/clanker-infra/acme-hel1
pulumi config set --secret githubAppPrivateKey --cwd pulumi --stack acme/clanker-infra/acme-hel1 < app.pem
    with:
      checkout_app_from: pulumi:acme/clanker-infra/acme-hel1:githubAppId,githubAppPrivateKey

The secrets you hold

Which ones is the host spec’s decision. The workflow’s secrets are all optional, and a missing one is reported by name.

state.provider / secrets.providerCI secrets you holdWhere the values live
pulumi-cloud / pulumi-config (the reference deployment)PULUMI_ACCESS_TOKEN — oneEncrypted in the committed stack file, decrypted by the stack’s own key
pulumi-cloud / pulumi-escPULUMI_ACCESS_TOKEN — oneYour organisation’s hosted secrets store on the state service
s3 / pulumi-configAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, FACTORY_PASSPHRASE — threeEncrypted in the stack file on your bucket
s3 / factory-secretsthe three above + FACTORY_SECRETS, ONE JSON object of name → valueThe JSON secret itself, in your repository settings

factory secrets set NAME (value on stdin) writes a value into the store the spec names.

Keeping the pins honest

factory doctor .devcontainer/devcontainer.json --host <id> is the offline skew report: whether your caller’s uses: and factory_ref: are one 40-character SHA, whether your base image is this engine’s major, and every secret NAME the host references. A float or a disagreement exits 1, so it can gate a CI step.

What runs on your runner, and what we can reach

Everything runs in your CI, under your permissions: the workflow checks out your repository and our engine at the SHA you pinned, validates the files, logs in to your state backend, runs the one action, and reads the host’s four non-secret outputs back. Every secret is an environment variable on the steps that need it, never an argument. It asks for contents: read and packages: read, nothing more. Nothing on our side can reach your host; revoking us is deleting the caller workflow (what we hold).

@v1 — the floating release tag — is what a first try may use. It is not what you run in anger: a float is code you have not read yet.