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.yml in the repository (the clankerlabs host, with its clankbase: block).

How a fleet finds a host

host: in the fleet file:

FormUse when
./hosts/<name>.ymlyour repository carries the spec (you own the host, or vendored the owner’s copy)
repo:<owner>/<repo>@<ref>:<path>.ymlthe 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

KeyTypeMeaning
idslugNames the infrastructure state (<org>/<project>/<id>), the DNS ownership tag managed-by=factory/<id>, and the host’s name on your private network
namePrefixslugPrefix 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

KeyTypeDefaultMeaning
providerhetzner | existing-host—Create the server in your cloud provider’s project, or converge a VM you already run
hetzner.serverTypeslugcpx32
hetzner.locationslughel1fsn1 is refused (disabled for the account)
hetzner.imagestringubuntu-24.04The host setup targets Ubuntu 24.04
hetzner.tokenFromsecret:NAMEsecret:hetznerToken
existingHost.addressstring—Required for existing-host
existingHost.sshUserstringroot
sshKeyFrom.privateKeysecret:NAMEsecret:hostSshPrivateKeyThe 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.publicKeysecret:NAMEsecret:hostSshPublicKeyFor existing-host the pair is yours to set (the factory mints only for hosts it creates)
firewall.sshboolean—Open port 22 publicly. Must be explicitly true for access.provider: ssh-direct
firewall.moshbooleantrueOpen the mosh port range

ingress

KeyTypeDefaultMeaning
providercloudflare-tunnel | none—A private tunnel from your edge to the host’s reverse proxy, with TLS at your edge — or no public ingress
accountIdstring—Your DNS and edge provider’s account id (non-secret). Required for the tunnel
tokenFromsecret:NAMEsecret:cloudflareApiKeyTunnel 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

KeyTypeDefaultMeaning
providercloudflarecloudflareThe DNS provider; one is supported
zoneszone[] (≥1)—Zones the factory may write into. hostnames.zone must be one of them; the first is the default
tokenFromsecret:NAMEsecret:cloudflareApiKeyDNS edit on the listed zones

Only records carrying managed-by=factory/<id> are ever created, updated or deleted — DNS ownership.

access

KeyTypeDefaultMeaning
providertailscale | ssh-direct—How engineers reach their pods: over your private network, or ssh on the host’s public address
tailscale.tagtag:…—The access tag every pod joins with; required for tailscale
tailscale.oauthFrom.clientIdsecret:NAMEsecret:tailscaleOAuthClientIdJoin keys are minted on your runner from this, one per pod, and never stored on the host
tailscale.oauthFrom.clientSecretsecret:NAMEsecret:tailscaleOAuthClientSecret

secrets

KeyTypeMeaning
providerpulumi-config | pulumi-esc | factory-secretsWhere every secret:NAME resolves
pulumiEsc.environmentproject/environmentRequired 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 service PULUMI_ACCESS_TOKEN is the only credential CI needs; on an s3 backend your passphrase or KMS key decrypts. Seed with factory secrets set.
  • pulumi-esc — the state service’s hosted secrets store, referenced from a stack file that holds nothing. Needs state.provider: pulumi-cloud.
  • factory-secrets — a JSON name → value map handed to the workflow as ONE secret (FACTORY_SECRETS), for callers that cannot pass many names.

state

KeyTypeDefaultMeaning
providerpulumi-cloud | s3—The hosted state service, or any S3-compatible bucket you hold
pulumiCloud.orgstring—Your organisation on the state service
pulumiCloud.projectstringclanker-infra
s3.urls3://bucket[?endpoint=…]—Any S3-compatible object storage works via ?endpoint=
s3.secretsProviderpassphrase | 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.

KeyTypeDefaultMeaning
hosthostname—Required. What the server answers. Must be under one of dns.zones; its record is protected
imageTagtag—Required. The exact server release to run. Rollback = converge with the previous tag
appOriginscheme://host—The browser app allowed to frame pod previews: every pod route answers with it as frame-ancestors. Unset = no frame header
githubOrglogin—An extra gate: only active members of this organisation may sign in. The repository gate always applies
tokenTtlDaysint (1-365)30Lifetime of a person’s token
accessRecheckSecondsint (10-86400)600How often a signed-in person’s push access is asked again; losing push access loses sign-in within this window
brokerTokenTtlDaysint (1-365)7Lifetime of the token a pod’s agents get from the broker
appFromobject{}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)
brokerSecretFromsecret:NAMEsecret:gatewayBridgeSecretRequired. 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
githubClientSecretFromsecret:NAMEsecret:gatewayGithubClientSecretOptional. 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
githubClientIdFromsecret:NAMEsecret:gatewayGithubClientIdOptional. 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.

KeyTypeDefaultMeaning
r2[].nameslug—An object-storage bucket, named exactly as repositories reference it. Created with the host, protected
r2[].locationWNAM | ENAM | WEUR | EEUR | APAC | OCWNAMImmutable after create
r2[].credentialsFrom.{accessKeyId,secretAccessKey}secret:NAMEsecret:R2_ACCESS_KEY_ID / secret:R2_SECRET_ACCESS_KEYThe S3 token pair, minted once in your storage provider’s dashboard
upstash[].nameslug—A cache database, under the name repositories reference. Then EXACTLY ONE of the two forms below
upstash[].existing.restUrlFromsecret: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.restTokenFromsecret:NAME—The REST token — always a name
upstash[].databaseNameslug{namePrefix}-{name}Create form — the factory creates and owns the database, protected. Immutable after create
upstash[].primaryRegionstringus-east-1
upstash[].credentialsFrom.{email,apiKey}secret:NAMEsecret:upstashEmail / secret:upstashApiKeyThe 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

ConditionWhy
access.provider: ssh-direct without compute.firewall.ssh: trueOpening port 22 to the world must be a line the owner wrote
secrets.provider: pulumi-esc with state.provider: s3The hosted secrets store belongs to the hosted state service
ingress: cloudflare-tunnel without accountIdThe tunnel is created in an account
compute.hetzner.location: fsn1Disabled for the account
existing-host with a hetzner block, or tailscale without its blockA spec that thinks it is two things
clankbase.host outside dns.zonesA record that could never be created
resources.upstash[] mixing existing with the create-form keysOne entry is one database, either yours or the factory’s
a literal token anywhere a secret:NAME is expectedThat is a committed secret
any unknown keyObjects 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"]
ActionWhat 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-upMints 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-convergeThe setup half only, from what exists. Re-run freely; the coordination server rolls out here
host-downRefuses 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.