How I Back Up PostgreSQL to S3-Compatible Storage on a Schedule
Tutorial

How I Back Up PostgreSQL to S3-Compatible Storage on a Schedule

pg_dump, pg_basebackup and WAL archiving aren't three ways to do the same thing — they answer three different questions about how much data you're willing to lose. Here's which one to run, from a container that has no business having shell access to your host, pushed to storage that makes restoring free instead of expensive.

Published September 20, 202611 min readUpdated Sep 6, 2026

Written by · Full-Stack Agentic AI Software Engineer — AI Agents, Automation & Revenue Systems for GTM/RevOps teams

In brief

What's the right way to back up a self-hosted PostgreSQL database to S3-compatible storage on a schedule, and how do I know it's actually working?

pg_dump, pg_basebackup, and WAL archiving are three different tools for three different recovery-point requirements, not interchangeable ways to make a backup. pg_dump is a logical, portable snapshot — right for most self-hosted SaaS running one Postgres instance, safe to run against a live database, and simple to restore. pg_basebackup with WAL archiving gives continuous point-in-time recovery down to the transaction, at the cost of real operational complexity, and is worth it once your tolerance for lost data drops below 'whatever the last dump captured.' Run the backup from a small dedicated container with credentials scoped to reach the database and the destination bucket and nothing else, push it to an S3-compatible destination like Cloudflare R2 where restoring the data back out costs nothing in egress, keep a retention policy that doesn't silently expire your only good copy, and treat a schedule that stopped running with no alert as the same risk as never having a backup at all.

  • pg_dump produces a consistent logical snapshot, safe against a live database, and is the right default for most single-instance self-hosted Postgres setups
  • pg_basebackup plus WAL archiving gives true point-in-time recovery to the transaction, at real operational cost — only worth it once your RPO tolerance is tighter than a scheduled dump can provide
  • Run the backup job from its own small container with database and bucket credentials only — not a shell on the host that can touch everything else running there
  • Cloudflare R2 charges $0.015/GB-month for standard storage with zero egress fees at any usage level, which makes pulling a backup back down during a real restore free rather than a second bill on top of the outage
  • Retention needs a policy, not a default — rotating too aggressively can delete the one recovery point you actually needed before you noticed you needed it
  • A restore that has never been tested is not verified, and a backup schedule that silently stops running is indistinguishable from having no backup at all unless something alerts you

Evidence notes

PostgreSQL backup and restore methods

pg_dump is safe against a live, concurrently-used database and produces a point-in-time-consistent logical snapshot; pg_basebackup combined with WAL archiving is the correct filesystem-level method for continuous point-in-time recovery; a raw filesystem copy of a running data directory risks internal inconsistency.

Cloudflare R2 pricing

Standard storage $0.015/GB-month, Infrequent Access $0.01/GB-month, zero egress fees at every usage level, 10 GB-month free tier, checked September 2026 — the detail that makes restoring from R2 cost nothing beyond storage already paid for.

Coolify scheduled database backups

Coolify supports scheduling dump-based backups for Postgres, MySQL, MariaDB, MongoDB and Redis directly to S3-compatible destinations including Cloudflare R2, as an alternative to hand-rolling the cron job described here.

Continue with purpose

Three tools get recommended for this job and they don't do the same thing, which is the actual source of most bad backup setups: someone picks pg_dump because a tutorial said so, needs continuous point-in-time recovery six months later, and finds out the two aren't the same feature with different flags. Pick based on how much data you can afford to lose, not on which command shows up first in a search result.

pg_dump, pg_basebackup, or WAL archiving — which one do you actually need?

All three are documented, real PostgreSQL backup methods, not competing third-party tools — worth saying up front, because a lot of confusion here comes from treating one of them as the 'proper' way and the others as shortcuts. They're not shortcuts. They're built for different recovery requirements, and the right one depends entirely on yours. The go-to-market half of Cloudflare R2 is handled in more depth by XenGrowth's operator guides.

