Bring your own cloud
CLANKER.NET runs in your accounts. What runs there is the replicator — a host, your engineers’ pods behind a reverse proxy, and the coordination server where engineers and their agents hand off work — for whatever repository you are building, never a project of ours (clankerengineer is a separate sister project with its own site). There is no hosted control plane, no vendor-held cloud credential, and nothing to revoke on our side because we were never granted anything.
This is Deployed mode, and this page is its trust model: the bill of materials you need before writing a host spec, what lives in your state, the shared-responsibility split, and the limits. A compliance program (SOC 2 / ISO / GDPR) is not carried here; it belongs to whichever project sells it.
Where everything lives
flowchart LR
subgraph ours["Ours — public, pinned by SHA"]
code["the engine · the reusable workflow · the base image"]
end
subgraph yours["Yours"]
ci["your repository's CI<br/>your token, your permissions"]
accounts["your cloud, DNS and edge,<br/>private network, state service"]
host["your host<br/>pods · reverse proxy · coordination server"]
end
code -- "checked out at the SHA you pinned" --> ci
ci -- "creates and converges" --> accounts
ci -- "over ssh" --> host
host x--x code
The arrow from us to you is source code you can read. There is no arrow back.
Bill of materials
What the host owner must already have. A prospect who cannot hold a DNS zone at the supported DNS and edge provider is not a replicator prospect.
| You need | Because | Host spec key |
|---|---|---|
| An account at the supported DNS and edge provider, with one zone in it | Mandatory: cloudflare-tunnel and cloudflare are the supported ingress and DNS providers. The free plan is enough; the provider’s own certificates cover one label under the zone | ingress, dns.zones |
| An API token at that provider: tunnel edit + zone DNS edit on that zone | Creates the private tunnel; writes the tagged records | ingress.tokenFrom, dns.tokenFrom |
| Either a project + API token at the supported cloud provider | The replicator creates a server (cpx32 by default, Ubuntu 24.04), a firewall with NO public TCP port, and an ssh key in it | compute.provider: hetzner |
| or an Ubuntu 24.04 VM you already run, with root ssh | Set up over ssh; outbound UDP/7844 (the tunnel) is the only network requirement | compute.provider: existing-host |
Either a private network + a client for it (scopes auth_keys write, devices write) and an access tag | Pods AND the host join your private network; engineers reach pods there, and the control plane reaches the host there (ephemeral runner keys). The tag must be allowed to reach itself on 22 | access.provider: tailscale |
or nothing — ssh-direct | ssh on the host’s public address: one public TCP port. You write compute.firewall.ssh: true yourself | access.provider: ssh-direct |
| A token at your DNS and edge account, which also opens the state bucket | The replicator keeps its state, and the secrets stored beside it, in a bucket in your account (the default for new hosts); your CI needs only that one token (REPLICATOR_STATE_TOKEN) | state.provider: replicator, secrets.provider: replicator |
| A repository access token | None: access.github.org is checked with the GitHub App (Organization “Members: read”), clones and fetches use the same App through the broker, and pushes use each developer’s own credential | gatewayAppId, gatewayAppPrivateKey |
| A GitHub App for the fleet’s repositories + a GitHub sign-in app | The sign-in every public pod name is behind: a pod with a public port needs the gateway: block (validate refuses it otherwise unless the port says auth: none). The sign-in app’s device flow is optional; without it people sign in with a GitHub token they already have | gateway.appFrom, gateway.githubClientIdFrom, gateway.githubClientSecretFrom |
A static-site project at your edge provider, serving app.<zone> | The web app the sign-in sends people to (quickstart, “4. The app”). Without it, a pod’s public names refuse everyone | the app project and its API_ORIGIN variable |
| An image-registry read token | Only if the images your host pulls are private | image.pull.tokenFrom |
| Either a cache database you already run (its REST url + token) | Only if your pods consume a cache. The existing form: the host declares nothing, creates nothing, never holds a management key | resources.upstash[].existing |
| or a management API key at the cache provider | The create form: the replicator creates and owns the database (protected; only resources destroy deletes it) | resources.upstash[].credentialsFrom |
| An object-storage API token pair for each bucket the host provides | Only if your pods consume a bucket. The bucket is created with the host; the token is minted once in the provider’s dashboard and named here | resources.r2[].credentialsFrom |
Everything above is referenced by name. The names resolve in your secrets store; the values never enter a file in either repository.
What we hold
Nothing.
- No cloud credential of yours. The reusable workflow runs in your repository’s CI, with your state token, reading your own secrets store. Our repository is a workflow definition and an engine; it executes under your permissions.
- No inbound path. Nothing on our side calls your host, your tunnel or your pods. Health probes run on your runner and report to your job summary.
- No telemetry. The engine emits no usage record to us; there is no crossing whose schema could carry one.
- No secret in an output. The host publishes exactly four values —
serverIp,tunnelId,tailscaleHostname,hostSpec— none secret.
Revocation is unilateral and total: delete the caller workflow, or stop pinning our SHA. Running hosts and pods are unaffected; we could not update them anyway. A vendor that cannot be locked out has an unfalsifiable claim; this one can be locked out by removing one file.
What we DO control: the code your runner executes. Pin the reusable workflow to a 40-character SHA (your dependency bot bumps it), never a floating tag, and read the diff. That is the one crossing, and it is reviewable in git.
What is in your state
Infrastructure state necessarily holds host secrets, because the resources are made of them: the tunnel’s credential, the ssh key resource (public half). The control is who holds the backend and the decryption key, not “no secrets in state”:
| Backend | Who can read state | Who holds the decryption key |
|---|---|---|
replicator | whoever holds the state token for your bucket | the same token: the state’s key sits beside it in the bucket, so treat the token as your root credential |
Not in state, by design:
- The host’s root private key — it lives in the secrets store; the first
host-upmints the pair on your runner and stores it there. No one hands a private key to anyone. - The coordination server’s secrets (the GitHub App’s private key, the broker key). They travel runner → host over ssh, one file each that only the server’s own user can read, per converge.
- Any
env.secretsvalue. Those flow runner → pod env file per provision. - Any private-network client. The runner exchanges it for a tagged, one-use join key; only the key reaches the host, over ssh, never in the host’s first-boot data (which any process on the box can read).
Per-pod DNS records are not state at all — they are tagged writes
(managed-by=factory/<host>), so many engineers provisioning at once never
contend for one stack. Only host records are state, and the coordination
server’s is protected.
Boundary contract
Every crossing between our repository and your accounts, with direction and payload:
| # | Crossing | Direction | Initiated by | Payload |
|---|---|---|---|---|
| 1 | Workflow + engine checkout | ours → your runner | your workflow_dispatch | Source at the SHA you pinned. Contains no credential |
| 2 | Base image pull | the image registry → your host | your runner | ghcr.io/clankerlabs/factory-base@sha256:…, signed with provenance + SBOM |
| 3 | Cloud API calls (compute, DNS and edge, private network, state backend) | your runner → your accounts | your runner | Resource declarations under your tokens |
| 4 | ssh setup | your runner → your host | your runner | The tunnel credential, the join key, pod definitions, the env files — the coordination server’s included |
| 5 | Job summary | your runner → your repository | your runner | Hostnames, onboarding card. Never a secret value |
There is no crossing from your accounts to ours. Data that never crosses in either direction: repository contents, env values, database rows, private-network device lists, DNS records, logs.
A pod may reach a hosted service of yours through its egress proxy with a
key that belongs to the pod’s developer (egress.proxy.secrets[].perDeveloper,
devcontainer.md): the key is stored per developer on the
host, swapped in at the proxy, and never read by the pod. Who may hold a pod
is decided here, by push access to the repositories in your fleet file; who
may use the hosted service is decided there, by its own membership. The two
are separate decisions on purpose, and neither side needs the other to exist.
Shared responsibility
| Control | Us | You | Notes |
|---|---|---|---|
| Engine code, reusable workflow | ● | Pinned by SHA in your caller; reviewable | |
| The base image and its provenance/SBOM | ● | You pin the tag | |
| Vulnerability disclosure → patched release | ● | We publish; you bump the pin | |
| Cloud account hardening, IAM, token scopes | ● | We name the minimum scopes; you issue the tokens | |
| Infrastructure state backend and its key custody | ● | Table above | |
| Secrets store contents and rotation | ● | the replicator’s store / your secrets store / FACTORY_SECRETS | |
| Host OS patching | ● | The host setup is safe to re-run; running it is yours to schedule | |
Who may provision (access.github, repository permissions) | ● | Authorisation = your repository’s workflow_dispatch permission, optionally tightened | |
Engineer ssh key source (access.ssh.keySource) | ● | github-keys default; sshid is a third-party authority you opt into | |
| Backups of pod storage | ● | teardown preserves storage; nothing copies it off-host | |
| Log retention, intrusion detection, incident response | ● | We cannot see inside your host. Support is consent-per-incident | |
| Subprocessors (your cloud, DNS and edge, private-network, state-service and repository accounts) | ● | All under your accounts and your terms | |
| Image-registry access while a package is private | ● | ● | We grant; you hold the pull token |
The uncomfortable row is intrusion detection. An appliance the vendor cannot observe is an appliance whose breach the vendor learns about from whoever runs it; the answer to “what do you do when I report one” is: ship a fix under the disclosure row, and nothing else, because nothing else is possible.
Keeping agents off your trunk
The host enforces no branch rule, so by default a pod’s agent may push to any branch its installation token may write, including the default one. To land agent work through review instead, make it your repository’s rule, not ours:
- A ruleset on the default branch only (
~DEFAULT_BRANCH) that requires a pull request and your checks. - Tell the agents, in the repository’s own
CLAUDE.md/AGENTS.md, to push a branch (sayagent/<task>) and open the pull request; the ruleset is what makes that binding, the text is what makes it the easy path.
This is a recipe over GitHub’s own controls; the replicator neither applies nor checks it.
Limits
What the replicator does not do. Each is a fact about the engine as it stands, stated so that a host owner can plan around it; none is a promise.
| Limit | What to do instead |
|---|---|
One compute provider plus existing-host; one ingress and DNS provider; one private-network provider plus ssh-direct | Hold accounts at the supported providers, or bring an Ubuntu VM you already run |
Secrets stores are replicator and factory-secrets only — no cloud secrets-manager or vault integration | Mirror the named values into one of the two |
Ingress is a private tunnel or none — no direct certificate ingress on the host | ingress.provider: none and front the host yourself |
| A pod’s git asks the coordination server for a short-lived GitHub App installation token on every operation, for a repository the pod’s person may write, and stores none; the host enforces no branch rule | Branch protection, fast-forward and who may push are your repository’s rulesets |
existing-host has no end-to-end test against a VM of your own | Try host-up against a throwaway VM first |
One coordination identity per pod member (<login>.<label>) beside the person’s own sign-in | Expected; every message names the agent that sent it |
A pod shares one network (the fleet file): every port a member declares is a port of the whole pod, so two repos that both bind 3000, or more than one repo with the built-in database or cache, cannot share a pod — refused loudly by validate, but refused | Declare every listener; move a colliding port in one repo’s file; keep one database-backed repo per pod, or give the second its own host |
No container-in-container inside a pod: the standard docker-in-docker feature is a warning there, and tools that need it do not run in a member | Run those on a single-container fleet (no pod:), where the feature works as before |
| Members of one engineer’s pod see each other’s ports and the pod’s shared ssh host keys — one trust domain (one human), no isolation between the repos they hold | Expected for a personal pod. Per-member isolation is the designed follow-up, a per-entry opt-in |
Members of one engineer’s pod share a filesystem when pod.siblings is on (the default): every member’s clone is mounted read-write in every member at /workspace/<name> — and a member’s env file sits in its clone, so a root session could read a sibling’s secrets if allowed to | The root’s shipped agent settings deny every member’s env file, .ssh and the replicator’s files; pod.siblings: false (or repos[].pod.sibling: false for one repo) keeps a tree inside its own member |
| The apex (the pod’s one public name) shows the selected member only; every member’s own port names answer regardless | Switch the apex with select; a member’s workers and published store are reachable on their port names at any time |
Private images and a private engine repository need a read credential (image.pull.tokenFrom, a GitHub App via checkout_app_from) | Name it in the host spec / caller; public packages need neither |
Want us to run it for you? That is Based mode.