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.yml in the repository (a generic host with its gateway: block; every account identifier in it is a placeholder).

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

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 | 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.modulepath—Required for custom: your provider module, relative to the host spec’s folder
custom.sshUserstringrootThe login your image gives: root, or a user with passwordless sudo
custom.envNAME: secret:NAME map{}Environment variables your provider package reads its credential from, each from the host’s secret store
custom.settingsmap{}Anything your module needs (a size, a region). Handed to it untouched; never a secret
aws.regionregion name—Required for aws: where the instance runs
aws.instanceTypetype namet3.large
aws.architectureamd64 | arm64amd64The instance type’s processor architecture; it picks the default image
aws.diskGb20–200080The root volume (encrypted). Pod volumes live on it
aws.amiami-… | ssm:/pathUbuntu 24.04, looked upAn image id, or a parameter that holds one. A running host is never replaced for a newer image
aws.credentialsFromtwo secret:NAME— (the environment’s own)accessKeyId and secretAccessKey, when the runner has no role or profile to use
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— (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.moshbooleanfalseOpen the mosh port range on the public address. Mosh over the tailnet needs no rule
firewall.webboolean— (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

KeyTypeDefaultMeaning
providercloudflare-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
accountIdstring—Your DNS and edge provider’s account id (non-secret). Required for the tunnel
tokenFromsecret:NAMEsecret:cloudflareApiKeyTunnel permissions
tls.emailemail—direct only, required: the contact for the certificate authority’s account
tls.caServerURLthe authority’s production directorydirect 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

KeyTypeDefaultMeaning
providercloudflare, route53, manual, customcloudflareWhere 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.hostedZoneIdzone id—Required for route53: the hosted zone the wildcard record is written in
custom.module, custom.env, custom.settingsas under compute—provider: custom: your module writes the records
custom.recordswildcard | per-namewildcardWhether your module covers every name with a wildcard record, or declares one record per developer name
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. Ignored by manual
recordsapi | programapicloudflare 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

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
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

KeyTypeMeaning
providerreplicator | factory-secretsWhere 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. Needs state.provider: replicator. Seed with replicator secrets set (one name per call, the value on stdin).
  • 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
providerreplicator—The replicator’s own state, in a bucket in your account
replicator.accountId32 hex digits—The account that holds the state bucket (an identifier, not a credential). Required for cloudflare, refused for local
replicator.applowercase, digits, -replicator-hostThe 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.backendcloudflare, aws, localcloudflareWhere 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.regionregion 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.

KeyTypeDefaultMeaning
hosthostname—Required. What the server answers. Must be under one of dns.zones; its record is protected
imageimage repositorythe distribution’s factory-gatewayThe gateway image repository, no tag (registry/namespace/name). Set it to run a mirror or a fork of the image
imageTagtag—Required. The exact server release to run. Rollback = converge with the previous tag
appOriginscheme://host—Deprecated and ignored since 2026-11 (the app frames no pod); accepted so an existing spec validates.
app.servehost or externalexternalWho 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)
githubOrglogin—An extra gate: only active members of this organisation may sign in. The repository gate always applies
admitany, all or { entries, owners }anyWho 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
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: replicator secrets rotate GATEWAY_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
connectKeyFromsecret:NAMEsecret:gatewayConnectKeyOptional. 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.host does. The engine writes no record for it; one wildcard record for the zone covers both.
  • imageTag must 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, and gateway.host must 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.

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.

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):

FieldWhat it is
namesThe 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
cloudNamesNames for the cloud side, from namePrefix
publicKeyThe public half of the key the engine logs in with
firstBootThe first-boot script to pass as user data. It holds no secret
firewallWhat must be reachable from outside, from the spec’s compute.firewall lines: open exactly these
settingsYour custom.settings, untouched
secret(n)A value your custom.env names, by its store name
hostid, 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

ConditionWhy
access.provider: ssh-direct without compute.firewall.ssh: trueOpening port 22 to the world must be a line the owner wrote
ingress: cloudflare-tunnel without accountIdThe tunnel is created in an account
provider: custom without its custom block, or providers on a host with no custom providerA module needs a path; a package list needs a module
compute.provider: aws without an aws block, or dns.provider: route53 without route53.hostedZoneIdThe provider has nowhere to create anything
dns.provider: route53 without ingress.provider: directA wildcard address record cannot point at a tunnel
state.replicator.region different from compute.aws.regionOne run carries one region
ingress.provider: direct without compute.firewall.web: true on a host the engine createsOpening 80 and 443 must be a line the owner wrote
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
gateway.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

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"]
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 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.