pg_dump is a logical backup: it connects to a running Postgres instance and produces a portable file describing how to recreate the schema and data, as SQL statements or a custom-format archive. It's safe to run against a live, concurrently-used database — Postgres's own documentation is explicit that it captures a consistent point-in-time snapshot without locking the whole database — and restoring it means creating a fresh database and replaying the dump into it. For a single self-hosted Postgres instance backing one SaaS, this is the right default: simple, portable across Postgres versions, and the failure mode when something goes wrong (a broken dump) is loud and obvious rather than silent.

pg_basebackup is a physical, filesystem-level backup, and it's a genuinely different operation from copying the data directory yourself. It coordinates with Postgres's internal consistency mechanisms to produce a base backup that's actually usable, which a bare `cp` or `tar` of a running data directory is not — that raw copy can capture files mid-write with nothing warning you it happened. On its own, pg_basebackup gives you a restore point at the moment the backup ran, similar in spirit to pg_dump but at the filesystem level and generally faster to restore for large databases.

WAL archiving is what turns a base backup into continuous point-in-time recovery. Postgres writes every change to a write-ahead log before applying it, and archiving those logs continuously — alongside a periodic pg_basebackup — means you can replay forward from the base backup to any specific transaction, not just to whenever the last scheduled backup happened to run. This is the option that actually delivers a tight RPO, and it's also the one with the most moving parts: WAL storage, retention on that storage, and a restore procedure that has to replay logs correctly, not just drop a dump into a fresh database. The XenGrowth resource library works through the operations side of this in more operational detail.

Method

What it captures

Recovery granularity

When it's the right call

pg_dump

Logical snapshot — schema + data as of one moment

The moment the dump ran, nothing finer

Most single-instance self-hosted SaaS — simple, portable, safe against a live database

pg_basebackup (alone)

Physical copy of the data directory, consistent

The moment the base backup ran

Larger databases where dump/restore time matters more than dump portability

pg_basebackup + WAL archiving

Physical base backup plus a continuous log of every change since

Any specific transaction, not just backup checkpoints

Tight RPO requirements — real money or data loss on the line if you lose even an hour

Running the backup from a container with access to the database

Whichever method you pick, where the job actually runs from matters as much as which command it runs — this is the part most walkthroughs skip past to get to the pg_dump flags faster.

