Engineering

How dbferry streams backups without touching disk

Every dbferry backup is a single streaming pass: pg_dumpzstdage → S3 multipart upload, with nothing written to local disk in between. Here's how that pipeline is built, and why the constraints — bounded memory, client-side encryption, self-healing on crash — fall out of it.

A look under the hood. The pipeline is open source — the CLI and core are the same code the hosted service runs.

The pipeline, end to end

Conceptually the whole thing is a Unix pipe:

pg_dump -Fc -Z0 | zstd | age -r <recipient> | s3 multipart-upload

Each stage's stdout is the next stage's stdin. Data flows through in chunks; no stage buffers the whole database. In Go this is io.Readers composed end to end — the dump's output pipe feeds a zstd encoder, whose output feeds the age encryption writer, whose output is sliced into parts and uploaded. The bytes only exist in flight.

Why not just dump to a file first?

The obvious alternative — pg_dump to a temp file, compress it, encrypt it, upload it — needs scratch space as large as the (compressed, then encrypted) dump, times the intermediate copies. On a backup box running many databases, that's a disk-capacity and cleanup problem, and it leaves plaintext dumps sitting on disk between steps. Streaming removes the temp files entirely: there's never a plaintext dump at rest, and disk size stops being a limit on database size.

Stage 1 — dump: pg_dump -Fc -Z0

Custom format (-Fc) instead of plain SQL, because it allows selective restore of individual tables and parallel pg_restore -j later. Built-in compression is turned off (-Z0) on purpose — compressing twice wastes CPU, and we want zstd doing that job at a level we control. For MySQL the equivalent stage is mysqldump --single-transaction.

Stage 2 — compress: zstd

zstd gives a strong ratio at high speed and streams naturally. It sits between the dump and encryption so that what gets encrypted (and paid for in storage) is already small. Compression before encryption is the right order: ciphertext is incompressible, so you must compress first.

Stage 3 — encrypt: age, client-side, BYOK

Encryption happens on the machine taking the backup, before a byte reaches object storage. dbferry uses age — X25519 key agreement with ChaCha20-Poly1305, an authenticated (AEAD) cipher. Backups are encrypted to your age recipient; the service only ever holds the public half.

What "zero data retention" means here: because encryption is client-side and keyed to you, dbferry never has the ability to read your backups. The trade is honest and absolute — lose the age identity and the backups are unrecoverable. No escrow, no "contact support to decrypt." Your key, your data.

Stage 4 — upload: S3 multipart, bounded memory

The encrypted stream is uploaded with S3 multipart upload, which is what makes "stream a database of unknown size" work: you don't need to know the length up front, and you can upload part by part as bytes arrive. The defaults:

Memory use is a function of part size and concurrency, not database size. A 5 GiB database and a 200 GiB database use the same buffers.

The validity invariant: a backup is valid only with its manifest

Alongside each ciphertext object, dbferry writes a small JSON manifest — but only after the multipart upload completes successfully. That single rule makes crashes self-healing:

Retention deletes ciphertext before manifest, so even a crash mid-prune leaves only a dangling manifest the next pass resolves. There's no state where a half-finished object looks like a good backup.

Why one Go binary

The pipeline, the scheduler, the job workers and the web UI are one static Go binary. Streaming composition is natural with Go's io interfaces; a single binary is trivial to ship and run yourself; and the same core code powers both the open-source CLI and the hosted service — the "we can't read your data" claim is provable because you can read the code that encrypts it.

Run the pipeline yourself, or let dbferry schedule it

The CLI is open source and free forever — same pipeline, your machine. The hosted service adds scheduling, per-database fan-out, GFS retention, failure alerts and a dead-man's switch, into your own bucket.