Bring your own cloud
CLANKER.NET runs in your accounts. What runs there is the factory — 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 product of ours (clankerengineer is a separate, hosted sister product and is not installed in a customer’s cloud). 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 page is the 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 product 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 factory 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 factory creates a server (cpx32 by default, Ubuntu 24.04), a firewall 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 join your private network; engineers reach them there. Join keys are minted on your runner per pod | access.provider: tailscale |
or nothing — ssh-direct | ssh on the host’s public address. You write compute.firewall.ssh: true yourself | access.provider: ssh-direct |
| Either an organisation on the hosted state service | State under your org; secrets encrypted in the committed stack file (the reference deployment) or in the service’s hosted secrets store; your CI needs only PULUMI_ACCESS_TOKEN | state.provider: pulumi-cloud, secrets.provider: pulumi-config or pulumi-esc |
| or an S3-compatible bucket + access keys + a passphrase or KMS key | Self-managed state; three caller secrets (FACTORY_PASSPHRASE, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) | state.provider: s3, secrets.provider: pulumi-config or factory-secrets |
| 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 coordination repository + (optional) a sign-in app with device flow | Only if you run a coordination server (clankbase: in the host spec). Without the sign-in app, people sign in with a GitHub token they already have | clankbase.appFrom, clankbase.githubClientIdFrom |
| 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 factory 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 backend’s token (or your S3 keys), reading your stack’s config or your 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 |
|---|---|---|
pulumi-cloud | members of your organisation on the state service, with stack access | the state service’s operator (service-managed) — you accept them as a key holder |
s3 + passphrase | whoever can read the bucket | whoever knows FACTORY_PASSPHRASE |
s3 + awskms://… | whoever can read the bucket | your KMS key policy |
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. Customer data that never crosses in either direction: repository contents, env values, database rows, private-network device lists, DNS records, logs.
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 | ● | stack config / 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 the customer; 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 factory neither applies nor checks it.
Limits
What the factory 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 |
s3 backends use static access keys; no OIDC | Scope the keys to the bucket; rotate them in your secrets store |
Secrets stores are pulumi-config, pulumi-esc, factory-secrets only — no cloud secrets-manager or vault integration | Mirror the named values into one of the three |
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 customer VM | 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 factory’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? Talk to us: [email protected].