The fleet file — factory.yml
The host-level file: one per host, owned by the host owner. It names the host and the repositories every engineer’s pod on that host holds, each at a pinned ref, and who may hold them. A repository’s own container settings are not here — each repository carries its standard dev-container file. Where it all runs is the host spec the fleet points at. From nothing to a first pod: Quickstart.
# yaml-language-server: $schema=https://clanker.net/schema/factory-fleet.v2.json
version: 2
host: ./hosts/dev-host.yml # or a repo:/stack: reference, or the host spec inline
pod: # present → one POD per engineer; absent → one container per repo
default: clankerlabs/CLANKERNET # what a fresh pod shows first
repos:
- url: https://github.com/clankerlabs/clankerengineer
ref: develop # a branch (resolved at every read) or a 40-character sha
devcontainer: .devcontainer/devcontainer.json # the default
access: { github: { org: clankerlabs } } # REQUIRED — who may hold a pod with it
- url: clankerlabs/CLANKERNET # the bare slug is the same repository
ref: main
access: { github: { org: clankerlabs } }
- url: clankerlabs/ClankerLabs
ref: main
devcontainer: false # no container: a clone every member of the pod can see
access: { github: { org: clankerlabs } }
sync:
schedule: "0 6 * * *" # when the daily fleet-sync runs, optional
- Schema:
https://clanker.net/schema/factory-fleet.v2.json. - Validate:
factory validate factory.yml. The report names the host, every enabled repository with the sha its ref resolved to, the file that was read and its ssh port, and everyenabled: falseentry as skipped.--offlinemakes no network call (entries are listed unresolved with a warning);--jsongives the same as data.
What a pod is
With pod: present, every engineer who provisions on this host holds ONE
pod named after them: a member for each repository that has a
dev-container file, a workspace clone for each one that does not — all
behind ONE public name and ONE address on your private network.
flowchart TB
subgraph pod["Pod dev-alice — one address on your private network"]
direction TB
door["Port 22 → the <b>selected</b> member"]
m1["Member <b>clankerengineer</b><br/>its database, its ssh port 2201<br/>runs /workspace/clankerengineer"]
m2["Member <b>clankernet</b><br/>its ssh port 2202<br/>runs /workspace/clankernet"]
ws["<b>/workspace</b> — the shared root, seen by every member<br/>clankerengineer · clankernet · clankerlabs (a workspace clone)<br/>CLAUDE.md · AGENTS.md · the <code>clanker</code> agent"]
door -. currently .-> m2
m1 --- ws
m2 --- ws
end
public["<b>dev-alice.your.zone</b><br/>and marketplace-dev-alice.your.zone …"] -- "the selected member" --> door
Members share one network: they reach each other on localhost, and every
port a member declares is a port of the whole pod (which is why two members
cannot declare the same number). Each member keeps its own storage — its
clone, its database, its keys — and every checkout is a sibling in every
member: a member’s clone is also mounted, read-write, at
/workspace/<name> in every other member, beside the workspace clones. So
from any member ls /workspace lists every repository of the fleet; the
files are the ones each member runs. What stays a member’s own is its
runtime — its processes, ports, hooks and tmux session. (pod.siblings: false keeps each clone inside its own member; repos[].pod.sibling: false
keeps one clone out.) Nothing is lost on a teardown; only purge removes
storage.
The shared root is also a project for the coding agents: it carries a
CLAUDE.md you may edit, an AGENTS.md generated at every boot (every
repository, its path, its ssh port, which one the public name shows), a
clanker agent that works inside one repository, and the coordination
server. A session started at /workspace fans out one clanker per
repository and closes with the checks each repository names; a session
inside one repository follows that repository’s own instructions. The
members’ secrets are denied to that root session on purpose.
Without pod:, the fleet keeps the single-container shape: one container per
engineer per repository, each with its own name and address.
Fields
Strict throughout: an unknown key is an error, not a silent no-op.
version (required)
2.
host (required)
Where the fleet runs — a host reference in one of three forms, or the host spec inline:
| Form | Resolved from |
|---|---|
./hosts/<name>.yml | a file relative to the fleet file |
repo:<owner>/<repo>@<ref>:<path>.yml | the host owner’s repository at that ref (a private one needs the workflow’s checkout_app_from) |
stack:<org>/<project>/<stack> | the host’s own published, non-secret description |
| an object | the host spec itself, in place |
--host on the command line overrides it: a host id, a path, or a reference.
repos[] (required, at least one)
| Key | Type | Default | Meaning |
|---|---|---|---|
url | repository URL or owner/name | — | The repository; the URL and the bare slug are one identity, and a repository listed twice is refused |
ref | branch/tag or 40-hex sha | — | Where the dev-container file is read. A branch means “whatever it is at read time” — the run summary records the sha it resolved to; pin a sha for reproducibility |
devcontainer | repo-relative path, or false | .devcontainer/devcontainer.json | The file to read at ref. false makes the entry a workspace: no container runs — the repository is cloned at /workspace/<name>, where every member sees it (needs pod:). A branch ref is fast-forwarded on every start and sync; a sha holds it at one commit |
access | object | — (required) | Who may hold a pod with this repository on this host, and how they reach it. Below |
enabled | boolean | true | false keeps the entry in the file but out of every read: not fetched, not converged. In a pod fleet its member leaves every pod like a removed one (storage kept), and it still counts for the ssh port table, so its port waits for it |
gateway | boolean | false (absent) | true makes this the coordination repository: the people who can push it are the people who may sign in to the host’s coordination server, and in a pod fleet the FIRST flagged, enabled entry is where the pod coordinates — every member’s CLANKBASE_WORKSPACE is its name (as its checkout, /workspace/<name>), whichever member boots (several flagged: the first wins, validate warns, and pushers of any of them may sign in; none flagged: each member keeps a workspace named after its own repository). A host spec with a clankbase: block needs at least one flagged, enabled entry (host up and host converge refuse otherwise, validate warns). A host without that block needs none. The key keeps its original name |
pod.sshPort | 1–65535, not 22 | 2200 + the entry’s place among container entries | The member’s ssh port on the pod’s address (needs pod:). Pin it to keep the port across a reorder; two entries on one port are refused |
pod.sibling | boolean | true | With pod.siblings on, this clone is mounted in every member at /workspace/<name>. false keeps this one clone inside its own member (which still sees the others) |
The file is read at the sha the ref resolved to, so a push between two reads cannot straddle a run. A repository with no dev-container file at its ref fails naming the path, the ref and the sha:
fleet repo `acme/api`: no `.devcontainer/devcontainer.json` at ref `develop` (b2c…) — every fleet repo carries its dev-container file at the pinned ref
access
Who may hold a container of this repository on this host, and how they reach it:
| Key | Type | Default | Meaning |
|---|---|---|---|
github.org | login | — | Only members of this organisation may provision. Needs an org-read token by name in the host’s secrets store |
github.users | login[] | — | An explicit allow-list; needs no token |
ssh.keySource | github-keys | sshid | list | github-keys | Where authorised keys come from. github-keys = the login’s published keys |
ssh.users.<login>.sshid | string | the login | sshid.io username override |
ssh.users.<login>.keys | string[] | — | Static keys, for list |
tailscale.hostname | template (single label) | first label of the default hostname | The container’s name on your private network — single-container fleets only. In a pod fleet the pod has ONE address (pod.tailscale.hostname) and this key is a warning asking you to remove it |
Required, with github.org or github.users inside it. “Who may hold a
pod on this host” is a grant of the host owner’s compute, so the host owner
states it, in the host owner’s file — a repository cannot grant itself a
host, which is why access is refused inside a dev-container file and why
an entry without it is an error rather than an open door.
sync
| Key | Type | Default | Meaning |
|---|---|---|---|
schedule | five-field cron expression | — | When the daily fleet-sync re-reads every repository at its ref and converges every pod on the host |
pod — one pod per engineer
| Key | Type | Default | Meaning |
|---|---|---|---|
hostnames.default | template ending in .{zone} | dev-{user}.{zone} | The pod’s one public name, answered by the selected member. Must contain {user}; {repo} is refused (one name for the whole pod). Its first label names the pod |
hostnames.port | template ending in .{zone} | {port}-dev-{user}.{zone} | Every other public port of the selected member, by the port’s name. Must contain {user} and {port} |
tailscale.hostname | template (single label) | dev-{user} | The pod’s ONE name on your private network. Must contain {user}. Every member’s ssh is a port on it |
default | owner/name | the first container entry | What a fresh pod shows before anyone selects. Must be a container entry (a workspace has nothing to show) |
siblings | boolean | true | Every member’s clone mounted read-write in every member at /workspace/<name> — one engineer, one user, one trust domain (every member must declare the same remoteUser). false keeps each clone inside its own member |
All keys are optional — pod: {} takes every default. The pod’s templates
name every member; a member’s own file may repeat them or leave them out,
but a file declaring different templates is refused, because it would be
routed under names it never gets.
Reaching your repos
A pod has ONE address on your private network and ONE public name. The
onboarding card (the job summary of your provision) prints all of this
for your own pod; the shape, for an engineer alice on the host above:
| Port | Reaches |
|---|---|
22 | the selected member, whichever it is |
2201 | clankerengineer |
2202 | clankernet |
ssh developer@dev-alice # :22 → the selected member → its tmux session
ssh -p 2201 developer@dev-alice # clankerengineer, whatever is selected
ssh -p 2202 developer@dev-alice # clankernet
mosh developer@dev-alice # the selected member
scp -P 2202 file dev-alice:~/clankernet/
Every member runs its own ssh service on its port, authenticates you with
its own entry’s access.ssh.keySource, and drops you into its workspaces
tmux session at the shared root — where a session works across every
repository. Every repository of the pod is a tmux session of its own,
named by the repository and opened in /workspace/<name> (so cd .. is
the root): this member’s own session runs the Procfile’s processes, each
other one holds a login shell in its directory. prefix s lists them,
Alt+Left/Right steps between them, prefix Tab returns to the last one,
and the right of the status bar reads <you>@<member>;
services.tmuxSession names the session of a standalone container only.
The pod shares one host key,
so a switch never changes what your client sees. The card also prints a
~/.ssh/config block so each member has a name of its own:
Host dev-alice
HostName dev-alice
User developer
Host dev-alice-clankerengineer
HostName dev-alice
User developer
Port 2201
Framing a pod
The app can show your pod’s public name in a frame, and nothing else can. On
a host whose clankbase: block names an appOrigin, every public route of
every pod answers with Content-Security-Policy: frame-ancestors <origin>,
where <origin> is that appOrigin (the app’s own origin), and the response
carries no X-Frame-Options header. A host without one renders none of this,
so nothing may frame its pods.
The router can set a header but not add one only when the app has not: on a
pod’s routes the router’s Content-Security-Policy replaces the one your
app sends, and an X-Frame-Options your app sends is dropped. An app that
needs its own content policy for the page itself cannot keep it behind a pod
route; leave framing to the router and do not set either header in the app.
The rule is written into a pod’s routes when it is provisioned, so an
existing pod gets it only after its next provision. The app’s own pages stay
unframeable, and its content policy lists the pod zone as the one place it may
frame.
The app derives the name to frame itself: dev-<signed-in login> under one of
the pod zones it is built with (client/src/pod.js), exactly as planPod names
the apex, and frames it only when that is one label under a pod zone. Nothing
about it is written into the pod’s record.
Switching
select moves the public name (the apex and the port names) and the
port-22 front door to one member. Nothing restarts; established ssh
sessions keep their connection; only the next ssh dev-alice lands on the
new member.
sequenceDiagram
actor A as alice
participant CN as member clankernet
participant CE as member clankerengineer
participant P as public name
Note over P: shows clankernet
A->>CN: factory select clankerengineer
CN-->>P: route + port 22 now point at clankerengineer
Note over P: shows clankerengineer — a few seconds, nothing restarted
A->>CE: ssh dev-alice (port 22)
Note over CN: still running, no public route<br/>ssh -p 2202 reaches it
Two ways, one result:
- From inside any member:
factory select <name>(factory selectalone lists the members and the current selection). No credential leaves the host; a member can only pick from its own pod. - From the workflow:
action: selectwithentry: owner/name. The run then checks that the public name answers 200 on the member’shealthpath and that port 22 reaches it.
The member that is not selected keeps running, and its public PORT names keep answering (a public port label belongs to one member of the pod, so nothing contends for it); only the apex — and the path-routed ports on it — moves with the selection. DNS never changes on a switch: every member’s public names are published once, at provision.
Keeping a host converged: fleet-sync
fleet-sync (the daily job from sync.schedule, or on demand) converges
every pod on the host to the fleet file. For each pod, each repository
in the fleet is compared with what the pod holds:
flowchart LR
r["A repository in the fleet…"]
r --> u["unchanged, or only its sha moved"] --> ku["member keeps running<br/>same container, same session"]
r --> c["its dev-container file changed,<br/>or a new image was published"] --> rc["member keeps running, reported <b>stale</b><br/>the engineer's own provision applies it"]
r --> a["added"] --> j["joins every existing pod<br/>nobody re-dispatches provision"]
r --> d["removed or disabled"] --> l["member leaves every pod<br/>storage kept, its public names pruned"]
r --> n["no longer allowed for this person"] --> o["left out of their pod<br/>never torn down"]
Three rules protect people. fleet-sync never restarts a running
container — nobody consented to that: a member whose dev-container file
changed, or whose image the run pulled past (a new release), keeps running
and its pod is reported stale, naming the container and why; the
engineer applies it with their own provision, when it suits them. A
moved commit alone is never a change — the clone lives on the member’s
storage and sync fast-forwards it. A pod whose owner cannot be
checked (the lookup failed rather than answered “no”) is reported as
failed and nothing runs on it. And someone allowed for nothing is
skipped and reported, never torn down — offboarding is an admin’s
explicit teardown with user:
(Quickstart). The run summary lists
every pod with its members, their shas and ports, the selection and the
outcome.
An engineer’s own sync is the same idea for one pod: every member
fast-forwards its clone and re-runs its updateContentCommand, every
workspace pulls — fast-forward only, never a reset, so a commit you made in
a workspace clone is never lost.
Refusals, in one place
| Condition | Result |
|---|---|
version other than 2 | error |
host neither a reference form nor a valid inline spec | error |
repos empty, or the same repository twice (either spelling) | error |
url not a repository URL or owner/name | error |
ref with whitespace, .., a leading -//, or a .lock suffix | error |
devcontainer absolute, climbing (..), or not .json | error |
access missing, or access.github missing / with neither org nor users | error |
access.tailscale.hostname with an unknown placeholder or a dot | error |
sync.schedule not five cron fields | error |
| any unknown key at any level | error |
| a repository with no dev-container file at its ref (online) | error |
a ref that cannot be resolved (online) | error |
--offline | warning — nothing fetched; a repo:/stack: host also skipped |
devcontainer: false or pod.sshPort without a top-level pod: | error — a workspace or a port means nothing without a pod |
pod.hostnames.* without {user}, with {repo}, or not ending in .{zone}; port without {port} | error |
pod.tailscale.hostname without {user}, or with a dot | error |
pod.default not listed, or naming a workspace entry | error |
| two container entries on one ssh port (a pin colliding with a positional default counts) | error |
pod.sshPort = 22 (the front door) | error |
(online) pod.sshPort = 5432, 6379, 8079 or 9000 (the built-in services) | error — one database, one cache, one object store per pod |
in a pod fleet, per-entry access.tailscale | warning — ignored; the pod has one address |
(online) services.postgres, services.redis or services.s3 in two members | error — one database, one cache, one object store per pod |
| (online) a port number declared by two members, or equal to a member’s ssh port | error — every declared port is a port of the whole pod |
| (online) a public port name declared by two members | error — its hostname routes for every member at once |
(online) a member’s name reserved (net, redis, redis-rest, s3, s3-init, routes, egress), all digits, or shared with another member or workspace | error — customizations.clankernet.repo.name renames it |
(online) a workspace named like a file of the shared root (agents.md, .claude, …) | error — it would be mounted over that file |
(online) pod.siblings on and two members declaring different remoteUsers | error — one user is what makes a shared tree safe |
| (online) a member’s name equal to any member’s public port name | error — one label space in the pod’s routes |
the coordination (flagged) entry is a container with pod.siblings: false or its own pod.sibling: false | error — the pod coordinates around that checkout, so every member must see it |
(online) a member’s file declaring hostnames different from the pod’s | error — the pod’s templates name every member |
(online) a member declaring the standard docker-in-docker feature | warning — unavailable in a pod; the member runs without it |
(online) the members’ summed hostRequirements over the host | warning |