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
| Who | Action | What happens |
|---|---|---|
| host owner | host-up | Creates 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 owner | fleet-sync | Converges 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 |
| engineer | provision | Once. 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 |
| engineer | select | Moves 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 |
| engineer | sync | Fast-forwards every repository in the pod at its ref and re-runs its update command |
| engineer | teardown / purge | Stops the pod and keeps its data; purge removes the data too |
| org admin | teardown / 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.provider | CI secrets you hold | Where the values live |
|---|---|---|
pulumi-cloud / pulumi-config (the reference deployment) | PULUMI_ACCESS_TOKEN — one | Encrypted in the committed stack file, decrypted by the stack’s own key |
pulumi-cloud / pulumi-esc | PULUMI_ACCESS_TOKEN — one | Your organisation’s hosted secrets store on the state service |
s3 / pulumi-config | AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, FACTORY_PASSPHRASE — three | Encrypted in the stack file on your bucket |
s3 / factory-secrets | the three above + FACTORY_SECRETS, ONE JSON object of name → value | The 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.