The self-host runbook: deploy your own downpipes from an empty Cloudflare account
This is the runbook for standing downpipes up in your own Cloudflare account. The runbook takes you 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). A first install runs that one action twice, once in the engine checkout and once in the console checkout, because they are separate Workers in separate repositories. Everything after both deploys, attaching sources, setting a destination, running a restore, is done from the console with no terminal.
Choose where the console lives
You can serve the console on a Cloudflare workers.dev hostname or on a custom domain you own.
- Start on workers.dev. You need no zone and no DNS. The shipped
wrangler.tomlfiles use a custom domain, so you change three settings: the console’sworkers_dev, the console’sroute, and the engine’sCONSOLE_ORIGIN. Email sending and invite links need a sending domain you own, so they need a custom domain. - Use a custom domain (recommended for production). The shipped
wrangler.tomlfiles use this path. A zone you control lets you set Minimum TLS 1.2, which is free. It lets you add the Modern cipher suite preset, which is optional and needs the paid Advanced Certificate Manager add-on. Cloudflare Access gets a hostname of your own, and email and invite links get a domain.
The steps that differ are marked workers.dev path and Custom domain path. A step with no mark applies to both.
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.18 or later (on the 23 line, 23.6 or later) | The deploy runs the engine’s TypeScript files directly, and earlier versions refuse them. npm run deploy checks the version first and stops with a message on an earlier one. |
wrangler authenticated against the account | The engine pins wrangler 4. Confirm with npx wrangler whoami before you start. |
| A custom domain for the console (optional, recommended for production) | The console is the product’s only public surface in the shipped topology. On the workers.dev path it is served at https://<console worker name>.<your account's workers.dev subdomain>.workers.dev and you need no zone. A custom domain adds a zone you control, a hostname for Cloudflare Access, and a sending domain for email and invite links. |
| Edge TLS posture on the console’s zone (custom domain path only) | Minimum TLS 1.2 is free on a zone. The Modern cipher suite preset is optional hardening: with it, the console negotiates only recommended, forward-secret suites. A cipher preset needs Cloudflare’s paid Advanced Certificate Manager add-on on the zone. Set before you claim the first Owner; see the note below. A workers.dev hostname has no zone of yours, so you cannot set these on that path. |
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.
Set the console's edge TLS posture before you claim the first Owner
This note applies to the custom domain path. On the workers.dev path you have no zone, so you cannot set the edge TLS floor or cipher suites.
On the custom domain path the console is served in your own Cloudflare zone, so its edge TLS posture is a zone setting you set yourself. downpipes has no standing or deploy-time Cloudflare credential that can touch it: the deploy token on the deploy token scopes page carries no Zone Settings Edit permission. Cloudflare’s zone default, the “Legacy” cipher preset, enables DES-CBC3-SHA, static-RSA suites such as AES128-SHA, and CBC suites such as ECDHE-ECDSA-AES128-SHA. None of them is a recommended suite.
Under SSL/TLS > Edge Certificates on the zone that carries your console domain, set Minimum TLS Version 1.2 (PATCH /zones/{zone_id}/settings/min_tls_version with value "1.2", free on the zone) and cipher suites: the Modern preset (optional; PATCH /zones/{zone_id}/settings/ciphers with the Modern list, or set per-hostname under Edge Certificates; needs the Advanced Certificate Manager add-on). The Modern preset admits only forward-secret AEAD suites, so TLS 1.3 negotiates TLS_AES_256_GCM_SHA384 and TLS 1.2 negotiates an ECDHE suite.
Verify from a workstation, against your own console domain:
openssl s_client -connect console.example.com:443 -servername console.example.com -tls1_2 -cipher 'ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:AES128-SHA:@SECLEVEL=0' </dev/nullThis must end in alert handshake failure (alert 40). The list holds CBC suites for an ECDSA certificate and for an RSA certificate. Keep the ECDSA suites in the list. A hostname that serves only an ECDSA certificate refuses the static-RSA suite AES128-SHA under every preset, so AES128-SHA alone is not a test of the preset. Then run the same command with -cipher 'ECDHE:@SECLEVEL=0', which must complete and report an ECDHE cipher. The full reasoning is on the first deploy page.
You provision and run these steps with a scoped, short-lived Cloudflare API token that you create now and revoke at the end. The full permission list, and the evidence that the running engine holds no token afterwards, are on the deploy token scopes page.
workers.dev path: the token needs no Zone permission. It needs six Account permissions: Workers Scripts: Edit, Workers KV Storage: Edit, Workers R2 Storage: Edit, D1: Edit, Secrets Store: Edit and Account Settings: Read.
Custom domain path: add two Zone permissions to those six: Workers Routes: Edit and DNS: Edit. Limit them to the zone that holds the console’s domain.
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. Check out the release tag named on the changelog before you run it.
A release tag is v<version>. Engine 0.3.10 is git checkout v0.3.10 in the engine repository. Console 0.2.10 is git checkout v0.2.10 in the console repository. npm run deploy builds from whatever commit is in front of it, and the released version is the tagged commit. There is no separate pre-built download to fetch instead; see get the source for the full explanation.
The release workflow builds the signed channel’s artefact from the tagged commit. The build is reproducible: a rebuild of the tagged source gives the same bytes, and verify a release shows how to check that yourself.
Set the console origin
The engine allows one exact origin for CORS, cookie-borne requests and passkeys. A state-changing request from a Cloudflare Access caller that carries an
Originheader must match it too (handleAdmin,engine/src/admin/router.ts). In the engine’swrangler.toml, setCONSOLE_ORIGINto your console’s origin so the browser is allowed to call the engine. On the custom domain path, use your custom domain. On the workers.dev path, use the console’s workers.dev origin,https://<console worker name>.<your account's workers.dev subdomain>.workers.dev(the console worker name is thenamein the console’swrangler.toml,downpipe-consoleby default). The engine keepsworkers_dev = falseon both paths. 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"On the workers.dev path you need the subdomain before the engine deploy, because
CONSOLE_ORIGINgoes into the engine’s deploy. The Workers and Pages section of the Cloudflare dashboard shows your account’s workers.dev subdomain. The API returns it too, fromGET /accounts/{account_id}/workers/subdomain. If the account has no workers.dev subdomain, register one in the dashboard before you deploy.The header comments in the shipped
wrangler.tomlfiles describe the custom-domain default. The workers.dev edits on this page still apply.Run npm run deploy
On a first deploy nothing is attached yet, so there are no live sources to preserve, and the reconcile works that out for itself: the settings read comes back 404 (or wrangler reports the script as not found), it says there is nothing to preserve, and it deploys
wrangler.tomlunchanged. The command is just:cd engine npm ci npm run deployOn a fresh engine the script asks two questions, one after the other. The first,
Enter 1 or 2 [1]:, asks for the key posture. Option 1 is break-glass-only, which a bare Enter keeps. Option 2 is the two-recipient posture.The second,
Enter 1 or 2 [2]:, asks how you will claim the first Owner. Option 1 is an emailed set-up link. Option 2 is the admin token, which a bare Enter keeps.Do not reach for DOWNPIPE_ALLOW_BINDING_RESET
You do not need
DOWNPIPE_ALLOW_BINDING_RESET=1on a first deploy, and you should not learn it as part of one. It does not mean “allow an empty reconcile”. It means “if I cannot read the live bindings, deploy anyway”, so on a later redeploy, when the reconcile cannot read them (an expired or revoked deploy token, for instance), it shipswrangler.tomlas-is and silently drops every source you attached from the console. Leave it unset and let a failed reconcile stop the deploy.The script checks the checkout first. This preflight runs
npm run lint:deps-installed,npm run typecheck, awrangler deploy --dry-runbuild into a temporary directory, andnpm run validate:customer, the checks that guard your data and keys. The preflight took 299 to 738 seconds (5 to 12 minutes) when measured, at load averages from 15 to 110. If a check stops the script, the script has deployed nothing:- Exit 1 means that a check found a fault in the code.
- Exit 2 means that a check could not read what it checks. The usual causes are dependencies that do not match
package-lock.json(runnpm ci) and a check that could not reach something on this machine.
First deploy has the detail.
When the preflight passes, the script does several things in order. 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. It builds the bundle once and records its SHA-384, so the deployed engine self-reports the real SHA-384 of its bundle. 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. In break-glass-only, the default, six secrets are installed:SIGNER_PRIVATE, the break-glassBREAK_GLASS_PUBLIC, a config-recipient pair,CONFIG_RECIPIENT_PUBLICandCONFIG_RECIPIENT_PRIVATE, which opens this engine’s own configuration export and nothing else,CONFIG_WRAP_KEY, which encrypts the destination and integration credentials the engine stores, and theOPERATIONAL_RETIREDmarker, which records that the engine has no operational key on purpose. If you choose the two-recipient posture, the operational read-back pair,OPERATIONAL_PUBLICandOPERATIONAL_PRIVATE, replaces the marker, which makes seven, and the engine then holds both halves. Neither setup path lands you in the two-recipient posture unless you choose it. The console’s in-browser ceremony has no default and waits for you to choose, so an operational key is what you opt into there. This terminal path asks before it generates any key, so a bare Enter, or no terminal at all, keeps you in break-glass-only. A laternpm run deployalso asks before it adds an operational key to an engine that has keys but none, such as one the console keyed as break-glass-only. Tightening to strict break-glass-only afterwards is a switch on the console’s Keys screen and needs no re-key. The switch deletes the operational key, so after it the engine cannot read the runs sealed before it, and the break-glassidentity.keythey were sealed to still opens them.Because this path generated your keys, the console’s in-browser ceremony is not something you go back and do.
GET /setup-statereportskeysReadytrue once the signer and break-glass public key are both present. The console’s setup then reads its Keys step as done and takes the posture from the engine (keysDone,console/src/lib/setup-flow/steps.ts). Setup starts at its first unfinished step, which is Connect until your engine holds the read-only token, and after Protect it goes to Apply. It shows neither Welcome nor Keys on this path.If you open the Keys step yourself, at
/setup/keys, it says “Your engine already holds keys” and offers “Use these keys” instead of making new ones. Replacing the keys anyway produces a second break-glass key, and a second break-glass key does not open archives sealed to the first. The full reconciliation of the two paths is on which ceremony generated your keys.The opt-out removes less than it may seem. 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 andCONFIG_WRAP_KEYare generated in either posture and deliberately survive the switch, and the config-recipient pair 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 when you auditwrangler secret list; removing them removes that self-healing.Verification at seal and the hourly canary do NOT need the operational key: the seal path 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: it decrypts your archives, even without the engine 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 a pinned signerTwo of those four are must-keeps and they are must-keeps for different reasons.
identity.keyis the only key you keep that can decrypt an archive, andsigner.pubis the only thing that can verify one.downpipe verifyanddownpipe restoreboth refuse to run without a pinned signer, and--allow-unverifieddowngrades a bad signature rather than waiving that, so it is not a way out.The printed sheet carries its fingerprint, not the key. The bucket that your engine seals runs to holds a copy of the current signer’s
signer.pubthat the sheet’s fingerprint can pin (reader 0.3.4,--signer-fingerprint). Replication copies the recovery bundle to each destination that holds runs, and retries a copy that has not landed at the next cron tick. After a re-key the copy is the new signer’s, so keep the file.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: Deploy the console
The console comes before the first Owner because you claim that Owner in the console, and until this step runs no console exists to open. The console is a separate Worker with a service binding to the engine. First set its address in console/wrangler.toml to match the engine’s CONSOLE_ORIGIN.
Custom domain path: set the pattern of the route to your domain, for example route = { pattern = "console.example.com", custom_domain = true }. The shipped file already has workers_dev = false.
workers.dev path: set workers_dev = true, and remove the route line or comment it out.
On either path you can set preview_urls = false in both wrangler.toml files to switch off per-version preview hostnames. Build the bundle and deploy from the console repository:
cd console
npm ci
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 (npm run lint:deps-installed, npm run typecheck and npm run validate:customer, about 36 to 75 seconds when measured) that stops the deploy on any failure, then runs wrangler deploy, which reads console/wrangler.toml, which carries the console’s address settings (the custom-domain route as shipped, or workers_dev = true on the workers.dev path) and the ENGINE service binding. 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.
Later console updates come through the channel
The signed update channel can carry the console as a component alongside the engine, so after this first deploy, console updates apply from the console with no terminal. The flow is on applying updates.
Step 3: 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.
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. When neither BOOTSTRAP_OWNER_EMAIL nor ADMIN_TOKEN is already set, the guided deploy asks which of the two paths you want and offers the admin token as the default, so choose option 1 at that prompt to be asked for the address and take this path.
The link stays valid for 24 hours. Pressing the send button on the sign-in page again while the link is valid sends the same link. The engine mails it at most once every 60 seconds (BOOTSTRAP_RESEND_COOLDOWN_MS, engine/src/admin/passkey.ts).
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, use an admin token and sign in with it. This is what option 2 at the deploy prompt does for you: the script generates the value with openssl rand -base64 32, sets it as the ADMIN_TOKEN secret and prints it once. On an engine that has already been deployed you can set one yourself instead:
npx wrangler secret put ADMIN_TOKEN
Use a long random value and store it in your secrets manager; the printed value is never echoed by wrangler and there is no copy to recover later. 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 has no expiry and stays valid until you retire it, so the console’s Security Centre prompts 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 4: 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.
There is no control-plane to deploy. The control-plane that mints licences is a vendor service, and the vendor provides its source to no one. Leave LICENCE_SIGNER_PUBLIC unset. A non-empty value always wins over the baked default (see effectiveSignerPin in src/admin/licence.ts). A licence then verifies only against that value, so a vendor-issued licence falls open to the community edition.
Either way the engine fails open: a missing or unverifiable licence is the community tier, never a blocked backup.
Step 5: Revoke the deploy token
As soon as the engine and console are confirmed healthy, revoke the deploy token from the Cloudflare dashboard. After this, no deploy token is stored anywhere, so no deploy credential is left to leak. The only Cloudflare API token the engine can hold is the optional read-only discovery token, which cannot write to or deploy your account. 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 .