Host specs — hosts/<name>.yml
A host spec says where a factory runs: which accounts, which tokens (by name), where the infrastructure state lives. It is owned by the host owner — not necessarily the team whose repository runs on it.
It is non-secret by construction. Every credential is a secret:NAME
reference into the secrets store the spec names; no value ever appears in
the file. That is what lets a host spec sit in a public repository, travel
through a repo: or stack: reference, and be pasted into a support ticket.
A literal token anywhere a name is expected is refused.
flowchart TB
spec["<b>hosts/acme.yml</b><br/>names only — secret:NAME"]
spec --> compute["<b>compute</b><br/>a server the factory creates,<br/>or a VM you already run"]
spec --> ingress["<b>ingress + dns</b><br/>a private tunnel to the host<br/>and the public names under your zone"]
spec --> access["<b>access</b><br/>your private network,<br/>or ssh on the public address"]
spec --> secrets["<b>secrets + state</b><br/>where names resolve,<br/>where infrastructure state lives"]
spec --> res["<b>resources</b> (optional)<br/>a bucket, a cache, the coordination server"]
- What you need to own before you can write one: Bring your own cloud.
- Example:
examples/hosts/dev-host.ymlin the repository (the clankerlabs host, with itsclankbase:block).
How a fleet finds a host
host: in the fleet file:
| Form | Use when |
|---|---|
./hosts/<name>.yml | your repository carries the spec (you own the host, or vendored the owner’s copy) |
repo:<owner>/<repo>@<ref>:<path>.yml | the owner keeps it in their repository; pin @<ref> to a tag. A private repository needs the workflow’s checkout_app_from |
stack:<org>/<project>/<stack> | you only know the host exists; the engine reads the host’s own published, non-secret description and nothing else from it |
Keys
id, namePrefix
| Key | Type | Meaning |
|---|---|---|
id | slug | Names the infrastructure state (<org>/<project>/<id>), the DNS ownership tag managed-by=factory/<id>, and the host’s name on your private network |
namePrefix | slug | Prefix of every cloud resource name: {namePrefix}-key, {namePrefix}-firewall, and {namePrefix} itself for the server and the tunnel |
A prefix carries names, never state. Every host is built fresh by
host-up; nothing is imported. A state stack that already holds resources
the factory did not create is refused before any action runs — the one that
holds someone else’s live resources is exactly the one a preview would
replace and a host-down would destroy.
compute
| Key | Type | Default | Meaning |
|---|---|---|---|
provider | hetzner | existing-host | — | Create the server in your cloud provider’s project, or converge a VM you already run |
hetzner.serverType | slug | cpx32 | |
hetzner.location | slug | hel1 | fsn1 is refused (disabled for the account) |
hetzner.image | string | ubuntu-24.04 | The host setup targets Ubuntu 24.04 |
hetzner.tokenFrom | secret:NAME | secret:hetznerToken | |
existingHost.address | string | — | Required for existing-host |
existingHost.sshUser | string | root | |
sshKeyFrom.privateKey | secret:NAME | secret:hostSshPrivateKey | The host’s root key, in the secrets store. Minted by the first host-up when both names are unset; half a pair is refused |
sshKeyFrom.publicKey | secret:NAME | secret:hostSshPublicKey | For existing-host the pair is yours to set (the factory mints only for hosts it creates) |
firewall.ssh | boolean | — | Open port 22 publicly. Must be explicitly true for access.provider: ssh-direct |
firewall.mosh | boolean | true | Open the mosh port range |
ingress
| Key | Type | Default | Meaning |
|---|---|---|---|
provider | cloudflare-tunnel | none | — | A private tunnel from your edge to the host’s reverse proxy, with TLS at your edge — or no public ingress |
accountId | string | — | Your DNS and edge provider’s account id (non-secret). Required for the tunnel |
tokenFrom | secret:NAME | secret:cloudflareApiKey | Tunnel permissions |
ingress: none is legal (a host reachable over your private network only);
a repository with public: ports on it validates with a warning.
dns
| Key | Type | Default | Meaning |
|---|---|---|---|
provider | cloudflare | cloudflare | The DNS provider; one is supported |
zones | zone[] (≥1) | — | Zones the factory may write into. hostnames.zone must be one of them; the first is the default |
tokenFrom | secret:NAME | secret:cloudflareApiKey | DNS edit on the listed zones |
Only records carrying managed-by=factory/<id> are ever created, updated
or deleted — DNS ownership.
access
| Key | Type | Default | Meaning |
|---|---|---|---|
provider | tailscale | ssh-direct | — | How engineers reach their pods: over your private network, or ssh on the host’s public address |
tailscale.tag | tag:… | — | The access tag every pod joins with; required for tailscale |
tailscale.oauthFrom.clientId | secret:NAME | secret:tailscaleOAuthClientId | Join keys are minted on your runner from this, one per pod, and never stored on the host |
tailscale.oauthFrom.clientSecret | secret:NAME | secret:tailscaleOAuthClientSecret |
secrets
| Key | Type | Meaning |
|---|---|---|
provider | pulumi-config | pulumi-esc | factory-secrets | Where every secret:NAME resolves |
pulumiEsc.environment | project/environment | Required for pulumi-esc. CI needs only PULUMI_ACCESS_TOKEN to read it |
pulumi-config— the reference deployment: values encrypted into the committed stack file, decrypted by the stack’s own key. On the hosted state servicePULUMI_ACCESS_TOKENis the only credential CI needs; on ans3backend your passphrase or KMS key decrypts. Seed withfactory secrets set.pulumi-esc— the state service’s hosted secrets store, referenced from a stack file that holds nothing. Needsstate.provider: pulumi-cloud.factory-secrets— a JSONname → valuemap handed to the workflow as ONE secret (FACTORY_SECRETS), for callers that cannot pass many names.
state
| Key | Type | Default | Meaning |
|---|---|---|---|
provider | pulumi-cloud | s3 | — | The hosted state service, or any S3-compatible bucket you hold |
pulumiCloud.org | string | — | Your organisation on the state service |
pulumiCloud.project | string | clanker-infra | |
s3.url | s3://bucket[?endpoint=…] | — | Any S3-compatible object storage works via ?endpoint= |
s3.secretsProvider | passphrase | awskms://… | — | passphrase reads FACTORY_PASSPHRASE at run time |
State necessarily holds host secrets (the tunnel’s credential, the key resource). The control is who holds the backend and the decryption key (what is in your state).
image.pull.tokenFrom
A registry read token, by name, when the images your host pulls are private. A host that pulls only public images leaves it unset.
clankbase (optional)
The coordination server — where engineers and their agents sign in, claim
repositories, publish heads and hand off work — as a service of the host. It
runs only when this block is present; removing the block stops it on the
next host-converge and keeps its data volume.
| Key | Type | Default | Meaning |
|---|---|---|---|
host | hostname | — | Required. What the server answers. Must be under one of dns.zones; its record is protected |
imageTag | tag | — | Required. The exact server release to run. Rollback = converge with the previous tag |
appOrigin | scheme://host | — | The browser app allowed to frame pod previews: every pod route answers with it as frame-ancestors. Unset = no frame header |
githubOrg | login | — | An extra gate: only active members of this organisation may sign in. The repository gate always applies |
tokenTtlDays | int (1-365) | 30 | Lifetime of a person’s token |
accessRecheckSeconds | int (10-86400) | 600 | How often a signed-in person’s push access is asked again; losing push access loses sign-in within this window |
brokerTokenTtlDays | int (1-365) | 7 | Lifetime of the token a pod’s agents get from the broker |
appFrom | object | {} | The GitHub App that is the server’s ONE GitHub credential: the sign-in gate and every installation token the pod broker hands out. appId (default secret:gatewayAppId) and privateKey (default secret:gatewayAppPrivateKey) |
brokerSecretFrom | secret:NAME | secret:gatewayBridgeSecret | Required. The key every pod’s broker capability is an HMAC under; rotating it invalidates every pod’s capability until its pod is re-provisioned. Mintable: factory secrets rotate CLANKBASE_BROKER_SECRET --host <id> --yes |
githubClientSecretFrom | secret:NAME | secret:gatewayGithubClientSecret | Optional. The OAuth app’s client secret (a 0400 credential file): turns on the browser app’s “Continue with GitHub” redirect sign-in. Unset, the device flow and a token on stdin still work |
githubClientIdFrom | secret:NAME | secret:gatewayGithubClientId | Optional. The sign-in app the device-flow login uses (Device Flow enabled; not a secret). Unset, people sign in with a token on stdin |
Every setting is declared ONCE, in the table
packages/engine/src/schema/clankbase-config.ts: its host-spec field, default,
whether it is required, and whether it is a credential. The spec fields above,
the secret names factory plan asks for, the compose file and the converge’s
files are all derived from it.
Who may sign in is not a key of this block: a person is admitted when they can
push a repository of the fleet file (any enabled entry of repos[],
container or workspace), asked of GitHub with the App — never organisation
membership. Every one of those repositories is tracked in the pod’s clankbase
workspace, which is named after the pod.default entry (else the first
enabled one): its member name, the same as its /workspace/<name> checkout.
host up and host converge read the fleet with --fleet (default
./factory.yml) whenever this block is present, offline: only the repository
list and pod.default are used, and a fleet with no enabled entry is refused.
A host without this block needs no fleet.
The secret values are resolved on your runner and delivered over ssh stdin at
each converge as files, never as container environment: each credential is
a 0400 file (owned by the container’s user, in a 0700 directory, written
under umask 077) that compose mounts under /run/secrets, and the container’s
environment only names the file, so docker inspect shows no value. They are
never in a container definition, never in the host’s first-boot data, never an
output, never a command-line argument. Every line of remote output the engine
streams passes a masker holding each resolved secret value first (a value of
at least 8 characters, per line). Operating it: docs/operator/clankbase.md.
resources (optional)
Shared backends the host PROVIDES to its pods. A repository consumes one by
name and maps its exports to env values (customizations.clankernet.resources);
it cannot create one.
| Key | Type | Default | Meaning |
|---|---|---|---|
r2[].name | slug | — | An object-storage bucket, named exactly as repositories reference it. Created with the host, protected |
r2[].location | WNAM | ENAM | WEUR | EEUR | APAC | OC | WNAM | Immutable after create |
r2[].credentialsFrom.{accessKeyId,secretAccessKey} | secret:NAME | secret:R2_ACCESS_KEY_ID / secret:R2_SECRET_ACCESS_KEY | The S3 token pair, minted once in your storage provider’s dashboard |
upstash[].name | slug | — | A cache database, under the name repositories reference. Then EXACTLY ONE of the two forms below |
upstash[].existing.restUrlFrom | secret:NAME | https://… | — | Existing form — a database you already run. The host creates nothing and never holds a management key; resources destroy refuses it (“not ours to destroy”) |
upstash[].existing.restTokenFrom | secret:NAME | — | The REST token — always a name |
upstash[].databaseName | slug | {namePrefix}-{name} | Create form — the factory creates and owns the database, protected. Immutable after create |
upstash[].primaryRegion | string | us-east-1 | |
upstash[].credentialsFrom.{email,apiKey} | secret:NAME | secret:upstashEmail / secret:upstashApiKey | The cache provider’s management credential that creates the database. validate warns on every create-form entry: whether the store holds the key is not knowable offline |
An entry that mixes the two forms is refused.
Refusals
| Condition | Why |
|---|---|
access.provider: ssh-direct without compute.firewall.ssh: true | Opening port 22 to the world must be a line the owner wrote |
secrets.provider: pulumi-esc with state.provider: s3 | The hosted secrets store belongs to the hosted state service |
ingress: cloudflare-tunnel without accountId | The tunnel is created in an account |
compute.hetzner.location: fsn1 | Disabled for the account |
existing-host with a hetzner block, or tailscale without its block | A spec that thinks it is two things |
clankbase.host outside dns.zones | A record that could never be created |
resources.upstash[] mixing existing with the create-form keys | One entry is one database, either yours or the factory’s |
a literal token anywhere a secret:NAME is expected | That is a committed secret |
| any unknown key | Objects are closed; a typo is an error, not an ignored line |
Which secrets a host needs
factory plan prints the list with a reason per name, and the workflow
checks it before touching a cloud. A host with the hetzner compute
provider, a tunnel and private-network access needs hetznerToken,
hostSshPrivateKey and hostSshPublicKey (minted by the first host-up),
cloudflareApiKey, tailscaleOAuthClientId, tailscaleOAuthClientSecret;
plus image.pull, the coordination server’s names when clankbase: is present, each
repository’s env.git.tokenFrom, the GitHub App pair (gatewayAppId, gatewayAppPrivateKey, or the names in clankbase.appFrom) when any
access.github.org is set, and every name in env.secrets. An
existing-host + ssh-direct host needs neither the cloud-provider nor the
private-network credentials.
What each host action does
flowchart LR
up["<b>host-up</b><br/>mint the root key if missing<br/>create server, firewall, tunnel, records<br/>set the host up over ssh"] --> conv["<b>host-converge</b><br/>re-apply the host setup<br/>(proxy, tunnel, private network, coordination server)<br/>safe to re-run"]
conv --> down["<b>host-down</b><br/>remove the host<br/>refuses while it still provides<br/>a bucket or a created database"]
down -. only path .-> rd["<b>resources destroy --name</b><br/>empty and delete one backend"]
| Action | What happens |
|---|---|
plan host <id> | A preview of what host-up would change, with every needed secret resolved first. A preview that would delete a DNS record fails closed unless you say why (ALLOW_DNS_DELETION="<reason>") |
host-up | Mints the host’s root key on your runner if the two names are unset and stores it; creates the host and its records; then sets the host up over ssh — the reverse proxy, the tunnel, the private-network join — and prunes stale private-network entries by exact name |
host-converge | The setup half only, from what exists. Re-run freely; the coordination server rolls out here |
host-down | Refuses while the host provides a bucket or a created cache (they are protected). An existing database is not the factory’s and does not block it |
resources destroy --name <n> | The ONLY path that deletes a shared backend: unprotect, empty, remove. Then take it out of the spec. Refuses an existing database |
secrets set <NAME> | Value on stdin, into the store the spec names |
secrets rotate <NAME> --host <id> | Mints a fresh random 32-byte hex for GF_AUTH_SECRET, GF_INTERNAL_SECRET or GF_HOOK_SECRET, writes it through the provider (value on stdin, never printed) and names the converge that publishes it. GF_AUTH_SECRET signs everyone out and needs --yes. A secret issued elsewhere (the GitHub App key, the OAuth pair, the bucket token) is refused with where to rotate it |
The host publishes exactly four outputs — serverIp, tunnelId,
tailscaleHostname, hostSpec — none secret. The root key lives in the
secrets store, the tunnel credential is fetched on your runner, and the
private network’s client never leaves it; nothing secret is written into
the host’s first-boot data.