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 needBecauseHost spec key
An account at the supported DNS and edge provider, with one zone in itMandatory: 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 zoneingress, dns.zones
An API token at that provider: tunnel edit + zone DNS edit on that zoneCreates the private tunnel; writes the tagged recordsingress.tokenFrom, dns.tokenFrom
Either a project + API token at the supported cloud providerThe factory creates a server (cpx32 by default, Ubuntu 24.04), a firewall and an ssh key in itcompute.provider: hetzner
or an Ubuntu 24.04 VM you already run, with root sshSet up over ssh; outbound UDP/7844 (the tunnel) is the only network requirementcompute.provider: existing-host
Either a private network + a client for it (scopes auth_keys write, devices write) and an access tagPods join your private network; engineers reach them there. Join keys are minted on your runner per podaccess.provider: tailscale
or nothing — ssh-directssh on the host’s public address. You write compute.firewall.ssh: true yourselfaccess.provider: ssh-direct
Either an organisation on the hosted state serviceState 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_TOKENstate.provider: pulumi-cloud, secrets.provider: pulumi-config or pulumi-esc
or an S3-compatible bucket + access keys + a passphrase or KMS keySelf-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 tokenNone: 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 credentialgatewayAppId, gatewayAppPrivateKey
A GitHub App for the coordination repository + (optional) a sign-in app with device flowOnly 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 haveclankbase.appFrom, clankbase.githubClientIdFrom
An image-registry read tokenOnly if the images your host pulls are privateimage.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 keyresources.upstash[].existing
or a management API key at the cache providerThe 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 providesOnly 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 hereresources.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”:

BackendWho can read stateWho holds the decryption key
pulumi-cloudmembers of your organisation on the state service, with stack accessthe state service’s operator (service-managed) — you accept them as a key holder
s3 + passphrasewhoever can read the bucketwhoever knows FACTORY_PASSPHRASE
s3 + awskms://…whoever can read the bucketyour KMS key policy

Not in state, by design:

  • The host’s root private key — it lives in the secrets store; the first host-up mints 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.secrets value. 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:

#CrossingDirectionInitiated byPayload
1Workflow + engine checkoutours → your runneryour workflow_dispatchSource at the SHA you pinned. Contains no credential
2Base image pullthe image registry → your hostyour runnerghcr.io/clankerlabs/factory-base@sha256:…, signed with provenance + SBOM
3Cloud API calls (compute, DNS and edge, private network, state backend)your runner → your accountsyour runnerResource declarations under your tokens
4ssh setupyour runner → your hostyour runnerThe tunnel credential, the join key, pod definitions, the env files — the coordination server’s included
5Job summaryyour runner → your repositoryyour runnerHostnames, 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

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

  1. A ruleset on the default branch only (~DEFAULT_BRANCH) that requires a pull request and your checks.
  2. Tell the agents, in the repository’s own CLAUDE.md / AGENTS.md, to push a branch (say agent/<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.

LimitWhat to do instead
One compute provider plus existing-host; one ingress and DNS provider; one private-network provider plus ssh-directHold accounts at the supported providers, or bring an Ubuntu VM you already run
s3 backends use static access keys; no OIDCScope 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 integrationMirror the named values into one of the three
Ingress is a private tunnel or none — no direct certificate ingress on the hostingress.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 ruleBranch protection, fast-forward and who may push are your repository’s rulesets
existing-host has no end-to-end test against a customer VMTry host-up against a throwaway VM first
One coordination identity per pod member (<login>.<label>) beside the person’s own sign-inExpected; 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 refusedDeclare 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 memberRun 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 holdExpected 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 toThe 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 regardlessSwitch 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].