How to back up Fly.io Postgres to Cloudflare R2
RESTORE-TESTED · ENCRYPTED · IN A BUCKET YOU OWN
Fly.io keeps your database running, but its snapshots live in the same account and region as the database itself. An off-site copy in your own Cloudflare R2 bucket protects you from the failures the platform can't: a bad migration, a locked account, or a compliance question you need a real answer to. Here's how to set it up in a few minutes — and, crucially, how to know the backup actually restores.
Step 1 — Get your Fly.io connection string
Fly Postgres connection string (fly postgres connect details), or the credentials you set when you created the cluster.
Fly Postgres is unmanaged Postgres running as a Fly app. Make sure the database is reachable over the public internet — either allocate a public IP for the Postgres app or expose it — since backups connect from outside your Fly private network.
A read-only role is all you need. The exact CREATE ROLE SQL is on the security page.
Step 2 — Create a Cloudflare R2 bucket and access keys
- In the Cloudflare dashboard, open R2 and create a bucket.
- Create an R2 API token with Object Read & Write permission.
- Copy the S3-compatible access key ID, secret, and your account's R2 endpoint URL.
In R2, create an API token scoped to Object Read & Write. R2 gives you an S3-compatible access key ID and secret — use those. R2 has no egress fees, which makes it a popular backup target.
Endpoint: https://<account-id>.r2.cloudflarestorage.com Region: auto Bucket: your-backup-bucket
Step 3 — Connect it to OffsiteDB
Paste the Fly.io connection string, add Cloudflare R2 as your destination with the bucket and keys from Step 2, and choose a schedule (hourly to daily). OffsiteDB tests the connection, then runs pg_dump, gzips and seals the artifact with AES-256-GCM, and ships it to your bucket. It's plain Postgres; a read-only role on your schemas captures everything.
Step 4 — Know it restores (the part everyone skips)
Every snapshot is restore-drilled: OffsiteDB restores it into a throwaway Postgres cluster and counts the rows before marking it sealed. When you need it back, every artifact is a standard custom-format dump:
gunzip -c fly-db_2026-06-09.dump.gz \ | pg_restore -d "$NEW_DATABASE_URL" --clean --if-exists
You also get a monthly Restore Drill Report with tested restore times — the document you forward when someone asks “are your backups tested?”