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: 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/acme.ymlin the repository (a generic host with itsgateway:block; every account identifier in it is a placeholder).
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 |
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 | aws | existing-host | custom | — | Create the server in your cloud provider’s project or account, converge a VM you already run (any cloud), or run a provider of your own |
custom.module | path | — | Required for custom: your provider module, relative to the host spec’s folder |
custom.sshUser | string | root | The login your image gives: root, or a user with passwordless sudo |
custom.env | NAME: secret:NAME map | {} | Environment variables your provider package reads its credential from, each from the host’s secret store |
custom.settings | map | {} | Anything your module needs (a size, a region). Handed to it untouched; never a secret |
aws.region | region name | — | Required for aws: where the instance runs |
aws.instanceType | type name | t3.large | |
aws.architecture | amd64 | arm64 | amd64 | The instance type’s processor architecture; it picks the default image |
aws.diskGb | 20–2000 | 80 | The root volume (encrypted). Pod volumes live on it |
aws.ami | ami-… | ssm:/path | Ubuntu 24.04, looked up | An image id, or a parameter that holds one. A running host is never replaced for a newer image |
aws.credentialsFrom | two secret:NAME | — (the environment’s own) | accessKeyId and secretAccessKey, when the runner has no role or profile to use |
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 | — (closed) | Open port 22 publicly. Closed by default: the converge reaches the host over the tailnet. Must be explicitly true for access.provider: ssh-direct |
firewall.mosh | boolean | false | Open the mosh port range on the public address. Mosh over the tailnet needs no rule |
firewall.web | boolean | — (closed) | Open ports 80 and 443 publicly. Must be explicitly true for ingress.provider: direct on a host whose firewall the engine owns |
capacity | { cpus, memoryGb, diskGb } | — | What this host can hold, stated by you, for any provider. A repository’s hostRequirements are checked against it; without it the engine uses what it knows about the size the spec names, and says so when it knows nothing |
With provider: aws the engine creates a key pair, a firewall group built from
the same firewall lines, one instance and a static address (so the address
survives a stop and start), in the account’s default network. It logs in as the
image’s own user and becomes root with sudo; the instance’s metadata service
answers session tokens only. It uses the credentials your cloud tooling already
finds (a role, a profile, the environment), and stores none unless you name
them with credentialsFrom. The state bucket, when it is in the same cloud
account, must be in the same region.
An existing-host may log in as root or as any user with passwordless sudo.
ingress
| Key | Type | Default | Meaning |
|---|---|---|---|
provider | cloudflare-tunnel | direct | none | — | A private tunnel from your edge to the host’s reverse proxy, with TLS at your edge; the host serving the web itself (direct); 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 |
tls.email | — | direct only, required: the contact for the certificate authority’s account | |
tls.caServer | URL | the authority’s production directory | direct only: another authority’s directory, a staging or test one to try the setup |
ingress: none is legal (a host reachable over your private network only);
a repository with public: ports on it validates with a warning.
ingress: direct has the host terminate TLS itself: the proxy listens on 80 and
443, obtains one certificate per hostname from the authority over the HTTP
challenge on first use, keeps them in a host directory (so a restart or a
recreate keeps them) and redirects 80 to 443. It needs no cloud account and no
tunnel. Because the ports are public, you open them with an explicit line
(compute.firewall.web: true on a host whose firewall the engine owns; your own
firewall on an existing VM), the gateway’s sign-in check does not trust
forwarded headers, and the host’s names are plain address records at the
host’s own address (written by the DNS provider, or by you under
dns.provider: manual: one wildcard record covers them). Certificates are issued
per name, so an authority’s rate limits bound how many names one domain can
add in a week; point tls.caServer at a staging directory to rehearse.
dns
| Key | Type | Default | Meaning |
|---|---|---|---|
provider | cloudflare, route53, manual, custom | cloudflare | Where names are written: through an API per name, as one wildcard record the host keeps (route53), by you (manual), or by a provider of your own (custom) |
route53.hostedZoneId | zone id | — | Required for route53: the hosted zone the wildcard record is written in |
custom.module, custom.env, custom.settings | as under compute | — | provider: custom: your module writes the records |
custom.records | wildcard | per-name | wildcard | Whether your module covers every name with a wildcard record, or declares one record per developer name |
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. Ignored by manual |
records | api | program | api | cloudflare only: how each developer’s names are written. api: tagged writes from the runner. program: resources of that developer’s own small program, applied like the host’s |
Only records carrying managed-by=factory/<id> are ever created, updated
or deleted — DNS ownership.
With records: program each developer’s names are kept by a small program of
that developer’s own: two developers never wait on each other, a provision
never touches the host, and a name that is no longer held is removed by the
next run. A host moves to it one developer at a time: on a developer’s next
provision or sync, the records written the earlier way under their names are
removed and the program creates them. The name is missing for the seconds in
between.
With provider: manual the engine writes no record and holds no DNS
credential. You create the names yourself, at any DNS host (typically one
wildcard record per zone), and each provision or teardown logs which names
must resolve to what.
With provider: route53 the host itself keeps one wildcard record per zone,
pointing at its own address, in a hosted zone of your cloud account. Every name
the engine serves is one label under a zone, so the wildcard covers them all and
a provision or a teardown writes nothing. It needs ingress.provider: direct
(a wildcard address record can only point at a host that answers on its own
address), and the record is removed with the host.
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 | |
tailscale.registration | { attempts, delayMs } | { 6, 5000 } | How many times, and how far apart, provision checks that a pod’s device registered under its exact name (1–12 checks, up to 60 000 ms apart, the whole window at most 5 minutes) |
The host itself is reached the same way. Under tailscale, every host
action (host-up, host-converge, host-diagnostics, fleet-sync, and the
container verbs) ssh-es to the host’s tailnet address, never its public
one, so the cloud firewall carries no TCP/22 (compute.firewall.ssh is unset
and stays unset). On a runner the control-plane workflow installs a pinned
tailscale daemon; the engine mints an ephemeral, single-use key under the
host’s own tailscale.tag, joins as <host id>-runner-<random> for the length
of the job, and logs out at the end (a cancelled job’s node expires on its own).
On a machine already on the tailnet, yours included, the engine uses it as is.
Two things your tailnet must allow: the OAuth client may mint keys for the tag
(auth_keys write, the same it needs for the host), and the tag may reach
itself on port 22 (the default policy allows everything; a restrictive one
needs tag:X -> tag:X:22). When the tailnet path is not usable the engine says
why and tries the public address, which fails by name once the firewall is
closed. host-up on a host that is not on the tailnet yet, a new server in
particular, applies the firewall twice: 22 open for the one converge that
joins it, then closed. FACTORY_TAILSCALE names the CLI prefix (the workflow
sets sudo -n tailscale).
secrets
| Key | Type | Meaning |
|---|---|---|
provider | replicator | factory-secrets | Where every secret:NAME resolves |
replicator— the store: the host’s own encrypted store, kept beside its state in a bucket in your own cloud account, with nothing committed to any repository. Needsstate.provider: replicator. Seed withreplicator secrets set(one name per call, the value on stdin).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 | replicator | — | The replicator’s own state, in a bucket in your account |
replicator.accountId | 32 hex digits | — | The account that holds the state bucket (an identifier, not a credential). Required for cloudflare, refused for local |
replicator.app | lowercase, digits, - | replicator-host | The name the state is kept under (<app>/<host id>). Never change it after the first run: the secrets stored beside the state are keyed by it |
replicator.backend | cloudflare, aws, local | cloudflare | Where the state lives: a bucket in your edge account, a bucket in your cloud account, or this machine’s config folder (no account, no token) |
replicator.region | region name | — | backend: aws only, required: the region of the state bucket |
The one credential a runner needs is REPLICATOR_STATE_TOKEN: a token scoped to
the account that holds the bucket. Pass it as a secret of that name in your
repository’s workflow settings.
backend: aws keeps them in a bucket in your cloud account, opened with the credentials
your cloud tooling already finds (environment, profile or role); the engine reads and stores
none, and no edge account is involved. Scope that role to the state bucket.
backend: local keeps the state and the encrypted secrets in the config folder
of the user who runs the verbs ($XDG_CONFIG_HOME, else ~/.config). It needs
no account and no token, so replicator secrets set and secrets list work
offline. It lives on that one machine: back the folder up, and use the bucket
home when a second runner (a workflow, a teammate) must open the same host.
examples/hosts/vendor-free.yml is a complete host that names no vendor: your own VM,
ingress: direct, dns: manual, access: ssh-direct and a local state. CI plans it and
renders its converge with no vendor credential in the environment.
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.
image.netImage
The image each pod’s net service runs. Unset, it is this distribution’s base
image (the baseImage of distribution.json). Set it to a mirror or a fork of
that image when your registry is not the default one; it must be the engine’s
base image, because the pod’s front door, its network device and its select
loop live in it. A private registry also needs image.pull.
gateway
The coordination server — where engineers and their agents sign in, chat,
claim repositories, publish heads and hand off work — as a service of the host,
and the sign-in every public pod name is behind. It runs only when this
block is present; removing the block stops it on the next host-converge
and keeps its data volume. A pod with a public port needs it: without
it, validate and provision refuse every routed port that does not say
auth: none, because nothing would stand between that hostname and anyone
who resolves it. The sign-in itself is the web app at app.<zone>
(Quickstart, “4. The app”); provision warns when that does
not answer.
| Key | Type | Default | Meaning |
|---|---|---|---|
host | hostname | — | Required. What the server answers. Must be under one of dns.zones; its record is protected |
image | image repository | the distribution’s factory-gateway | The gateway image repository, no tag (registry/namespace/name). Set it to run a mirror or a fork of the image |
imageTag | tag | — | Required. The exact server release to run. Rollback = converge with the previous tag |
appOrigin | scheme://host | — | Deprecated and ignored since 2026-11 (the app frames no pod); accepted so an existing spec validates. |
app.serve | host or external | external | Who serves the web app people sign in to. external: it is published somewhere else. host: this host serves it itself, from the server image, at app.<zone> (see “The web app on the host” below) |
githubOrg | login | — | An extra gate: only active members of this organisation may sign in. The repository gate always applies |
admit | any, all or { entries, owners } | any | Who may sign in. any: push access to one repository of the fleet file. all: push access to every enabled repository. An object narrows the repositories that gate sign-in to the entries it names (owner/name) and to every entry whose owner is one of owners; access to any one of those admits. Naming no enabled repository is refused |
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: replicator secrets rotate GATEWAY_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 |
connectKeyFrom | secret:NAME | secret:gatewayConnectKey | Optional. The key every developer’s connected credential (an agents.http[].connect server, the dev-container file) is encrypted under at rest. Mintable: replicator secrets rotate GATEWAY_CONNECT_KEY --host <id> --yes. Unset, Connect is off |
Every setting is declared ONCE, in the table
packages/engine/src/schema/gateway-config.ts: its host-spec field, default,
whether it is required, and whether it is a credential. The spec fields above,
the secret names replicator plan asks for, the compose file and the converge’s
files are all derived from it.
Who may sign in is the admit key above: by default 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 gateway
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
./clanker.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.
admit is the host owner’s choice and nothing a repository can change. Put
admit: all on a host that holds repositories of different teams and a person
must have push access to every one of them to sign in. Put a list on a host
whose fleet is wider than the people it serves, for example
admit: { owners: [platform] } admits whoever can push a repository owned by
the platform team (the owner of an entry in the fleet file). The
pod’s coordination workspace is still the one pod.default names, even when the
list leaves that repository out of the gate.
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/gateway.md.
The web app on the host
By default (app.serve: external) the web app at app.<zone> is published
separately (Quickstart, “4. The app”) and the host only sends
people to it. With app.serve: host the host serves it itself and nothing else
is published:
gateway:
host: api.acme.example
imageTag: "<a release that carries the app>"
app:
serve: host
- The server image carries the built app. The same service answers
app.<zone>on a second port, and the app’s calls to the server never leave that process. - The host routes
app.<zone>to it the way it routes the server’s own name: the same entry points, and a certificate obtained the same way where the host issues its own. The sign-in rules are the app’s own and are the same either way: the session is a cookie no script can read. - The app’s name must resolve to the host the way
gateway.hostdoes. The engine writes no record for it; one wildcard record for the zone covers both. imageTagmust name a release that carries the app. A converge on an older one fails saying so, and routes nothing.- No container may claim
app.<zone>on such a host, andgateway.hostmust be a different name.
Switching back to external removes the route on the next converge.
resources (optional)
Shared backends the host PROVIDES to its pods. A repository consumes one by
name and maps its exports to env values (customizations.replicator.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.
A provider of your own
The engine ships a few clouds. Any other is a file in your own repository: a module that declares your server (and, if you want, your names) with any infrastructure provider package. The engine runs it inside its own program, keeps its state, shows it in every plan, and does everything after the server exists (logging in, configuring it, the containers, the sign-in) exactly as on every other cloud. Adding a cloud does not mean changing the engine.
# hosts/acme.yml
providers:
examplecloud: 4.41.0 # the package your module imports, at an exact version
compute:
provider: custom
custom:
module: ../infra/host-provider.ts # relative to this file
sshUser: root
env:
EXAMPLECLOUD_TOKEN: secret:exampleCloudToken # from the host's secret store
settings: { size: medium, region: north }
// infra/host-provider.ts
import * as cloud from "@pulumi/examplecloud";
import type { HostProvider } from "@clankernet/replicator";
export default {
compute: {
declare(ctx) {
const server = new cloud.Server(ctx.names.server, {
name: ctx.cloudNames.server,
size: String(ctx.settings.size),
sshKeys: [ctx.publicKey],
userData: ctx.firstBoot,
});
return { serverIp: server.ipv4Address };
},
},
} satisfies HostProvider;
What your module is given (ctx):
| Field | What it is |
|---|---|
names | The names to give your resources: server, firewall, sshKey, address, wildcardRecord, and extra("…") for anything else. The engine refuses a state holding a resource it does not recognise, so use these |
cloudNames | Names for the cloud side, from namePrefix |
publicKey | The public half of the key the engine logs in with |
firstBoot | The first-boot script to pass as user data. It holds no secret |
firewall | What must be reachable from outside, from the spec’s compute.firewall lines: open exactly these |
settings | Your custom.settings, untouched |
secret(n) | A value your custom.env names, by its store name |
host | id, namePrefix, zones |
It returns { serverIp }: the address the server answers on. If your cloud
gives a server a changing address, attach a static one and return that.
Names. With dns.provider: custom the same kind of module writes the
records. hostRecords(ctx, { serverIp }) declares records that live as long as
the host, typically one wildcard per zone, which covers every name. With
records: per-name, podRecords(ctx, { names, zone, target }) is called with
the names one developer holds now, in a small program of that developer’s own:
declare one record per name, named ctx.names.extra(name). A name that is no
longer passed is removed for you; two developers never wait on each other, and
a provision never touches the host.
What stays yours to get right. The module is your code, run with your
credentials: the engine does not sandbox it. providers lists every package it
imports at an exact version; a package the engine already carries may only be
listed at the engine’s own version. A complete module and its spec are in
examples/providers/host-provider.ts and examples/hosts/custom.yml; CI runs
that pair end to end against a mock cloud.
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 |
ingress: cloudflare-tunnel without accountId | The tunnel is created in an account |
provider: custom without its custom block, or providers on a host with no custom provider | A module needs a path; a package list needs a module |
compute.provider: aws without an aws block, or dns.provider: route53 without route53.hostedZoneId | The provider has nowhere to create anything |
dns.provider: route53 without ingress.provider: direct | A wildcard address record cannot point at a tunnel |
state.replicator.region different from compute.aws.region | One run carries one region |
ingress.provider: direct without compute.firewall.web: true on a host the engine creates | Opening 80 and 443 must be a line the owner wrote |
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 |
gateway.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
replicator 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 gateway: is present, each
repository’s env.git.tokenFrom, the GitHub App pair (gatewayAppId, gatewayAppPrivateKey, or the names in gateway.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. A host on the aws provider with a wildcard record and
ssh-direct access stores nothing but the keypair the first host-up mints:
it uses your cloud tooling’s own credentials.
replicator init host writes a spec for one of these shapes and prints exactly
the names it still needs (Starting a host).
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 GATEWAY_BROKER_SECRET or GATEWAY_CONNECT_KEY, writes it through the provider (value on stdin, never printed) and names the converge that publishes it. Rotating the broker key invalidates every pod’s broker capability; rotating the connect key makes every stored connection unreadable, so each needs --yes. A secret issued elsewhere (the GitHub App key, the OAuth pair) 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.