Skip to content
downpipes docs

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.

Prerequisites

Confirm all of these before you deploy, rather than discovering a gap from a failed run later.

PrerequisiteWhy it is required
Cloudflare account on Workers PaidThe 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 enabledThe 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 enabledThe 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. From engine 0.3.6, npm run deploy checks the version first and stops with a message on an earlier one.
wrangler authenticated against the accountThe engine pins wrangler 4. Confirm with npx wrangler whoami before you start.
A custom domain for the consoleThe 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.
Edge TLS posture on the console’s zoneMinimum TLS 1.2 and the Modern cipher suite preset, so the console’s custom domain negotiates only recommended, forward-secret suites. Set before you claim the first Owner; see the note below.

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

The console is served on a custom domain 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 and static-RSA suites such as AES128-SHA. Neither 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 (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 'AES128-SHA:@SECLEVEL=0' </dev/null

This must end in alert handshake failure (alert 40). 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 permissions that token needs, and the evidence 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. Check out the release tag named on the changelog before you run it: 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.

  1. Set the console origin

    The engine scopes its CORS to your console’s origin. In the engine’s wrangler.toml, set CONSOLE_ORIGIN to your console’s custom domain so the browser is allowed to call the engine. Leave the source bindings out of wrangler.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"
  2. 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.toml unchanged. The command is just:

    cd engine
    npm run deploy

    Do not reach for DOWNPIPE_ALLOW_BINDING_RESET

    You do not need DOWNPIPE_ALLOW_BINDING_RESET=1 on 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 ships wrangler.toml as-is and silently drops every source you attached from the console. Leave it unset and let a failed reconcile stop the deploy.

    The 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 the downpipe-engine Worker, the scheduler and per-run seal Durable Objects, and the reconciliation cron that drives new runs.

  3. 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-glass BREAK_GLASS_PUBLIC, a config-recipient pair, CONFIG_RECIPIENT_PUBLIC and CONFIG_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 the OPERATIONAL_RETIRED marker, which records that the engine has no operational key on purpose. If you choose the two-recipient posture, the operational read-back pair, OPERATIONAL_PUBLIC and OPERATIONAL_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. From engine 0.3.6 a later npm run deploy also 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, and every run sealed before you make it stays readable by the engine.

    Because this path generated your keys, the console’s in-browser ceremony is not something you go back and do. GET /setup-state reports keysReady true once the signer and break-glass public key are both present. The console’s setup then reads its Keys step as done, takes the posture from the engine, and offers “Use these keys” instead of making new ones (keysDone, console/src/lib/setup-flow/steps.ts). Setup still starts at its first unfinished step, which on a new engine is Connect. 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 and CONFIG_WRAP_KEY are 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 audit wrangler 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 signer

    Two of those four are must-keeps and they are must-keeps for different reasons. identity.key is the only key you keep that can decrypt an archive, and signer.pub is the only thing that can verify one. downpipe verify and downpipe restore both refuse to run without a pinned signer, and --allow-unverified downgrades a bad signature rather than waiving that, so it is not a way out. The printed sheet carries its fingerprint, not the key. From engine 0.3.6, the bucket holds a copy of the current signer’s signer.pub that the sheet’s fingerprint can pin (reader 0.3.4, --signer-fingerprint). A bucket from an older engine has no copy, and 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.

  4. 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.key is the only thing that can decrypt your backups if the engine or the account is ever lost, and signer.pub is what the reader checks their signatures against. Neither can be regenerated from the engine, and downpipe restore refuses 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 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.

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.

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.

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 a one-time 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 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 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 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 .