Guide

Back up DigitalOcean Managed Postgres to your own S3 bucket

DigitalOcean's managed backups keep about seven days, at cluster level, and you can't download them. Here is how to take a logical dump of each database to an S3-compatible bucket you control — first by hand with cron, then automatically.

The same approach works for MySQL and for other managed providers (Aiven, Supabase, Scaleway, RDS). Useful with or without dbferry.

What DigitalOcean's built-in backups do and don't give you

DigitalOcean Managed Databases take automatic daily backups with point-in-time recovery. What that doesn't cover:

For a portable, long-lived, per-database archive you own, you take your own logical dumps.

Step 1 — create a least-privilege backup role

Don't dump as doadmin. Create a read-only role that can connect and read, nothing more:

-- run once, as doadmin, on each database you back up
CREATE ROLE dbferry_backup LOGIN PASSWORD '…';
GRANT CONNECT ON DATABASE client_b TO dbferry_backup;
GRANT USAGE ON SCHEMA public TO dbferry_backup;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO dbferry_backup;
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO dbferry_backup;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT ON TABLES TO dbferry_backup;
Trusted sources: DigitalOcean firewalls the cluster. Add the machine that runs the dump (your droplet's IP) to the database's Trusted Sources, or it can't connect.

Step 2 — dump one database, compressed, over TLS

Managed connections require TLS (sslmode=require). Use the custom format so you can restore selectively later:

pg_dump --format=custom --no-owner --no-privileges \
  "postgresql://dbferry_backup:PASS@db-x.db.ondigitalocean.com:25060/client_b?sslmode=require" \
  | zstd -19 > client_b.dump.zst

Step 3 — push it to your S3 bucket

Any S3-compatible storage works — AWS S3, DigitalOcean Spaces, Cloudflare R2, Backblaze B2, Wasabi. With Spaces:

aws s3 cp client_b.dump.zst \
  s3://my-backups/client_b/$(date +%Y/%m/%d-%H%M).dump.zst \
  --endpoint-url https://fra1.digitaloceanspaces.com

Step 4 — automate with cron (and encrypt)

Wrap it in a script and schedule it. Since the dump leaves your machine and lands in object storage, encrypt it before it goes — client-side, with a key the storage provider never sees. age is a good fit:

#!/usr/bin/env bash
# /opt/backup/client_b.sh — one line in crontab: 0 3 * * *
set -euo pipefail
ts=$(date +%Y/%m/%d-%H%M)
pg_dump --format=custom "$DSN" \
  | zstd -19 \
  | age -r "$AGE_RECIPIENT" \
  | aws s3 cp - "s3://my-backups/client_b/$ts.dump.zst.age" --endpoint-url "$SPACES"

That pipeline never writes the dump to local disk — it streams dump → compress → encrypt → upload in one pass. Repeat the block per database, add log capture and alerting, and you have a real backup job.

The parts cron won't do for you: per-database fan-out across a growing cluster, GFS retention (keep 7 daily / 4 weekly / 12 monthly, prune the rest), failure alerts and a dead-man's switch, multipart upload for databases bigger than a few GB, and a tested restore path. That's the gap a backup product fills.

Or let dbferry run it

dbferry is exactly this pipeline — pg_dump | zstd | age | S3 multipart — as a scheduled service against your DigitalOcean cluster, backing up each database separately into your own bucket, with retention, failure alerts, and a dead-man's switch. Client-side encryption, zero data retention: the age key stays yours.