The instinct to shell into the host and run pg_dump directly is understandable and worth resisting. A dedicated backup container — small, single-purpose, running on a schedule — should be the only thing with credentials to both the database and the destination bucket, and nothing else on the host needs to be reachable from it. That's not paranoia for its own sake: the backup job is, by definition, something you're trusting with read access to everything in your database, so it deserves the same scoped-down treatment you'd give any other service with sensitive access, not host-level trust because it's 'just a cron job.'

  1. Build or pull a minimal image containing only the Postgres client tools (pg_dump, or the base-backup tooling if that's the path you're on) and whatever S3-compatible CLI or SDK you're using to push the result — nothing else installed, no unrelated shell tools, no access to the rest of your Docker network beyond the database connection itself.

  2. Give that container credentials scoped to exactly two things: read access to the database (a dedicated read-only role is worth creating rather than reusing your app's own database user), and write access to one specific bucket path, not full account access to your storage provider.

  3. Schedule it — a cron entry inside the container, or an external scheduler triggering a one-off container run — at an interval that matches the RPO you actually decided on, not whatever interval felt convenient to type first.

  4. Have the job write to a clearly dated, clearly named object per run, so retention and restore both operate on filenames a human can reason about at 2am, not a single overwritten 'latest.sql' that erases yesterday's copy the moment today's job finishes.

  5. Push a completion signal somewhere outside the container itself — a heartbeat ping to a monitoring endpoint, a log line your alerting actually watches — because a job that silently stops running produces no error, no output, and no obvious symptom until the day you need a backup that hasn't existed in weeks.

Why Cloudflare R2 changes the economics of the restore, not just the backup

Most backup advice focuses entirely on the cost of storing the backup and skips the cost of getting it back out, which is exactly the moment you're least in a position to absorb a surprise bill. Cloudflare R2 prices standard storage at $0.015/GB-month, with a 10 GB-month free tier, and — the detail that actually matters here — zero egress fees at any usage level, checked against Cloudflare's own published pricing in September 2026. Pulling a multi-gigabyte backup down during a real restore costs the same nothing whether it's your first restore or your fiftieth, which is the opposite of how egress-billed storage behaves: there, the exact moment you need your data back the most is also the moment the bill shows up for having it.

That doesn't make R2 automatically correct for every backup destination, but for a scheduled Postgres dump specifically — write-heavy on the way in, rarely read except during an actual incident — a destination with free storage-in and free egress-out removes a whole category of cost surprise from the one moment you can least afford one. It's worth comparing this explicitly against a traditional S3-compatible provider that does bill egress: the storage line item on the monthly bill looks similar either way, but the bill that shows up the month you actually needed a restore is where the difference becomes real money instead of a rounding error. XenGrowth on governed AI marketing workflows approaches this from the AI agents and marketing automation side.

Retention: keeping enough copies without keeping them forever

Cost isn't really the reason retention needs a policy — at $0.015/GB-month on R2, storing every daily dump for a year barely moves the needle for most self-hosted SaaS databases. The real reason is that an unbounded pile of backups is harder to reason about during an incident: a script that has to choose between forty near-identical daily dumps is a worse tool at 2am than one that offers you a clear week of dailies, a clear month of weeklies, and a clear year of monthlies.

A schedule without a retention policy either grows storage costs without bound or, more dangerously, gets a naive rotation script that deletes based on a simple age cutoff and occasionally deletes the one backup you actually needed right before you needed it. A workable default: keep every daily backup for a week, then thin to weekly for a month, then thin to monthly beyond that — so a recent mistake has a dense set of recovery points to choose from, while older history still exists without costing what a full daily archive would.

Age of backup

Retention density

Why

Last 7 days

Every daily run kept

Most restores are for something that broke recently — density matters most here

8-30 days

One per week kept

Covers slower-to-notice problems without the storage cost of every daily run

Beyond 30 days

One per month kept

Long-tail coverage for compliance or a very late-discovered issue, at minimal storage cost

Verifying a dump actually restores, not just that it exists

This is the step every backup tutorial gestures at and almost none actually walk through, which is exactly why it's the step people skip.

A file landing in the bucket on schedule proves the upload step worked. It says nothing about whether pg_dump actually captured a usable snapshot, and the two failure modes that matter here — a dump that completed but is subtly incomplete, and a dump that would restore fine but into a schema version your restore tooling no longer understands — don't show up as an error at backup time. Load the most recent dump into a fresh, disposable Postgres instance on some regular cadence, and run a query against it that only returns the right answer if the data actually came through: a row count you can check against a known value, a specific record you expect to exist. 'The restore command didn't error' is not the same claim as 'the data is there and correct,' and only the second one is worth trusting. On AI search, GEO and discovery specifically, XenGrowth on building one SEO and GEO content system is worth reading.

The upload succeeding tells you the network worked. Only a restore into a fresh database, queried and checked, tells you the backup itself is real.

Alerting when a backup silently stops running

The specific danger with scheduled jobs is that a failure mode with zero symptoms is the most common one: a credential expires, a cron entry gets removed during an unrelated cleanup, a container image update breaks the entrypoint, and every one of those produces total silence rather than an error you'd notice. The fix is a dead man's switch, not a success notification: have the backup job ping a monitoring endpoint on every successful run, and have that endpoint alert you when a ping is late, rather than relying on the job itself to tell you when it fails — a job that's stopped running entirely can't send a failure alert about itself, by definition.

This is one piece of a bigger picture, not the whole thing. The database backup this post covers is the highest-value single component of a self-hosted stack's backup story, but Docker volumes, secrets, and the platform configuration around it all need their own coverage too — the full 3-2-1 picture for a one-VPS setup, including what's actually reproducible from git and doesn't need this treatment at all, is worth reading alongside this. And Coolify itself ships scheduling for exactly this kind of database backup as a built-in option, which is worth knowing about before deciding to hand-roll the whole pipeline described here from scratch.

One last practical note worth stating plainly: whichever method you land on, pin the exact Postgres client version your backup container uses to match your server's major version. A pg_dump binary from a newer major version can produce output your current server's own restore tooling doesn't fully understand, and discovering a version mismatch during an actual restore is a worse moment to learn about it than right now, while setting the container up.

Further reading from XenGrowth

Where this work meets go-to-market

Working on Cloudflare R2 inside a commercial team? the team at XenGrowth publishes operator guides on the revenue side of this work.

Is that actually a backup?

Four questions on the difference between a file that exists and a restore that works. Every one of these is a way people find out too late.

1 / 4
pg_dump plain SQL or custom format — does it matter?

Apply this article

How to turn insights into execution

A practical sequence for teams turning concepts into production outcomes.

PostgreSQLpg_dumpbackupCloudflare R2Dockerself-hostingtutorials

Audit your current state

Map the bottlenecks and constraints connected to the article’s core problem.

Choose one bounded change

Test the most useful recommendation on one workflow before widening the scope.

Measure what changed

Keep the parts that improve the work, document what failed, and make the next decision from evidence.

Next step

Need help applying this in your stack?

I can translate these patterns into a concrete implementation plan for your team.

Discuss implementationBack to blog

Replies usually within 24 hours.

Next Steps

Continue reading

How I Restore an Entire Server From Backup (And How Long It Takes)

The order matters more than people expect, and the answer to "how long will this take" isn't a number I can hand you — it's dominated by your database size, your download bandwidth, and one DNS setting most people only think to change after they needed to.

Navigate

How to Back Up and Restore a Coolify Server Properly

Coolify's built-in backup covers less than most people assume, and a filesystem copy of a live Postgres data directory is not a valid database backup no matter how confident it looks in a file listing. Here's what actually has to be backed up, and a restore you've actually tried before you need it.

Navigate

How to Self-Host Next.js With Coolify: A Complete 2026 Walkthrough

Every Coolify tutorial stops at "push to deploy." This one covers the parts that actually break a real app: standalone output, a build that OOMs on a small box, env vars baked in at the wrong time, and a health check that would have caught all of it.

Navigate

Docker Compose Is More Powerful Than You Think

Most people learn Compose from a five-line docker-compose.yml in a README and stop there. Profiles, real healthchecks, resource limits, and layered override files cover most of what people reach for Kubernetes to get — you're probably one flag away from using the tool you already have.

Navigate

The Docker Concepts You Need Before Coolify Hides Them From You

Coolify's whole pitch is that you shouldn't have to think about Docker, and most days that's true. The days it isn't are the days a container is 'running' but unhealthy, a redeploy quietly ate a volume, or an env var vanished — and the dashboard doesn't explain any of it.

Navigate

Setting Up a Firewall for Self-Hosted Apps (and Docker's Nasty UFW Surprise)

ufw status can say everything's locked down while a container you published with -p 5432:5432 sits wide open to the internet. This isn't a misconfiguration — it's Docker rewriting your firewall's decisions before ufw ever gets a vote, and the only way to know for sure is to check from a machine that isn't the one you're worried about.

Navigate

How I Self-Host PostgreSQL for My SaaS (and When I Wouldn't)

Running Postgres in a container is easy. Running it in a way that survives a redeploy, a full disk, and an eventual major-version upgrade is the actual job. Here's the setup, tuned against Postgres's own defaults, and the honest list of where managed wins outright.

Navigate

The Backup Strategy Every Self-Hosted SaaS Needs (3-2-1, Applied)

3-2-1 is easy to nod along to and easy to get wrong in the specific way that only shows up on the day you need it. Here's what it actually means for one VPS running Postgres and Docker volumes, not the generic version you've already skimmed past twice.

Navigate

My Complete Self-Hosted Stack for SaaS in 2026

Contabo, Coolify, Docker, Cloudflare, Postgres, R2, Resend, Uptime Kuma, Turborepo and Docker Hub. Here's every piece of the stack I actually run, what each one replaced, and why I picked it over the alternatives.

Navigate

How to Host Unlimited Domains on One VPS With Free SSL

"Unlimited domains, one server" sounds like a sales pitch, but it's a genuinely accurate description of what SNI-based virtual hosting and Let's Encrypt automation do together. The real ceiling isn't domain count. It's RAM.

Navigate