The self-host runbook: deploy your own downpipes from an empty Cloudflare account
This is the single authoritative runbook for standing downpipes up in your own Cloudflare account, from an empty account to a running engine and console you administer yourself. It collapses the whole first-deploy flow into one ordered procedure so you do not have to assemble it from several pages. Where a step needs deeper background, it links to the page that holds it.
It is for the operator who runs the deploy: an SRE or platform engineer with access to the Cloudflare account, working from a trusted machine. A deploy writes a Worker, so it is one of the two deliberate operator command-line actions in downpipes (the other is an upgrade). Everything after the deploy, attaching sources, setting a destination, running a restore, is done from the console with no terminal.
This runbook is dated
Last reconciled against the deploy scripts and engine source on 2026-07-03. The commands quoted here are the real npm scripts in the engine, console and control-plane repositories. If a script name has drifted, the source files listed in this page’s front matter are the truth.
Prerequisites
Confirm all of these before you deploy, rather than discovering a gap from a failed run later.
| Prerequisite | Why it is required |
|---|---|
| Cloudflare account on Workers Paid | The deploy itself fails without it: the engine sets a raised [limits] cpu_ms that an unpaid account refuses, and the free plan’s subrequest cap would break runs regardless. |
| Durable Objects enabled | The scheduler Durable Object owns the schedule, the run lock and the run-log index, and the seal Durable Object carries large runs between cron ticks. |
| R2 enabled | The default in-account destination, and a common source. An S3-compatible destination is the alternative. |
| Node.js 22 | The version the engine builds and tests under (NODE_VERSION: "22" in the engine continuous-integration config). Use it on the deploy machine too. |
wrangler authenticated against the account |
The engine pins wrangler 4. Confirm with npx wrangler whoami before you start. |
| A custom domain for the console | The console is the product’s only public surface in the shipped topology, and is custom-domain only. The workers.dev route is disabled by policy. |
Activate Workers Paid first
The engine’s wrangler.toml raises the per-invocation CPU ceiling, which an unpaid account rejects at deploy time. This is deliberate: it turns “is this account on the right plan” into a clear deploy-time failure instead of a subtle one during a large backup.
You provision and run these steps with a scoped, short-lived Cloudflare API token that you create now and revoke at the end. The exact permissions that token needs, and the proof that the running engine holds no token afterwards, are on the deploy token scopes page.
export CLOUDFLARE_API_TOKEN=<the token>
export CLOUDFLARE_ACCOUNT_ID=<your account id>
The token stays on this machine and is sent only to api.cloudflare.com. You will revoke it in the final step.
Step 1: Deploy the engine
Always deploy the engine with npm run deploy, never a bare wrangler deploy. The npm script is the guided scripts/deploy.sh, which front-loads everything provisionable so the console never has to ask for it.
Set the console origin
The engine scopes its CORS to your console’s origin. In the engine’s
wrangler.toml, setCONSOLE_ORIGINto your console’s custom domain so the browser is allowed to call the engine. Leave the source bindings out ofwrangler.toml: the engine declares no sources there on purpose, because you attach every source from the console later. The reasoning is on the deploy safety and bindings page.[vars] CONSOLE_ORIGIN = "https://console.example.com"Run npm run deploy
On a first deploy nothing is attached yet, so there are no live sources to preserve. Allow the empty binding reconcile explicitly:
cd engine DOWNPIPE_ALLOW_BINDING_RESET=1 npm run deployThe script does several things in order. It stamps the artefact provenance hash so the deployed engine self-reports the real SHA-384 of its bundle. It runs the binding reconcile (
scripts/sync-bindings.mjs), which reads the live worker’s bindings and deploys a superset, so a code deploy can never silently drop a source you attached from the console. Then it deploys, creating thedownpipe-engineWorker, the scheduler and per-run seal Durable Objects, and the reconciliation cron that drives new runs.Let the deploy generate your keys on this machine
On a fresh engine (when the signer secret is not yet set), the deploy generates the full default key set on your own machine, using
scripts/generate-keys.ts. Nothing is sent to the vendor at any point. Six secrets are installed to the engine by default:SIGNER_PRIVATE, the break-glassBREAK_GLASS_PUBLIC, an operational read-back pair,OPERATIONAL_PUBLICandOPERATIONAL_PRIVATE, that the engine holds both halves of, and a config-recipient pair,CONFIG_RECIPIENT_PUBLICandCONFIG_RECIPIENT_PRIVATE, which opens this engine’s own configuration export and nothing else. Strict break-glass-only custody is an opt-out on the console’s Keys screen rather than the default.Be precise about what that opt-out removes, because it is easy to over-read in both directions. It deletes the two
OPERATIONAL_*secrets, so the engine then holds no key that can read an archive. It does not leave the engine holding no private key at all: the config-recipient pair is generated in either posture and deliberately survives the switch, which is what lets a break-glass-only engine still heal its own configuration after a Durable Object wipe. Do not prune the config-recipient secrets as leftovers if you auditwrangler secret listagainst an older runbook; removing them is what takes that self-healing away.Be precise about what that key buys, because it is less than it used to be. Verification at seal and the hourly canary do NOT need it: the seal path still holds the run’s own per-run key while it finalises, so a break-glass-only engine reaches the same keyed verification tier and the canary runs with no skipped checks. What the operational key does buy is the UNATTENDED work: scheduled restore tests, scheduled drills, and in-account retention pruning, which need a key at a moment when nobody is present to supply one. An in-console restore works in either posture; without the operational key you supply the break-glass key to the browser for that restore, and it is wiped when the restore finishes. In break-glass-only, the scheduled pass defers instead of pruning silently; run this downpipe’s prune on demand from the console’s break-glass prune panel instead, supplying your break-glass key in the browser (or offline with
downpipe prune, if you prefer no console at all). The things you must keep regardless are written to a local./recovery-kit/folder:recovery-kit/identity.key the break-glass PRIVATE key: the only thing that can decrypt an archive recovery-kit/recovery-sheet.txt a printable sheet: public fingerprints and custody notes recovery-kit/recipient.pub the break-glass PUBLIC key, safe to keep anywhere; the engine has it recovery-kit/signer.pub the signer PUBLIC key: keep it, the reader refuses to run without itTwo of those four are must-keeps and they are must-keeps for different reasons.
identity.keyis the only thing that can decrypt an archive, andsigner.pubis the only thing that can verify one.downpipe verifyanddownpipe restoreboth take--signeras a required flag, and--allow-unverifieddowngrades a bad signature rather than waiving the flag, so it is not a way out. No copy ofsigner.pubsurvives in the archive or on the printed sheet, which carries its fingerprint and not the key.The engine never sees
identity.key. There is no upload path for it anywhere, and there is no server-side copy. The conceptual grounding is in the key ceremony and recovery kit.Move the recovery kit offline
This is the one irreversible custody step, so do it deliberately. Move the
recovery-kit/folder to offline storage, an encrypted USB drive, a corporate password manager, or a printed sheet in a safe, and remove it from this machine. Move the whole folder rather than the key alone.identity.keyis the only thing that can decrypt your backups if the engine or the account is ever lost, andsigner.pubis what the reader checks their signatures against. Neither can be regenerated from the engine, anddownpipe restorerefuses to run without both. Consider splitting custody across more than one holder; the console’s Keys screen explains the M-of-N pattern.
Step 2: Claim the first Owner
The first Owner is claimed once and once only. The engine latches a flag the moment the first Owner exists, so neither path can mint a second Owner afterwards, even if the role table is later emptied. There are two ways to claim it, and they are both real.
Path A: the email link (preferred)
When BOOTSTRAP_OWNER_EMAIL is set, the deploy can email the first-Owner set-up link to that deploy-time-pinned address, never a client-typed one. The guided deploy prompts you for BOOTSTRAP_OWNER_EMAIL once when it is absent, so on a fresh deploy you will usually have set it already.
This is the attributable path. The set-up link is bound to the pinned address, so the person who claims the first Owner is the person who controls that mailbox. Outbound email needs Workers Paid plus a send-email binding and a verified sender domain onboarded in the dashboard; until that is wired the engine logs a clean no-op rather than sending, so confirm your sender domain is onboarded before you rely on this path.
Path B: the admin token (break-glass)
If you have not wired outbound email yet, or you want a no-mailbox path, set a one-time admin token and sign in with it.
npx wrangler secret put ADMIN_TOKEN
Use a long random value and store it in your secrets manager. The admin token is the lower-assurance break-glass path: anyone with the engine URL and the token can administer the engine, and a token caller is not attributable in the audit log. Open the console, sign in once with the token to claim the first Owner, then register your Owner passkey. When you complete passkey enrolment the console shows you a set of recovery codes, once. Save them offline immediately. The engine stores only salted hashes of them, so there is no way to see them again later.
Retire the admin token after bootstrap
The admin token is a bootstrap credential, not a standing one, and the console’s Security Centre nags you to retire it once a better path exists. Retire it once your Owner passkey works (or Cloudflare Access is wired) and your recovery codes are saved. The retire is immediate and needs no redeploy: the engine stops honouring the token bearer the instant you retire it, and it refuses to retire the token until a way back in exists, so disposing of it can never strand you. The full procedure is on the identity and access page.
After either path you can confirm the engine is ready. GET /admin/status reports ready: true once the signer is present, the break-glass public key is present, and a destination resolves. GET /admin/preflight then probes the live prerequisites affirmatively. Do not consider the deploy finished over a red required preflight item. Both are detailed on the first deploy page.
Step 3: Deploy the console
The console is a separate Worker on its own custom domain, with a service binding to the engine. Build the bundle, then deploy from the console repository:
cd console
npm run deploy
npm run deploy is the guided scripts/deploy.sh: it builds the single-page app with esbuild into public/app.js, runs a blocking preflight (typecheck, validate, lint) that stops the deploy on any failure, then runs wrangler deploy, which reads console/wrangler.toml, which carries the console’s custom-domain route and the ENGINE service binding, never a workers.dev address. Never deploy the console with a bare wrangler deploy: it skips that preflight, the same rule as the engine deploy above. A service binding is an in-runtime call that never traverses the public network, so gating the one console hostname gates the whole deployment. For why the console is the only public surface, see topology.
The first console-capable rollout on an existing install is this manual deploy, once
Since the 0.1.3 release the signed update channel can carry the console as a component alongside the engine, so console fixes apply from the console with no terminal. A console deployed before that feature contains none of the component-aware UI and cannot update itself, which means the first console-capable rollout on an existing install requires exactly one manual console redeploy: the npm run build and npx wrangler deploy above, run from the console package. Every console fix after that arrives through the signed channel. A fresh install deploying today’s console has channel-driven console updates from day one. The flow is on applying updates.
Step 4: Deploy the control-plane (only if you self-issue licences)
The control-plane is the vendor-side component that mints the fail-open assurance licence. Most self-hosters never deploy it: a stock engine with no licence runs as the community edition and a licence enforces nothing on backups or recovery. Deploy your own control-plane only if you want to self-issue licences for your own engines.
The control-plane holds one secret, the licence signer private key, resolved read-only from a Secrets Store binding in production. It is a 503 not-ready until that signer is set, because an unset signer would refuse every mint.
cd control-plane
npm run deploy
npm run deploy runs the same kind of guided script as the engine and console: a blocking preflight (typecheck, validate, lint) before wrangler deploy, for the same reason, then it reminds you of the two Secrets Store entries the mint endpoint depends on. Set the signer secret as the Secrets Store entry DOWNPIPES_LICENCE_SIGNER_PRIVATE (bound internally as LICENCE_SIGNER_PRIVATE) and the operator mint bearer as DOWNPIPES_ADMIN_MINT_TOKEN (bound internally as ADMIN_MINT_TOKEN), then mint a licence by an authenticated call to POST /admin/licence with the account and the licence body. The control-plane holds no customer data and no Cloudflare token. The commercial model and the full mint flow are on the licensing and editions page.
Step 5: The licence pin note
A stock engine ships with the vendor’s real licence-signer public key baked in as the default (DEFAULT_LICENCE_SIGNER_PUBLIC in src/licence-pins.ts is a filled 2624-byte key, not a placeholder), so an out-of-the-box engine verifies a vendor-issued licence with no LICENCE_SIGNER_PUBLIC set. Licensing stays fail-open regardless: an engine with no licence token pasted in still runs as the community edition, and a missing or unverifiable licence never blocks a backup or a recovery.
If you run your own control-plane, pin your own signer public key so your engines verify the licences you mint instead of the vendor’s. An explicit, non-empty LICENCE_SIGNER_PUBLIC environment variable always wins over the baked default (see effectiveSignerPin in src/admin/licence.ts), so set it on the engine to your control-plane’s signer public half:
[vars]
LICENCE_SIGNER_PUBLIC = "<your control-plane signer public key, base64url>"
Either way the engine fails open: a missing or unverifiable licence is the community tier, never a blocked backup.
Step 6: Revoke the deploy token
As soon as the engine and console are confirmed healthy, revoke the deploy token from the Cloudflare dashboard. After this the system runs with no Cloudflare API token anywhere: the engine holds none by design, and there is no stored deploy credential left to leak. You re-create the same scoped token only when you next deploy, upgrade, or rotate a secret, and revoke it again each time. The no-custody posture is explained in the no-custody trust model.
Where this fits
This runbook is the spine for a first self-host. For the deeper first-deploy walkthrough with the full GET /admin/status and preflight detail, see first deploy. For the token permissions and the create-use-revoke lifecycle, see deploy token scopes. For why npm run deploy is mandatory, see deploy safety and bindings. To retire the admin token and wire Cloudflare Access, see identity and access. For the commercial model and the control-plane, see licensing and editions.
Last updated .