downpipe CLI command reference
downpipe is the standalone, MIT-licensed Go command line for downpipe archives. It is the break-glass recoverer’s tool to verify and restore an encrypted Cloudflare backup from the destination bucket bytes and the customer-held offline keys. Neither Cloudflare nor any vendor service is in the loop. This page is the per-command reference for everyone who runs it: the recoverer on the reader path, and the operator who uses the same binary to stand up or upgrade the engine.
The binary carries two distinct paths with two distinct trust postures. Keeping them separate is the mental model that makes the rest of the page read cleanly. The reader path (keygen, inspect, keys, verify, attest, verify-report, restore, selftest) makes no network call to Cloudflare or any vendor at all; when it reads from an S3-compatible bucket it issues GET requests only and refuses every HTTP redirect, so a hostile endpoint cannot bounce the read to another host. The provisioner path (setup, preflight, init, update) is the only path that talks to api.cloudflare.com. Only the operator’s own API token is used, read from the environment and never sent to the vendor or the in-account console.
The deep, step-by-step offline-recovery walkthrough lives on this page, under recover a bucket end to end. Other entry points (the break-glass offline overview, the restore flow) are shorter and point here for the exact commands.
Install the tool
The reader is the Go module github.com/downpipes-io/downpipe, and it builds with Go 1.26 or newer.
Getting a copy of that source is a step you complete before a recovery. downpipes-io/downpipe is a public repository: go install github.com/downpipes-io/downpipe/cmd/downpipe@latest resolves against the public module proxy, and a plain git clone of it also works. No account or engagement is needed. Even so, take your copy in advance rather than on the day: a recoverer who reaches this page without a copy already to hand has nothing here to fall back on, because a real recovery should not depend on the module proxy or GitHub being reachable at the time. The MIT licence then puts no condition on what you do with that copy: keep it, rebuild it, audit it, or hand it to a third party who recovers on your behalf.
The command set is the second question. The first is whether the binary implements your archives’ format version at all, because a reader implements exactly one major.minor and refuses every other version with exit 6. There is one format line, downpipe/0.1.x, and the reader implements exactly it. Read formatVersion from a run’s root manifest or _RECOVERY/FORMAT.md to confirm what your bucket holds, rather than finding out during a recovery. Getting the reader sets this out in the order you would do it.
Then run --help against the binary and confirm prune, recombine and unseal-export are all listed before you rely on it. What matters is the command set the binary in front of you reports. Confirming it yourself takes five seconds and removes the doubt either way.
From a copy of the source, on a machine whose own Go already satisfies the toolchain pin below, the build needs no network at all:
cd downpipe
go build -mod=vendor ./cmd/downpipe
./downpipe version
./downpipe --help
Every dependency the reader needs, including the post-quantum signature module, is vendored into ./vendor, so the build itself downloads no module and makes no network call. That offline-from-source property is what offline recovery depends on: if both the vendor and Cloudflare are gone, and with no module proxy reachable, you can still rebuild the recovery tool from a held copy of the source alone, provided the recovery machine’s own Go already satisfies the toolchain pin below. It is also the reason to take that copy early. Every guarantee on this page is conditional on your holding one before you need it.
go.mod pins toolchain go1.26.6, a deliberate security decision (the pin forces a patched toolchain rather than letting a stale local Go build a reader with known, fixed vulnerabilities). Under Go’s default GOTOOLCHAIN=auto, if the recovery machine’s installed go is older than the pin and that exact toolchain is not already cached locally, the vendored build tries to download it and, with no network available, fails outright rather than silently falling back to the older Go. Keep a go binary meeting the pinned version alongside your recovery kit, or confirm ahead of time it is already cached on the machine you intend to recover with, rather than discovering the gap during a real recovery. GOTOOLCHAIN=local builds with whatever Go is already installed, without the toolchain pin’s security-patch guarantee; treat it as a last resort, not a substitute for holding the right toolchain in advance.
The tool version is a different thing from the archive format version. The binary stamps its own build version (dev for a local build, since it does not run the maintainer’s release pipeline), while the on-disk archive format is the literal string downpipe/0.1.0. The two move independently: a new tool build never changes the format string, and a change to a byte-level format rule would be a new format version, not a tool release. The reader accepts exactly the downpipe/0.1.x line, so it opens any archive in the current format and refuses one that names a different identity. From reader 0.3.4, verify, restore, attest and inspect --identity exit 6 on an envelope suite or chunk size that SPEC.md 5.2 does not pin.
The command set at a glance
downpipe <command> [flags] dispatches fifteen subcommands across the two paths, plus three small built-ins. The reads-versus-writes column summarises each command, and each command has its own section below.
| Command | Path | Reads or writes |
|---|---|---|
keygen | reader | Writes four key files to a local directory; refuses to overwrite an existing key file |
inspect | reader | Reads only; shows the cleartext root manifest, and the encrypted detail when given an identity |
keys --which | reader | Reads only, keyless; groups an archive’s runs by the recipient identity that opens them |
verify | reader | Reads only; proves a run whole and genuine and restores nothing |
attest | reader | Reads only, keyless; needs no break-glass identity and materialises no plaintext |
verify-report | reader | Reads only; checks the signature on an engine assurance report against signer.pub, with no archive and no identity. From reader 0.3.4 |
restore | reader | Dry run by default; writes to the target only under --apply, and never overwrites existing state |
selftest | reader | Builds and recovers a sample archive in a temporary area, end to end |
prune | reader | Offline retention prune, for a break-glass-only posture where the SCHEDULED pass cannot run unattended. The console’s break-glass prune panel runs the same prune on demand without a terminal; this command is the offline alternative. Dry run by default; deletes only under --apply. --receipt <path> writes a JSON record of the pass, written before the first delete so an interrupted prune still leaves an account of itself. Because the offline prune holds only the signer public key, it cannot mark a superseded RUNLOG entry the way the engine’s own prune does, so a later attempt to open that run reports it listed but its tree entirely absent, worded deliberately as ambiguous: that is both the expected state after a completed prune and what an attacker’s deletion of the run would look like, and nothing in the archive tells the two apart |
recombine | reader | Rebuilds identity.key from the console’s custody artefacts. WRITES a complete key to disk, which is the whole difference from combining in memory |
unseal-export | reader | Opens a sealed control-plane export offline with the break-glass identity, and writes the recovered plaintext export JSON |
preflight | provisioner | GET-only account checks against api.cloudflare.com; provisions nothing |
setup | provisioner | Dry run by default; under --apply it WRITES Access, Secrets Store and binding changes |
init | provisioner | Prints a runbook only; makes no Cloudflare call |
update | provisioner | Prints a runbook only; makes no Cloudflare call |
version, spec, help | built-in | Print the tool version, point at the format spec, or print usage |
version (also --version and -v) prints the build version. spec points at docs/format/SPEC.md. help (also -h and --help) prints the usage text. Running the tool with no arguments prints usage and returns 6, the usage code, the same as an unrecognised command; see exit codes and verification outcomes.
Source selection (reader path)
The five reading commands (inspect, keys, verify, attest, restore) take the same source flags, so you point them at the bucket bytes the same way every time. Choose exactly one backend: a local directory, an S3-compatible endpoint, or an Azure Blob endpoint.
| Flag | Purpose |
|---|---|
--archive <dir> | Read from a local copy of the whole bucket tree |
--s3-endpoint <url> | Read from an S3-compatible bucket (R2, Backblaze B2, Wasabi, MinIO, AWS S3, and Google Cloud Storage at https://storage.googleapis.com) instead of --archive. Azure Blob Storage has its own pair below |
--s3-bucket <name> | The bucket name, required with --s3-endpoint |
--s3-region <region> | The region with --s3-endpoint (defaults to auto) |
--azure-endpoint <url> | Read from an Azure Blob container instead of --archive, giving the account’s blob endpoint, for example https://<account>.blob.core.windows.net |
--azure-container <name> | The container name, required with --azure-endpoint |
S3 credentials come from the standard AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables, never from a flag, so a key never lands in a process listing or shell history. The S3 reader issues GET requests only and refuses every redirect, so the read cannot be bounced to a host the bucket did not name. For a Google Cloud Storage destination those two variables take the HMAC interoperability key pair the destination itself uses.
There is a second network backend because Azure Blob Storage is not an S3-compatible store behind another host: it is a different wire protocol, with its own authentication scheme, its own request shape and its own listing document, so --s3-endpoint cannot reach an Azure container at any value. Azure credentials come from AZURE_STORAGE_KEY (a storage account access key) or AZURE_STORAGE_SAS_TOKEN (a shared access signature), on the same no-flag rule, and you set exactly one of the two rather than both. The storage account name is taken from the endpoint’s first label; set AZURE_STORAGE_ACCOUNT as well only when it is not, which is the case for a custom domain in front of the account. Half a pair, meaning an --azure-endpoint with no --azure-container, exits 6, and so does naming an --s3-endpoint and an --azure-endpoint in one command, which is refused rather than resolved by precedence.
A worked Azure restore, matching the one in the reader’s own docs/RECOVER.md:
downpipe restore --apply --azure-endpoint https://<account>.blob.core.windows.net \
--azure-container <container> --run <runId> \
--identity ./keys/identity.key --signer ./keys/signer.pub \
--min-runlog-index <n> --out ./restored
An applied prune does not run against an Azure container
The Azure backend implements the reads a recovery needs and deliberately implements no delete, so prune --apply refuses on the capability it cannot find rather than on a check somebody has to remember to write. prune without --apply still plans and reports what it would remove. Retention on an Azure destination is the engine’s job. Every other reading command takes --azure-endpoint on the same terms as --s3-endpoint. See destination providers compared.
Reader commands
keygen
keygen generates the offline key material in your browser-free, air-gapped path. It writes four files into the --out directory (default the current directory): identity.key (the break-glass private key, the only thing that can decrypt, so keep it offline on an encrypted drive, in a password manager or split across custodians, and never write it onto the printed recovery sheet), recipient.pub (the recipient public the writer encrypts to), signer.pub (the operator signer public the reader pins, which verify and restore both require, so keep a copy of this one too), and signer.key (the signer private the writer signs runs with). Each file is created at owner-only permissions and the command refuses to clobber an existing key file, so a re-run can never silently overwrite a break-glass key.
inspect
inspect --run <runId> reads the cleartext bootstrap manifest and prints what the run holds: the format version, the run id, the creation time, the envelope suite, the declared record count, shard count, whether a break-glass recipient is present, and the recipient set with fingerprints. It needs no identity, so it is the first look you can take without touching a key. Pass --identity together with --signer to open the encrypted preamble as well, which adds the downpipe name, cadence, source type, the captured window and the per-source restore descriptors a record carries (KV expiration and metadata, R2 metadata, the secret binding wiring, the D1 format).
inspect is a diagnostic viewer, not a verifier, and it says so on screen. Run without --identity it opens nothing at all, so no signature, no record hash and no RUNLOG is checked. Every line it prints is the bucket’s own account of itself; the command states this in its output. The declared signer fingerprint is a claim by the archive rather than proof, since the manifest is signed under the very key that line names, so it answers “is this the ceremony my recovery sheet describes” and never “is this archive authentic”.
Run with --identity and --signer it does genuinely verify the root signature, the master capsule, the key commitment, the recipient set, the record count and the Merkle root before printing the decrypted detail. On the anti-rollback freshness check it deliberately does not enforce: it waives the gate so it can show you a rolled-back or unverifiable run rather than refusing the archive you are trying to diagnose. It prints the freshness verdict on every open, whether or not it passed, so the line’s absence never has to be interpreted, and it points you at verify. It offers no override, because it has already applied one. When you need a run refused rather than shown, that is verify.
keys
keys --which is a keyless diagnostic over an archive’s public signed root manifests and the RUNLOG. It answers “which offline key opens which runs, and across what date range” across key rotations: the recipient fingerprints and roles are recorded in the clear in each run’s signed root, so the command reads the RUNLOG to enumerate the runs, reads each run’s root, and prints the runs grouped by recipient identity (break-glass first, then by fingerprint), each identity’s run count and time window, and the run ids it opens. It needs no --identity and unwraps nothing. A run whose root cannot be read is skipped and reported to stderr rather than aborting the whole index, except for runs whose tree has been pruned away: those are counted and summarised on one line, because on an estate under retention they are the normal state of a healthy archive rather than something to warn about once per run. That count is worth comparing against the retention you actually ran.
keys --which also takes an optional --signer. With it, the RUNLOG and each run’s root are verified before they feed the index. Without it, the index is labelled UNVERIFIED. keys --fingerprint reads no archive. It prints the fingerprints of the key files you pass (--identity or the custody artefacts, --recipient, --signer), so you can check them against your recovery sheet.
verify
verify proves a run is whole and genuine, and restores nothing. It recovers the run master from the master capsule using the break-glass identity, derives every file key, and then checks the hybrid signature against the supplied signer, every shard hash, the recipient set, the break-glass binding, the key commitment, the Merkle root over every record hash, the declared record count, and the RUNLOG freshness. Because the Merkle root binds each record hash, which binds its plaintext hash, that chain covers every record’s integrity by construction, but by default verify is shallow: it checks the signed root, the shard manifests and the RUNLOG only, and never reads a seg/ data object itself, so a flipped ciphertext byte in a shard passes a shallow verify. Pass --deep to close that gap: it runs the same decrypt-and-discard path as restore --sink discard, reassembling every record, AEAD-tag verifying every chunk and checking each record’s plaintext SHA-384, and writing nothing.
| Flag | Purpose |
|---|---|
--run <runId> | The run to verify (required) |
--identity <file> | The break-glass identity file (required, unless you supply the custody artefacts below) |
--signer <file> | The operator signer public-key file. Required unless you pass --signer-fingerprint |
--signer-fingerprint <fp> | From reader 0.3.4. The signer fingerprint (edmldsa1:...) from your printed recovery sheet. The reader reads the signer.pub copy in the bucket’s recovery bundle (written from engine 0.3.6, and always the current signer’s key) and uses it only if its fingerprint equals this value. A copy that does not match exits 2; a bucket with no copy exits 6. With --signer as well, the file is checked against this value |
--allow-stale | Proceed despite this run’s age and nothing else: the RUNLOG verified against --signer, is internally consistent, and says either that this run is not the latest for its downpipe or that its maximum index is below your --min-runlog-index pin. It does not waive the RUNLOG’s signature, presence or self-consistency, so it covers only part of exit 5 |
--allow-unverified-runlog | Proceed despite the RUNLOG itself being untrustworthy: absent, unreadable, unparseable, empty, not carrying this run, failing to verify against --signer, disagreeing with the signed root manifest, or internally contradictory. This is the acknowledgement for the rest of exit 5. Nothing is then established about this run’s recency, and a bucket someone has rolled back or replaced looks exactly like this |
--allow-unverified | Proceed despite a missing or invalid signature; a receipt records the mode allow-unverified and the signature result. Implies both acknowledgements above |
--min-runlog-index <n> | Reject a RUNLOG whose maximum index is below this anti-rollback pin |
--acknowledge-no-rollback-pin | Silence the stderr reminder printed when --min-runlog-index is left unset. Rollback protection stays off either way; this only suppresses the reminder, for a genuine first recovery before a recovery sheet entry exists. The receipt still records the pin as unset |
--check-bundle | Verify the in-bucket recovery bundle against its signed SHA384SUMS |
--deep | Also decrypt and hash-verify every record’s segment bytes (the full restore path, writing nothing); without it, verify checks the signed manifests only, never a seg/ data object |
--fetch-concurrency <n> | Parallel shard-prefetch window (defaults to 1, strictly sequential and bounded memory) |
--receipt <path> | Write a verify receipt to this path (- writes to stderr); it is signed only when --receipt-signer is given |
--receipt-signer <file> | Sign the receipt with this signer private-key file |
Leaving --min-runlog-index unset prints a one-line stderr warning on every run, naming the missing flag and what to pass instead; it never blocks the run and never touches stdout. Pass --acknowledge-no-rollback-pin to silence that reminder once you are certain there is no recovery sheet entry yet to pin against.
The verdict and a one-line summary go to stderr, and the process exit code is the machine-readable result. The full code contract, and what the salvage overrides change, is on exit codes and verification outcomes.
attest
attest --run <runId> is the keyless check: it verifies the root signature and the structural completeness of the signed root (the shard hashes and the shard count), without the break-glass identity. It never unwraps the master capsule, opens a shard, or decrypts a record, so it needs no --identity and materialises no plaintext. The --signer flag is optional: with a signer the signatures are cryptographically verified against the operator-pinned key; without it the attestation is fully keyless and reports the signature as unchecked. Either way, attest catches a tampered shard, a missing shard, and a malformed or absent signature.
Freshness is a separate question. A plain attest with no --min-runlog-index checks only that the run is present in the RUNLOG, never its freshness, so a rolled-back RUNLOG that still lists the run passes clean. Pass --min-runlog-index <n> to close that gap. With --signer, the pin is a full cryptographic anti-rollback check, exactly as verify and restore’s. Without --signer, the pin is honoured only structurally: it reads the RUNLOG’s own claimed index, which is not itself authenticated in keyless mode, so it catches an accidentally stale or wholesale-replayed bucket but not a targeted adversary who edits the unsigned RUNLOG’s stated index along with the rollback. The printed report always states which of the two it was.
| Flag | Purpose |
|---|---|
--run <runId> | The run to attest (required) |
--signer <file> | The operator signer public-key file (optional; without it the attestation is keyless) |
--min-runlog-index <n> | Reject a RUNLOG whose maximum index is below this pin: a full cryptographic anti-rollback check with --signer, a structural, unauthenticated one without it |
--acknowledge-no-rollback-pin | Silence the stderr reminder printed when --signer is set and --min-runlog-index is left unset. The reminder fires only when a signer is pinned, since that is the only case where the RUNLOG signature is actually checked |
Record-level completeness needs the identity, so for that you run verify or restore.
verify-report
From reader 0.3.4. verify-report checks the signature on an assurance report the engine generated: restore tests, SLA compliance, immutability, posture, the compliance evidence pack and change records. When the engine has a signer configured, it signs each report with that signer, the key pair that signs your archives.
The command needs no archive and no break-glass identity. It reads the report from a local file or standard input. It reads the signer public key from a local file, and it makes no network call. Reader 0.3.3 and earlier answer unknown command "verify-report" and exit 6.
| Flag | Purpose |
|---|---|
--report <file> | The report JSON (required): the body of GET /admin/reports/<kind>, or the console’s View JSON saved to a file. The console offers the compliance evidence pack as a PDF only, so its JSON is the body of GET /admin/reports/evidence-pack?framework=<id>. - reads standard input. The command reads at most 64 MiB |
--signer <file> | The operator signer public-key file, signer.pub from the key ceremony or your recovery kit (required). A fingerprint cannot replace it, because a fingerprint cannot verify a signature |
--signer-fingerprint <fp> | Optional. The signer fingerprint (edmldsa1:...) from your printed recovery sheet. The command refuses a --signer file with a different fingerprint and exits 6 |
The command takes no positional argument. It rebuilds the canonical JSON of the report’s kind, generatedAt, period and data, as the engine does. Both signature halves, Ed25519 and ML-DSA-87, must verify over it. The PDF carries no signature the command can check. Each request to the engine generates a new report, so a JSON you fetch after a PDF is a different report from the one the PDF shows.
The result goes to stderr. A verified report prints three lines:
verify-report: VERIFIED. kind=<kind> generatedAt=<generatedAt> period=<period>
signer: edmldsa1:<fingerprint>
Both signature halves (Ed25519 and ML-DSA-87) verified over the canonical {kind, generatedAt, period, data}. Compare the signer fingerprint with the one on your recovery sheet.
The period reads point-in-time for the posture, immutability and evidence-pack kinds. For a time-bounded kind it reads <fromSeconds>..<toSeconds>, followed by the same two times in UTC. On a failure the tool prints downpipe: and the reason. When the command got as far as checking the report’s content, a signer: line with the fingerprint follows.
# Verify a report against the signer public key from your recovery kit
downpipe verify-report --signer ./keys/signer.pub --report ./posture-report.json
# Also refuse a signer.pub whose fingerprint is not the one on your recovery sheet
downpipe verify-report --signer ./keys/signer.pub \
--signer-fingerprint edmldsa1:<fingerprint from your sheet> \
--report ./sla-compliance-report.json
Exit 0 means both halves verified. Exit 2 means the report did not verify. The message gives the reason: a missing signature, or a signature that fails because the report changed or another signer signed it. Exit 6 means the input is not a report JSON, for example the PDF, or a flag or key file is wrong. The cases behind each code are on exit codes and verification outcomes. For the report kinds and the auditor’s procedure, read signed reports.
restore
restore is a dry run by default. It plans the writes and reports any conflict with state already present in the target, and writes nothing. Pass --apply to opt in to writing. On a real apply it reassembles each record’s value, verifies it against its signed plaintext hash as it writes, and never overwrites an existing destination key (a key that appears between the plan and the write fails loudly rather than clobbering).
| Flag | Purpose |
|---|---|
--run <runId> | The run to restore (required) |
--identity <file> | The break-glass identity file (required, unless you supply the custody artefacts below) |
--signer <file> | The operator signer public-key file. Required unless you pass --signer-fingerprint |
--signer-fingerprint <fp> | From reader 0.3.4. The signer fingerprint from your printed recovery sheet, which pins the signer.pub copy in the bucket’s recovery bundle (written from engine 0.3.6, and always the current signer’s key). It behaves as it does for verify |
--sink <kind> | The restore target: file, env or discard (defaults to file) |
--out <dir> | The directory to restore record values into (required with --sink file) |
--apply | Write to the target; without it the restore is a dry run that plans only |
--allow-stale | Proceed despite this run’s age and nothing else: the RUNLOG verified against --signer, is internally consistent, and says either that this run is not the latest for its downpipe or that its maximum index is below your --min-runlog-index pin. It does not waive the RUNLOG’s signature, presence or self-consistency, so it covers only part of exit 5 |
--allow-unverified-runlog | Proceed despite the RUNLOG itself being untrustworthy: absent, unreadable, unparseable, empty, not carrying this run, failing to verify against --signer, disagreeing with the signed root manifest, or internally contradictory. This is the acknowledgement for the rest of exit 5. Nothing is then established about this run’s recency, and a bucket someone has rolled back or replaced looks exactly like this |
--allow-unverified | Proceed despite a missing or invalid signature; a receipt records the mode allow-unverified and the signature result. Implies both acknowledgements above |
--min-runlog-index <n> | Reject a RUNLOG whose maximum index is below this anti-rollback pin |
--acknowledge-no-rollback-pin | Silence the stderr reminder printed when --min-runlog-index is left unset. Rollback protection stays off either way; this only suppresses the reminder, for a genuine first recovery before a recovery sheet entry exists. The receipt still records the pin as unset |
--check-bundle | Verify the in-bucket recovery bundle against its signed SHA384SUMS |
--receipt <path> | Write a restore receipt to this path (- writes to stderr); it is signed only when --receipt-signer is given |
--receipt-signer <file> | Sign the receipt with this signer private-key file |
--fetch-concurrency <n> | Parallel shard-prefetch window (defaults to 1, strictly sequential and bounded memory) |
--max-memory <size> | Caps the bytes held in the prefetch buffer, for example 512MiB or 1GiB (defaults to 0, which honours GOMEMLIMIT instead) |
There are three sinks and no stdout sink. The file sink writes one file per record under --out at owner-only permissions. The env sink writes dotenv KEY='value' lines to stdout, treats the names already in the process environment as occupied so it never redefines a set variable, and rejects a name that is not a valid POSIX environment-variable name. The discard sink is a restorability check, not a write: it runs every record through the full restore path so each value is decrypted and hash-verified, then writes nothing, printing an attestation (records, bytes, failures) and a one-way restore digest over the verified plaintext with no plaintext in any output. Because the discard sink decrypts to verify, it always applies regardless of --apply.
The plan and the result print to stderr
For the file and env sinks, the plan and the apply result are written to stderr so stdout stays clean for the env sink’s data. The plan reports counts, names, keys, sizes and conflict reasons only, never a value.
selftest
selftest builds a sample archive with the post-quantum hybrid envelope and recovers it from disk using only an offline key, end to end, with nothing else in the loop. It is the fastest way to confirm a freshly built binary works before you point it at a real bucket.
| Flag | Purpose |
|---|---|
--keep <dir> | Write the sample archive and keys to this directory and keep them, instead of using and discarding a temporary directory |
Split custody: using shares without writing a key
If your organisation holds the break-glass key as M-of-N shares rather than as one file, every command that takes --identity takes the custody artefacts directly:
downpipe prune --archive ./archive --signer ./keys/signer.pub --keep 30 \
--share ./custody/share-1.txt --share ./custody/share-3.txt --share ./custody/share-4.txt \
--envelope ./custody/wrapped-identity.txt
| Flag | Purpose |
|---|---|
--share <file> | A share file, repeatable. Either the labelled download or a file holding the bare emailed share body |
--envelope <file> | The wrapped-identity file the console offered for download. Required with --share or --wrapping-key |
--wrapping-key <file> | The wrapping-key file, used instead of --share |
--threshold <n> | The ceremony’s M. Needed only when every share you supply is the bare emailed form, which carries no threshold |
The shares are combined in memory, used for that one command, and wiped. No complete copy of the key is written anywhere, so the M-of-N control covers the whole operation rather than ending the moment someone reassembles the key.
The share files themselves are still yours to dispose of, and the example above shows them gathered into one directory only for brevity. A quorum of shares sitting beside the envelope on one disk is the whole break-glass key to anyone who reads that directory later. The custody rule that shares live apart from the envelope and from each other exists to prevent exactly that outcome. Bring them onto removable or ephemeral media, and destroy the copies when the command is done: the filesystem-residue argument below applies to a co-located quorum exactly as it applies to a recombined key file.
That distinction is the point. recombine writes identity.key, which is the right answer when you deliberately want a key file, but reaching for it in order to run a command means the complete break-glass key sits on one operator’s disk until they remember to destroy it, and on a copy-on-write or journalled filesystem the bytes outlive the deletion. Supplying the artefacts to the command directly avoids ever creating that file.
Supplying both --identity and the custody artefacts is refused rather than resolved by precedence. If you pass both you have a belief about which key is being used, and a silent rule would be right only half the time.
Below the threshold the command fails before it reads the archive, so a quorum that has not been met cannot be worked around by trying a command that needs fewer bytes.
The commands that take an identity, and therefore take these flags, are inspect, keys --fingerprint, verify, restore, prune and unseal-export. The last one is worth naming: it opens a sealed control-plane export offline, without a console at all. The console’s own estate-import form does the identical unseal in your browser when a console is available, covered on recovering downpipes itself, so this command is for the console-unavailable case, and it is the one where materialising a key file first would be hardest to undo.
Recover a bucket end to end
This is the offline-recovery procedure. It is the path the format exists to keep open: a holder of the destination bucket bytes and the customer’s own offline break-glass key recovers the data with neither Cloudflare nor the vendor available. You need the whole bucket tree (or a local copy of it), the offline break-glass identity, and a pinned signer. The identity and signer.pub are files from the key ceremony, and neither is on the printed recovery sheet: the sheet records their fingerprints so you can confirm you have the right pair. verify and restore both refuse to run without a pinned signer.
From engine 0.3.6, the bucket’s recovery bundle carries a copy of the current signer’s signer.pub. From reader 0.3.4, you can pin that copy with --signer-fingerprint and the signer line of your sheet. Use it in place of --signer ./keys/signer.pub in the commands below. Every run overwrites the copy. A bucket that only engine 0.3.5 or earlier has written to has none, and after a re-key the copy is the new signer’s. Keep signer.pub wherever you keep identity.key, and keep the old one after a re-key.
Build a reader you trust
Build from the vendored source you hold, as in install the tool. This step assumes you already took that copy; there is no command here that fetches one now. Keep the source alongside your recovery key so a reader is always to hand, or re-implement a clean-room reader from
docs/format/SPEC.mdand the conformance vectors. Confirm the binary runs with./downpipe selftest.Find the run id
A
runIdis the 26-character identifier of one backup run. It is not on your recovery sheet: the sheet carries fingerprints, a posture and a blank anti-rollback line, and its own command templates print<runId>as a placeholder for you to fill in. Find one withdownpipe keys --which --archive ./bucket, which is keyless, needs no identity, and lists the runs an archive holds grouped by the key that opens them. Listing the bucket’srun/prefix works too when you hold the tree locally.downpipe inspectis the next step rather than the first one: it needs no identity and touches no key, but--runis required, so it reads a run you have already named, and it shows a run rather than vouching for it. You normally recover the newest good run.Read your anti-rollback pin off the recovery sheet
Before you verify or restore anything, take the latest trusted RUNLOG index from the anti-rollback line on your printed
recovery-sheet.txt(see the key ceremony and recovery kit). Pass it as--min-runlog-index <n>on everyverifyandrestorecommand below. Without it, a bucket that has been rolled back to an older, validly-signed run, whether by an attacker or by an operator error, restores cleanly with no warning: the pin is what tells the reader that a run older than the one you last trusted must be refused rather than served. This step is not optional; skipping it is the one way to follow this walkthrough exactly and still restore a rolled-back archive.Verify the run before you write anything
Prove the run is whole and genuine first. It restores nothing, so it is safe to run as often as you like.
downpipe verify --archive ./bucket --run <runId> \ --identity ./keys/identity.key --signer ./keys/signer.pub \ --min-runlog-index <n>A zero exit means verified and complete. Any non-zero exit is a specific verdict (a signature problem, incomplete coverage, a plaintext mismatch, a freshness problem, or a usage error); read exit codes and verification outcomes for what each one means and what to do. Exit 5 with a rollback reason means
--min-runlog-indexjust did its job: stop and investigate before you go anywhere near--allow-stale.Restore to a file sink (the usual case)
Restore is a dry run by default, so run it once to read the plan, then again with
--applyto write. Prefer a fresh, empty--outdirectory so there is nothing to clobber.downpipe restore --archive ./bucket --run <runId> \ --identity ./keys/identity.key --signer ./keys/signer.pub \ --min-runlog-index <n> \ --out ./restored downpipe restore --archive ./bucket --run <runId> \ --identity ./keys/identity.key --signer ./keys/signer.pub \ --min-runlog-index <n> \ --out ./restored --applyThe apply reassembles each record, verifies its plaintext hash as it writes, and refuses to overwrite an existing file. A per-record failure is reported and the apply continues, so a partial restore is never dressed up as a success.
Or restore straight from the S3-compatible bucket
To read the destination directly instead of a local copy, supply the endpoint and bucket and put the credentials in the environment. The reader issues GETs only and refuses redirects.
Scope that credential to this one archive bucket and to read only (
s3:GetObjectands3:ListBucket). Mint it for this recovery rather than reaching for a standing account key. The reason: the same two environment variables resolve the one S3 backend that every store-backed command shares (storeFlags.resolveincmd/downpipe/main.go), and that backend carriesDeleteas well asGet(internal/source/s3.go).prune --applyis the one command that uses it. So a read-only credential completes every step on this page except a prune, and a prune is worth minting a separate credential for.export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... downpipe restore --s3-endpoint https://<account>.r2.cloudflarestorage.com \ --s3-bucket <bucket> --run <runId> \ --identity ./keys/identity.key --signer ./keys/signer.pub \ --min-runlog-index <n> \ --out ./restored --applyKeep a receipt
Add
--receipt ./receipt.jsonto write a machine-readable receipt of the outcome, and--receipt-signer ./keys/signer.keyto sign it if you hold the signer private. The receipt records the outcome and flags whether the written values were hash-verified, so a drill leaves an auditable trail. From reader 0.3.4, when records fail, the restore receipt records the code the process exits with. A scheduled break-glass drill, ideally with the discard sink on an isolated machine, is the only test that proves rather than assumes the bucket and the offline key are enough on their own. See prove recoverability.
The construction is post-quantum hybrid, so an archive stays safe if either the classical half or the post-quantum half is later broken. The envelope is interoperable with nothing off the shelf, which is why recovery is delivered by this documented reader of the downpipe format rather than a third-party tool.
Provisioner commands
The provisioner path is what an operator runs from their own machine to stand up or upgrade the engine in a Cloudflare account. Every command in this path that talks to Cloudflare reads its token from CLOUDFLARE_API_TOKEN, keeps it on the machine, and sends it only to api.cloudflare.com; CLOUDFLARE_ACCOUNT_ID is read only by setup, preflight instead takes the account as the --account <id> flag, and init and update are plan printers that read no environment variable and make no Cloudflare call at all. None of these commands is on the recovery path above.
preflight
preflight --account <id> runs the read-only, deploy-time account checks an onboarding needs before the engine is deployed. It lists the account’s zones, verifies a chosen --domain sits on an active zone, reports Secrets Store headroom against the documented ceiling, looks for a Workers Paid subscription, reports whether a Logpush job already ships Workers logs to a SIEM, and lists R2 buckets as destination candidates.
From reader 0.3.4, a low free-slot count in the Secrets Store does not fail the preflight. The engine deploy adds no secret to the store, because the engine’s keys are Worker secrets. The check reports the free slots, and a full store gets a note that says what needs a free slot. A store that the token cannot read is still a check that could not run, and its remediation says what the free slots are for.
The command is GET-only and provisions nothing. Add --json for machine-readable output. A check the token cannot read reports “unknown” rather than failing, but an invalid token aborts loudly. It exits 7, the preflight code, when a check failed or could not run with this token, or a requested --domain was not verified.
setup
setup is the local Cloudflare provisioner, and it is not read-only. It is a dry run by default: without --apply it validates the inputs, builds the ordered plan, prints exactly what it would create, and makes no network call at all. Under --apply it takes the live path, the only path that contacts Cloudflare, and it WRITES.
setup --apply writes to your account
Under --apply, setup creates a Cloudflare Access self-hosted application, attaches a One-Time PIN allow policy scoped to your email allowlist, PUTs Secrets Store entries, and PUTs the engine’s source and destination Worker bindings. Only the dry run (the default) writes nothing. Of the provisioner commands, only preflight is GET-only.
| Flag | Purpose |
|---|---|
--apply | Execute the plan against Cloudflare; without it setup prints the plan and creates nothing |
--config <file> | A JSON file describing the deployment (Access, email, secrets, bindings); authoritative when present |
--app-name <name> | The Access application name when not using --config (defaults to downpipes) |
--access-domains <list> | Comma-separated console and engine custom hostnames to gate with Access |
--allow-emails <list> | Comma-separated allowed email addresses for the One-Time PIN policy |
--allow-email-domains <list> | Comma-separated allowed email domains for the One-Time PIN policy |
--email-from <addr> | Outbound sender address on a custom domain (records the manual sending-domain step) |
--engine-script <name> | The Worker script the bindings attach to (defaults to downpipe-engine) |
Secret values are never passed as flags. Each declared secret’s value is read from the environment under DOWNPIPE_SECRET_<NAME> (the name uppercased, non-alphanumeric mapped to underscore) on the apply path only, so a value never appears in the plan or a log. An allowlist is mandatory: setup refuses to build an unrestricted Access allow rule, which is the documented footgun. Hostnames must be real custom domains; a workers.dev address is rejected. The email sending-domain onboarding is the one step the API cannot do, so it is surfaced as a note rather than an API call.
init
init prints the full least-privilege deploy runbook in order and then stops. It is a plan printer, not a live provisioner: it makes no Cloudflare call and creates nothing, and you run the printed steps yourself. The runbook walks the three privilege states described on Cloudflare token scopes: create a scoped, short-lived deploy token (the exact Cloudflare permissions are printed), provision the resources, deploy the engine and console, run the key ceremony in the console, wire the first source, verify readiness, dispose of the one-time bootstrap token, and finally delete the scoped deploy token. After that the running engine stores no deploy token and reaches your data through bindings. The only Cloudflare API token it can store is the optional read-only discovery token. From reader 0.3.4, the printed runbook says so, and it says that the engine’s keys are Worker secrets, not Secrets Store entries.
Flags let you set the engine domain, console domain, zone and archive bucket names that appear in the printed plan; a workers.dev domain is rejected.
init --execute is not implemented
The --execute flag is a deliberate refusal, not a live path. init is print-only by construction, with no branch that contacts Cloudflare, so --execute refuses with a usage error and points you at the printed steps.
update
update prints the ordered upgrade runbook for an already-deployed stack and then stops, the same plan-printer contract as init. It walks the privilege-state-3 upgrade: confirm the available version is signature-verified (pulled and verified by the engine against the pinned release-signer key, reviewed in the console; the vendor cannot push a deploy), re-create the same scoped short-lived deploy token, deploy the reviewed new version of the engine and console, verify readiness, and delete the scoped token again so no standing deploy credential is left behind. Flags set the version label and the engine and console domains that appear in the plan. Like init, its --execute flag is unimplemented and refuses, so the command can never make a live call.
Where this fits
For the meaning of every exit code and what the salvage overrides change, read exit codes and verification outcomes. For the conceptual recovery postures behind the break-glass key, read recovery postures and the key ceremony and recovery kit. For the in-console restore journey that the engine drives (as opposed to this offline binary), read the restore flow and what restore can and cannot write back. For the provisioner path in operational context, read deploy and Cloudflare token scopes.
Last updated .