# Automate PostgreSQL Backups to S3 on a Schedule

> A managed database backs itself up daily, but a portable copy in your own S3 bucket is yours to keep. How to schedule pg_dump to object storage with cron.

1. [Home](/)
2. [Blog](/blog)
3. How to Automate PostgreSQL Backups to S3 on a Schedule

DatabasesJune 22, 20267 min read

# How to Automate PostgreSQL Backups to S3 on a Schedule

A managed database backs itself up daily — but a second, portable copy in your own S3 bucket is yours to keep. Here's how to schedule pg\_dump to object storage with a cron job, and when it's worth doing.

## The short version

Your managed database already backs itself up. A scheduled pg\_dump to your own S3 bucket adds a second, portable copy that does not live in your provider's account. It is one script and one cron job, and it is worth doing when you need portability, a longer retention window, or a restore path you control.

[Teo Marquardt](/about#author)

Your managed database already backs itself up every night. So why write a script to copy it somewhere else? For a lot of projects the honest answer is that you don't have to. But there's a specific kind of failure that a provider's own backups can't save you from, and the fix is one scheduled job. This is about when a second copy earns its keep, and how to set one up without turning it into a weekend project.

## Doesn't a managed database already back itself up?

It does, and it does it well. [Runsite's managed PostgreSQL](/services/postgresql) takes a daily automated backup, keeps it for up to 30 days, and supports [point-in-time recovery](/blog/postgres-point-in-time-recovery), so you can roll the database back to any second inside that window. For everyday accidents like a bad migration, a DELETE that forgot its WHERE clause, or the table someone dropped at 2am, that's exactly what you want, and it's already running.

The gap isn't quality. It's that every one of those copies lives in the same place: same provider, same account, same 30-day window. That's a single point of failure dressed up as a backup. The old sysadmin answer to this is the 3-2-1 rule: keep three copies of your data, on two kinds of storage, with one of them somewhere else entirely. A provider snapshot covers the first copy. A dump in your own bucket covers the rest.

Here are a few situations where that second copy stops being paranoia:

* **You need to keep backups longer than 30 days.** Retention caps out at a month. If an audit, a compliance rule, or your own archive policy says "keep a yearly snapshot for seven years," the provider window can't do that on its own.
* **You want a copy you can take with you.** A pg\_dump file in a bucket you control isn't tied to any platform. You can restore it to a laptop, another host, or a different provider without asking anyone's permission. It's an exit plan you hope to never use and are glad to have.
* **You want disaster recovery beyond the provider.** Account lockout, a billing dispute, a region-wide incident: rare, but if your only backups live inside the account you just lost access to, they may as well not exist. A separate bucket, ideally a separate account, is cheap insurance.

## What a good automated backup actually needs

Before any code, it helps to know what separates a real backup routine from a pg\_dump someone ran once and forgot. A few things have to be true:

* It runs on a schedule, without anyone remembering to trigger it.
* It's compressed, because a raw SQL dump of a real database is large and mostly text.
* Each file has a unique, date-stamped name, so a new backup never overwrites yesterday's.
* It fails loudly. A backup job that silently stops working is worse than no backup, because you'll trust it right up until you need it.
* Old copies get cleaned up on a lifecycle policy, so storage doesn't grow forever.

## The backup script

The core of it is three commands chained together: dump the database, compress the output, and upload it to object storage.

bash

```
#!/bin/bash
set -euo pipefail

# Dump, compress, and stream straight to a date-stamped object
pg_dump "$DATABASE_URL" | gzip | \
  aws s3 cp - "s3://my-backups/postgres/$(date +%F).sql.gz"
```

Reading it left to right: [pg\_dump](https://www.postgresql.org/docs/current/app-pgdump.html) reads the whole database from the connection string in $DATABASE\_URL and writes plain SQL to standard output. gzip compresses that stream on the fly. The aws s3 cp - command takes the compressed stream and uploads it as a single object, named with the current date so each run lands in its own file. The set -euo pipefail line at the top matters more than it looks. Without it, if pg\_dump fails halfway, the script can still upload a truncated file and exit as if nothing went wrong.

One detail trips people up: if your bucket is S3-compatible storage rather than AWS itself (Runsite's is), the CLI needs an `--endpoint-url` flag so it talks to the right host instead of guessing an AWS region. The exact value is in your storage settings. Same command, one extra flag.

Keep credentials out of the command

Store the connection string and your storage keys in environment variables, not in the script itself. Anything printed on the command line can end up in logs, and a database URL with a password in it is not something you want sitting in plaintext.

## Scheduling it as a cron job

A script you have to run by hand isn't automation. The job needs something to trigger it every day, on time, whether or not your laptop is on. That's what [managed cron jobs](/services/cron-jobs) are for: you give it a schedule and a container, and it runs the job for you. A schedule is the right shape here because the work has a time and nobody is waiting for it; [when a cron job is the wrong shape and you need a worker or a queue instead](/blog/cron-jobs-workers-queues) is the broader version of that decision.

Since each run executes a Docker container, the image you point it at needs pg\_dump and the aws CLI available inside it. The official Postgres image with the AWS CLI added covers it, or a small purpose-built image if you'd rather keep it lean. Whatever you pick, match the Postgres client version to your database so the dump format lines up.

A daily backup at 2am is a standard cron expression:

bash

```
# At 02:00 every day
0 2 * * *
```

A few settings turn this from "runs the script" into "runs the script safely":

* **Timezone.** Pin the schedule to a timezone so 2am means 2am where you expect, not wherever the server happens to think it is.
* **A timeout.** A backup that hangs shouldn't run forever. Give it a generous ceiling and let the platform kill it if it blows past.
* **Concurrency set to Forbid.** If last night's backup is somehow still running when tonight's is due, you don't want two pg\_dumps fighting over the same database. Forbid skips the new run instead of piling on. On Runsite that's the default.
* **A failure alert.** Wire up an email or webhook notification on a non-zero exit code. This is the part that makes the whole thing trustworthy: you find out the night it breaks, not the morning you need to restore.

Runsite's cron jobs keep a history of every run with its exit code and duration, so you can glance at the log and confirm last night's backup actually finished, not just that it started.

## Where the copy lives

The destination is an [S3-compatible bucket](/services/s3-storage) you own, kept separate from the provider's own snapshots. That separation is the whole point: a separate copy, somewhere else, doing a different job from the managed backup. If buckets are new to you, or you're weighing up which provider to keep the copy with, [how S3-compatible object storage works](/blog/s3-compatible-object-storage) covers the model and the things worth checking before you commit data to one.

Set a lifecycle rule on the bucket to match your retention policy. Maybe daily backups expire after 90 days while the first-of-the-month copy is kept for a year. The bucket handles the cleanup, so the script never has to.

Worth a thought while you're here: encryption. Runsite's object storage encrypts every object at rest with AES-256, so the dump isn't sitting in the clear once it lands. If you want it encrypted before it ever leaves the job, pipe the stream through gpg in the script and hold the key yourself, which keeps the backup readable only to you even if the bucket is ever exposed.

If your database, cron job, and bucket all sit in the same region, the backup never leaves it. On Runsite, [PostgreSQL](/services/postgresql), Cron Jobs, and object storage all run in the EU (Germany), so the dump travels between services in one region instead of crossing the open internet between three different providers.

## Restoring from it

A backup you've never restored isn't a backup, it's a hope. Test the round trip at least once so you know the file is good and you know the steps before you need them in a hurry.

Restoring reverses the script: pull the object down, decompress it, and pipe it into psql.

bash

```
aws s3 cp "s3://my-backups/postgres/2026-06-22.sql.gz" - | \
  gunzip | psql "$TARGET_DATABASE_URL"
```

The usual snag is role ownership: restore into a database with different roles than the original and psql will complain about missing owners. Adding `--no-owner` to the dump command sidesteps it by leaving ownership to whoever runs the import. That one flag aside, the full process, including restoring into a fresh database and the odd large-object edge case, is worth reading once in the [Runsite docs](https://docs.runsite.app) rather than improvising during an incident.

## When a second copy is worth it

Not every project needs this. If you're running a side project or an early-stage app, the managed daily backups with 30-day retention and point-in-time recovery are genuinely enough, and adding a second pipeline is effort you could spend elsewhere.

Reach for your own copy when one of these sounds like you:

* You're in a regulated space and 30 days of retention won't satisfy an auditor.
* You'd sleep better knowing you could walk to another provider tomorrow with your data in hand.
* Losing access to your provider account would be an extinction event, not an inconvenience.
* You've already lived through one bad restore and have no intention of repeating it.

The good news is that the whole thing is one script and one scheduled job. You're not building a backup system from scratch; you're adding a second, portable copy on top of the one you already have. [Schedule your first backup job](/services/cron-jobs) and keep a copy that's yours to take anywhere.

Related service

## Cron Jobs on Runsite

Deploy from a single git push and keep active apps warm — no cold starts, hosted entirely in the EU with a signed GDPR DPA on every plan.

[Explore Cron Jobs](/services/cron-jobs)

[Back to all articles](/blog)

FAQ

## Frequently Asked Questions

Common questions about this service.

### Isn't a managed database backup enough?

For most projects, yes. Managed PostgreSQL on Runsite runs daily backups with up to 30 days of retention plus point-in-time recovery, which covers the common failures like a bad migration or a dropped table. A second copy in your own bucket is worth adding when you need longer retention, a portable copy you can restore anywhere, or disaster recovery that survives losing access to the provider account.

### How often should I back up Postgres to S3?

A daily backup is the common default and fits most applications. If your data changes constantly and you can't afford to lose a day, lean on point-in-time recovery for fine-grained rollback and use the S3 dump as your longer-term, off-site copy. Match the frequency to how much data you're willing to lose, not to a habit.

### How do I restore from an S3 backup?

Download the object, decompress it, and pipe it into psql against the target database: aws s3 cp s3://.../backup.sql.gz - | gunzip | psql $DATABASE\_URL. Test the restore at least once before you rely on it, since a backup you've never restored is an untested assumption. The full process is documented in the Runsite docs.

Keep reading

## Related articles

[Databases10 min readPoint-in-Time Recovery: How WAL Archiving Saves You After a DROP TABLEYour nightly backup answers "do we have a copy?" Point-in-time recovery answers "can we go back to 3:06pm?" Here's what the write-ahead log stores, how PITR replays it, and what it still can't undo.Jul 22, 2026Read](/blog/postgres-point-in-time-recovery)[Storage11 min readS3-Compatible Object Storage: How It Works and How to Choose a Provider"S3" names three separate things: a product, a storage model, and an API. This guide separates them — how buckets actually behave, what "S3-compatible" guarantees, where that compatibility runs out, and the axes worth comparing providers on.Jul 29, 2026Read](/blog/s3-compatible-object-storage)[Databases11 min readSelf-Hosted vs Managed PostgreSQL: The Real Cost of OwnershipThe €5 VPS looks cheaper than the €14 managed plan — until you price the hours. A line-by-line cost of ownership for self-hosting Postgres versus managed, the risk nobody puts on the invoice, and when self-hosting actually wins.Jul 27, 2026Read](/blog/self-hosted-vs-managed-postgresql)

## Your app deserves to be online

€5 of credit on signup. Deploy in under a minute. No credit card needed.

[Start deploying](https://dashboard.runsite.app/login)[View documentation](https://docs.runsite.app)

---

Source: https://runsite.app/blog/postgresql-backup-to-s3
