pgBackRest is the backup tool most production PostgreSQL teams end up with once pg_dump
stops scaling. It takes physical backups of the whole cluster, archives every WAL segment, and
restores to any second inside your retention window: point-in-time recovery (PITR). It handles
multi-terabyte databases with parallel compression, encryption, block-level incrementals and
object storage like S3.
This guide starts with the concepts, builds a working setup on Rocky Linux / AlmaLinux 9 with PostgreSQL 17, and then moves to the advanced parts: multiple repositories, asynchronous archiving, retention tuning, PITR, partial restores, standbys and monitoring. Every configuration option and command flag used is explained where it first appears.
The commands and option names match pgBackRest 2.60. Check pgbackrest version on your host;
older releases lack some options (block incremental needs 2.46 or later).
pg_dump vs pgBackRest: Logical vs Physical Backups
pg_dump produces a logical backup: SQL or
a custom archive you replay into a running server. It is perfect for moving one database between
versions, but a restore re-runs every INSERT and rebuilds every index, and you can only go back
to the moment the dump started.
pgBackRest takes a physical backup: a copy of the data directory’s files, plus the write-ahead log (WAL). That gives you:
- Fast restores: files are copied back, not replayed as SQL.
- PITR: replay WAL to any moment, e.g. one second before someone ran
DELETEwithout aWHERE. - Incrementals: after the first full, only changed files (or blocks) are copied.
The trade-off: a physical backup restores to the same PostgreSQL major version and the same
architecture. Use pg_dump for version migrations, pgBackRest for disaster recovery.
How pgBackRest Works
A usable physical backup needs two things.
- A base backup. pgBackRest tells PostgreSQL a backup is starting (
pg_backup_start), copies the data directory while the database keeps running, then callspg_backup_stop. Files copied mid-write are inconsistent on their own. - The WAL archive. PostgreSQL writes every change to WAL in 16 MB segments. With
archive_mode=on, PostgreSQL hands each finished segment toarchive_command, which ispgbackrest archive-push. The WAL generated during the backup is what makes it consistent; the WAL generated after it is what makes PITR possible.
A restore copies a base backup into place, writes a restore_command (pgbackrest archive-get)
into the restored config, and starts PostgreSQL. PostgreSQL replays WAL from the archive until it
reaches the target you chose, or the end of the archive.
Key Terms
| Term | Meaning |
|---|---|
| Stanza | The backup configuration for one PostgreSQL cluster: its data directory, and where its backups go. Named in [brackets] in the config. |
| Repository (repo) | Where backups and WAL are stored: a local path, S3, Azure, GCS, SFTP, or a remote host. Numbered repo1, repo2, … |
| Full backup | A complete copy of the data directory. Label ends in F, e.g. 20261024-210001F. |
| Differential (diff) | Files changed since the last full. Label ends in D. Restoring needs the full plus this diff. |
| Incremental (incr) | Files changed since the last backup of any type. Label ends in I. Restoring needs the full, possibly a diff, and every incr in the chain. |
| Expire | Removing backups (and the WAL only they need) beyond the retention policy. |
| Backup set | One backup, identified by its label. |
Step 1: Install pgBackRest
This guide assumes PostgreSQL 17 from the PGDG repository, set up as in
How to Install and Configure Postgres 17 on Rocky/Alma Linux 9.
pgBackRest is in the same repository (pgdg-common):
sudo dnf install -y pgbackrest
pgbackrest version
On Debian/Ubuntu with the apt.postgresql.org repository:
sudo apt install -y pgbackrest
The package creates the directories pgBackRest uses by default:
| Path | Purpose |
|---|---|
/etc/pgbackrest/pgbackrest.conf | Main config file |
/etc/pgbackrest/conf.d/ | Extra *.conf files, merged into the main one |
/var/log/pgbackrest/ | Per-command log files |
/var/spool/pgbackrest/ | Spool for asynchronous archiving |
/var/lib/pgbackrest/ | Default local repository path |
pgBackRest refuses to run as root by default (allow-root=n). Run every command as the
postgres user: sudo -u postgres pgbackrest ....
Step 2: Understand the Configuration File
pgbackrest.conf is an INI file. Sections set options for different scopes:
[global]: applies to every stanza and command.[global:<command>]: applies to one command only, e.g.[global:archive-push].[<stanza>]: applies to one stanza, e.g.[main].[<stanza>:<command>]: one stanza and one command.
Every option can also be passed on the command line (--process-max=4) or as an environment
variable (PGBACKREST_PROCESS_MAX=4). The command line wins over the environment, which wins
over the file. Options that refer to a repository or a PostgreSQL instance carry an index:
repo1-path, repo2-path, pg1-path.
Step 3: A First Configuration with a Local Repository
Start with the simplest possible setup: one stanza, repository on a local disk. (In production, put the repository on different hardware. Step 9 moves it to S3.)
# /etc/pgbackrest/pgbackrest.conf
[global]
repo1-path=/var/lib/pgbackrest
repo1-retention-full=2
log-level-console=info
log-level-file=detail
start-fast=y
[main]
pg1-path=/var/lib/pgsql/17/data
What each option does:
| Option | Meaning |
|---|---|
repo1-path | Where repo1 stores backups and WAL. Default /var/lib/pgbackrest. For object stores, a path inside the bucket. |
repo1-retention-full | How many full backups to keep (see Step 8). With no retention set, pgBackRest warns and keeps everything. |
log-level-console | What prints to the terminal: off, error, warn (default), info, detail, debug, trace. info shows progress. |
log-level-file | What goes to /var/log/pgbackrest/<stanza>-<command>.log. Default info; detail records every file copied. |
start-fast | Default n. With y, the backup forces an immediate checkpoint instead of waiting for the next scheduled one (up to checkpoint_timeout, 5 min by default). |
pg1-path | The data directory of the cluster this stanza backs up. |
The [main] section name is the stanza name; any name works. Use something that identifies
the cluster, because it is part of every command you run.
Step 4: Configure PostgreSQL to Archive WAL
Add these to postgresql.conf:
archive_mode = on
archive_command = 'pgbackrest --stanza=main archive-push %p'
wal_level = replica
max_wal_senders = 3
archive_mode = on: PostgreSQL callsarchive_commandfor every finished WAL segment. Needs a restart, not just a reload.archive_command:%pis the path of the segment. PostgreSQL only recycles a segment after this command exits 0, so a failing archive makespg_walgrow until it succeeds.wal_level = replica: the minimum for archiving and physical backups. It is the default since PostgreSQL 10; just don’t set it tominimal.max_wal_senders: replication connections; only needed for standbys, harmless otherwise.
Optionally, force a segment switch on quiet databases so the archive is never more than a minute behind:
archive_timeout = 60
archive_timeout (seconds) caps your RPO, the data you can lose, on a low-traffic server.
Each forced switch archives a full 16 MB segment, so don’t set it too low.
Restart PostgreSQL:
sudo systemctl restart postgresql-17
Step 5: Create the Stanza and Check It
stanza-create initialises the stanza’s directories and metadata files in the repository:
sudo -u postgres pgbackrest --stanza=main stanza-create
check proves the whole path works: it confirms the config, connects to PostgreSQL, forces a WAL
switch and waits for that segment to arrive in the repository.
sudo -u postgres pgbackrest --stanza=main check
If check times out, read the PostgreSQL log: a failing archive_command logs every error
there. The usual causes are a stanza name mismatch, permissions on the repository path, and
archive_mode not taking effect because PostgreSQL was only reloaded.
Step 6: Take Your First Backups
sudo -u postgres pgbackrest --stanza=main --type=full backup
sudo -u postgres pgbackrest --stanza=main --type=diff backup
sudo -u postgres pgbackrest --stanza=main --type=incr backup
--type takes full, diff or incr (default incr). If no full exists yet, pgBackRest
silently upgrades a diff or incr to a full: a fresh stanza never has an orphaned
incremental.
Useful backup options:
| Option | Default | Meaning |
|---|---|---|
--type | incr | full, diff or incr. |
--start-fast | n | Force a checkpoint so the backup starts immediately. |
--process-max | 1 | Parallel processes for compression and transfer. The single biggest speed knob. Don’t starve the database: leave most cores for PostgreSQL. |
--compress-type | gz | none, bz2, gz, lz4 or zst. zst is the best size/speed trade-off; lz4 is fastest. |
--compress-level | per type | zst 3, gz 6, lz4 1, bz2 9. Higher is smaller and slower. |
--delta | n | Decide which files changed by checksum instead of timestamp. Slower; use it if you can’t trust file timestamps. |
--resume | y | Resume a failed backup of the same type instead of starting over (see Step 13). |
--archive-check | y | Wait until the WAL needed to make this backup consistent has been archived. Leave on. |
--archive-copy | n | Also copy that WAL into the backup itself, so it restores even if the WAL archive is lost. |
--expire-auto | y | Run expire automatically after a successful backup. |
--annotation | none | Attach key=value metadata, e.g. --annotation=reason="before v2 migration". Repeatable. |
--repo | first repo | Which repository to back up to. With several repos you must pick one per run (Step 10). |
--exclude | none | Skip a path relative to the data directory. Use sparingly: whatever you exclude is missing from every restore. |
Step 7: Inspect Backups with info
info is the main view. pgBackRest has no web UI; info, the logs and your monitoring are the
interface.
sudo -u postgres pgbackrest --stanza=main info
stanza: main
status: ok
cipher: none
db (current)
wal archive min/max (17): 000000010000000000000003/00000001000000000000000B
full backup: 20261028-081210F
timestamp start/stop: 2026-10-28 08:12:10+00 / 2026-10-28 08:12:41+00
wal start/stop: 000000010000000000000004 / 000000010000000000000004
database size: 29.5MB, database backup size: 29.5MB
repo1: backup size: 3.9MB
diff backup: 20261028-081210F_20261028-081402D
timestamp start/stop: 2026-10-28 08:14:02+00 / 2026-10-28 08:14:05+00
wal start/stop: 000000010000000000000006 / 000000010000000000000006
database size: 29.5MB, database backup size: 8.3KB
repo1: backup size: 512B
backup reference total: 1 full
How to read it:
- status:
ok, or an error such asno valid backupsormissing stanza path. - wal archive min/max: the oldest and newest WAL in the archive. You can restore to any point between the first backup’s stop time and the max segment.
- database size: the cluster’s size; database backup size: what this backup had to copy.
- repo1: backup size: the compressed size stored in the repo.
- backup reference: what the backup depends on. A diff references its full.
Options:
| Option | Meaning |
|---|---|
--output=json | Machine-readable output, for monitoring (Step 16). |
--set=<label> | Details of one backup, including its annotations and databases. |
--repo=<n> | Show only one repository. |
Step 8: Retention and Expire
expire deletes backups beyond your policy, and the WAL that only they needed. It runs after
every backup (expire-auto=y), so you rarely run it by hand.
| Option | Default | Meaning |
|---|---|---|
repo1-retention-full | none | Fulls to keep: a count, or days when the type is time. |
repo1-retention-full-type | count | count: keep the newest N fulls. time: keep fulls so that at least one is N days old. |
repo1-retention-diff | unset | Diffs to keep. Unset keeps every diff until its full expires. Fulls count as diffs here. |
repo1-retention-archive | derived | Backups’ worth of continuous WAL to keep. Leave unset: WAL is then expired only with the backups that need it. |
repo1-retention-archive-type | full | full, diff or incr: what retention-archive counts. |
repo1-retention-history | 365 | Days of backup history manifests to keep. |
Your PITR window runs from the oldest retained full to now. With weekly fulls and
repo1-retention-full=4 that’s about four weeks.
Remove one specific backup, and everything that depends on it:
sudo -u postgres pgbackrest --stanza=main --set=20261028-081210F_20261028-081402D expire
Avoid setting retention-archive aggressively to save space: PITR from a backup whose WAL was
expired is impossible.
Step 9: Store Backups in S3 with Encryption
A repository on the database host doesn’t survive the host. Put it in object storage. pgBackRest speaks the S3 API natively, so AWS S3, MinIO, Hetzner, Wasabi, Backblaze B2 and Cloudflare R2 all work.
# /etc/pgbackrest/pgbackrest.conf
[global]
repo1-type=s3
repo1-path=/pgbackrest
repo1-s3-bucket=my-pg-backups
repo1-s3-endpoint=s3.eu-west-1.amazonaws.com
repo1-s3-region=eu-west-1
repo1-retention-full=4
repo1-cipher-type=aes-256-cbc
repo1-bundle=y
repo1-block=y
process-max=4
compress-type=zst
start-fast=y
log-level-console=info
log-level-file=detail
[main]
pg1-path=/var/lib/pgsql/17/data
Keep the secrets in a separate file only postgres can read:
# /etc/pgbackrest/conf.d/secrets.conf (chmod 0640, owner root:postgres)
[global]
repo1-s3-key=<access key id>
repo1-s3-key-secret=<secret access key>
repo1-cipher-pass=<generated passphrase>
Generate the passphrase once:
openssl rand -base64 48
Store the cipher passphrase outside the server, in your secrets manager. Without it the backups cannot be decrypted, by you or anyone else. That is the point of encryption, and also the easiest way to lose every backup you have.
The S3 and encryption options:
| Option | Meaning |
|---|---|
repo1-type | posix (default, a local or mounted path), s3, azure, gcs, sftp or cifs. |
repo1-s3-bucket | The bucket name. |
repo1-s3-endpoint | The S3 API host, e.g. s3.eu-west-1.amazonaws.com or your provider’s endpoint. |
repo1-s3-region | The bucket’s region. Required even for providers that ignore it. |
repo1-s3-key / repo1-s3-key-secret | Access key pair, when repo1-s3-key-type=shared (default). |
repo1-s3-key-type | shared (keys in config), auto (EC2 instance role), web-id (EKS IRSA). |
repo1-s3-uri-style | host (default, bucket.endpoint) or path (endpoint/bucket). MinIO usually needs path. |
repo1-storage-verify-tls | Default y. Set n only for a test endpoint with a self-signed certificate. |
repo1-cipher-type | none or aes-256-cbc. Encryption happens on the client before upload, so the storage provider never sees plaintext. |
repo1-cipher-pass | The passphrase. Cannot be changed after stanza-create for that repo. |
repo1-bundle | Pack small files into bundles: far fewer objects and requests on object storage. Needed for repo1-block. |
repo1-block | Block incremental: diffs and incrementals store changed blocks of a file instead of the whole file. Large tables with small changes go from gigabytes per diff to megabytes. Most efficient when enabled from the first full. |
process-max | In [global], applies to every command: backup, restore, async archiving. |
Then create the stanza on the new repo and check it:
sudo -u postgres pgbackrest --stanza=main stanza-create
sudo -u postgres pgbackrest --stanza=main check
Step 10: Multiple Repositories
One copy is not a backup strategy. pgBackRest supports up to 256 repositories per stanza: say a nearby bucket for fast restores and a second region (or provider) for disasters.
[global]
repo1-type=s3
repo1-path=/pgbackrest
repo1-s3-bucket=pg-backups-primary
repo1-s3-endpoint=s3.eu-west-1.amazonaws.com
repo1-s3-region=eu-west-1
repo1-retention-full=4
repo1-cipher-type=aes-256-cbc
repo1-bundle=y
repo1-block=y
repo2-type=s3
repo2-path=/pgbackrest
repo2-s3-bucket=pg-backups-dr
repo2-s3-endpoint=s3.eu-central-1.amazonaws.com
repo2-s3-region=eu-central-1
repo2-retention-full=8
repo2-cipher-type=aes-256-cbc
repo2-bundle=y
repo2-block=y
Each repo gets its own keys and its own cipher passphrase (repo2-s3-key, repo2-cipher-pass).
How the commands treat several repos:
archive-pushwrites WAL to every repo.backupwrites to one repo,repo1unless you pass--repo. To back up to both, run it once per repo:sudo -u postgres pgbackrest --stanza=main --repo=1 --type=full backup sudo -u postgres pgbackrest --stanza=main --repo=2 --type=full backupThis catches people out: with two repos and a plain
backup, repo2 only ever gets WAL, never a base backup.restorelooks for a suitable backup in repo order unless you pass--repo.infoshows every repo;--reponarrows it.
Step 11: Asynchronous WAL Archiving
By default archive-push is synchronous: PostgreSQL waits for each segment to upload before
archiving the next. On a busy server, or with remote object storage, that becomes a bottleneck
and pg_wal grows. Asynchronous mode decouples the two:
[global]
archive-async=y
spool-path=/var/spool/pgbackrest
archive-push-queue-max=50GiB
[global:archive-push]
process-max=4
archive-timeout=300
[global:archive-get]
process-max=4
| Option | Default | Meaning |
|---|---|---|
archive-async | n | Push WAL from a background process in parallel batches; PostgreSQL gets a fast acknowledgement from the spool. For archive-get, prefetch WAL during recovery. |
spool-path | /var/spool/pgbackrest | Local directory for acknowledgements and prefetched WAL. Must be local disk, not NFS. |
archive-push-queue-max | unlimited | When this much un-archived WAL piles up (say the repo is down), pgBackRest tells PostgreSQL the WAL is archived and drops it. This protects the database from a full disk, at the cost of a gap in PITR until the next backup. Size it to fit comfortably on the pg_wal volume. |
archive-push-batch-size | 16GiB | Max WAL pushed per async run; lower values recheck the queue limit more often. |
archive-get-queue-max | 128MiB | How much WAL archive-get prefetches into the spool during recovery. Raise it to speed up long replays. |
archive-timeout | 1m | How long archive-push, check and backup wait for a WAL segment to reach the repo. With async mode and a slow repo, raise it. |
process-max (per command) | 1 | Parallel uploads/downloads for archiving. |
archive-push-queue-max is the most important safety option here. Without it, an unreachable
repository fills pg_wal, and when that volume is full PostgreSQL stops. With it, you lose
PITR for a window, but the database stays up.
Step 12: Schedule Backups with systemd Timers
A common schedule: weekly full, daily diff. Put the backup logic in one templated service:
# /etc/systemd/system/[email protected]
[Unit]
Description=pgBackRest %i backup
After=postgresql-17.service
[Service]
Type=oneshot
User=postgres
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
ExecStart=/usr/bin/pgbackrest --stanza=main --repo=1 --type=%i backup
ExecStart=/usr/bin/pgbackrest --stanza=main --repo=2 --type=%i backup
%i is the instance name: pgbackrest-backup@full passes --type=full. Nice and
IOSchedulingClass make the backup give way to the database. A oneshot unit runs its
ExecStart lines in order and stops at the first failure. If you’d rather repo2 still runs when
repo1 fails, use a small wrapper script that loops over the repos.
# /etc/systemd/system/pgbackrest-backup-full.timer
[Unit]
Description=Weekly pgBackRest full backup
[Timer]
OnCalendar=Sun *-*-* 01:00:00
Persistent=true
Unit=[email protected]
[Install]
WantedBy=timers.target
# /etc/systemd/system/pgbackrest-backup-diff.timer
[Unit]
Description=Daily pgBackRest differential backup
[Timer]
OnCalendar=Mon..Sat *-*-* 01:00:00
Persistent=true
Unit=[email protected]
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now pgbackrest-backup-full.timer pgbackrest-backup-diff.timer
systemctl list-timers 'pgbackrest-*'
Persistent=true runs a missed backup at boot if the server was off at the scheduled time.
Only one backup per stanza runs at a time; pgBackRest holds a lock. If a full is still running when the diff timer fires, the diff fails on the lock. That’s harmless, but plan the schedule so a slow full can finish.
Step 13: Resuming Failed Backups
With resume=y (the default), a failed backup leaves a partial backup in the repo. The next
backup of the same type checks the files already copied and continues instead of starting
over:
WARN: resumable backup 20261024-224141F of same type exists -- invalid files will be removed then the backup will resume
The condition matters. If a different type runs first, say tonight’s scheduled diff after last night’s failed full, the partial full is discarded:
INFO: backup '20261024-224141F' cannot be resumed: resume only valid for full backup
So if a long full fails, retry the full before the next diff runs, or the next attempt starts from zero.
Step 14: Restoring a Backup
Practise restores on a separate host. Never restore over a running production data
directory unless that server is already lost. The restore host needs the same PostgreSQL major
version, pgBackRest, the same pgbackrest.conf and secrets, and enough disk.
Full Restore to the Latest Point
sudo systemctl stop postgresql-17
sudo -u postgres pgbackrest --stanza=main restore
sudo systemctl start postgresql-17
By default the data directory must be empty. pgBackRest copies the latest backup, writes
restore_command into postgresql.auto.conf, and creates recovery.signal. PostgreSQL then
replays all available WAL and opens for writes.
Delta Restore
sudo -u postgres pgbackrest --stanza=main --delta restore
--delta keeps files in the existing data directory whose checksums match the backup and only
fetches the rest. Restoring a large cluster that is mostly intact (a failed upgrade, a deleted
table) becomes much faster. PostgreSQL must be stopped.
Restore Options Explained
| Option | Default | Meaning |
|---|---|---|
--set | latest | Which backup to restore, by label. |
--type | default | Recovery target type: default (replay to the end of the archive), immediate (stop as soon as consistent), time, xid, lsn, name (a pg_create_restore_point), standby (create a replica), preserve (keep existing recovery settings), none (no recovery settings). |
--target | none | The target for time, xid, lsn or name. A time is "2026-10-28 09:59:00+00". |
--target-action | pause | What PostgreSQL does at the target: pause (read-only, check the data, then SELECT pg_wal_replay_resume()), promote (open for writes), shutdown. |
--target-exclusive | n | With y, stop just before the target instead of just after it. |
--target-timeline | latest | Which timeline to follow after earlier promotions. |
--delta | n | Reuse files that match by checksum. |
--force | n | With --delta, overwrite a directory that doesn’t look like a PostgreSQL data directory. |
--db-include | all | Restore only these databases (repeatable). Others are restored as empty sparse files and cannot be connected to. |
--db-exclude | none | Restore everything except these databases. |
--tablespace-map | original | Restore a tablespace elsewhere: --tablespace-map=ts_01=/data/ts_01. --tablespace-map-all moves them all. |
--link-map | original | Change a symlink’s destination, e.g. a separate pg_wal volume. --link-all restores all links. |
--archive-mode | preserve | off sets archive_mode=off on the restored cluster. Always use off on a scratch or test restore. Otherwise the copy archives WAL into the production stanza. |
--recovery-option | none | Add a recovery setting, e.g. --recovery-option=primary_conninfo=.... Repeatable. |
--repo | search in order | Restore from a specific repository. |
--process-max | 1 | Parallel downloads and decompression. Restores are much faster with more. |
Step 15: Point-in-Time Recovery
PITR is why you run pgBackRest. Someone dropped a table at 10:00:30; restore to 10:00:00.
First find a backup that finished before the target:
sudo -u postgres pgbackrest --stanza=main info
Then restore with a time target:
sudo systemctl stop postgresql-17
sudo -u postgres pgbackrest --stanza=main --delta \
--set=20261028-081210F \
--type=time "--target=2026-10-28 10:00:00+00" \
--target-action=pause \
--archive-mode=off \
restore
sudo systemctl start postgresql-17
With --target-action=pause, PostgreSQL stops replaying at the target and stays read-only.
Check that the data is what you expect:
sudo -u postgres psql -c "select count(*) from orders"
If you need to go further, stop PostgreSQL and restore again with a later target. If it’s right, open the database for writes:
sudo -u postgres psql -c "select pg_wal_replay_resume()"
PostgreSQL promotes onto a new timeline. pgBackRest archives the new timeline’s WAL alongside the old one, so you can still PITR across the promotion later.
The most common PITR mistake is restoring a backup that finished after the target. If --set
is omitted, pgBackRest picks a backup before the target for you. If you pass a --set that
finished after the target, PostgreSQL ignores the target and replays to the end of the archive,
which is exactly what you didn’t want. Always check the timestamp start/stop line first.
Restoring a Single Database
To recover one database without the rest of the cluster, restore with --db-include on a scratch
host, then dump it:
sudo -u postgres pgbackrest --stanza=main --db-include=shop \
--type=time "--target=2026-10-28 10:00:00+00" --target-action=promote \
--archive-mode=off restore
sudo systemctl start postgresql-17
sudo -u postgres pg_dump -Fc -d shop -f /tmp/shop.dump
Load the dump into production with pg_restore. You need disk for the included databases only,
because the excluded ones are restored as sparse zero-filled files.
Step 16: Monitoring pgBackRest
A backup that silently stopped working is worse than none. Watch three things.
1. Backups are recent. The JSON from info gives each backup’s stop time:
sudo -u postgres pgbackrest --stanza=main --output=json info \
| jq -r '.[0].backup[-1] | "\(.label) \(.type) \(.timestamp.stop)"'
Alert when the newest backup is older than your schedule plus some slack, and separately when the newest full is older than a week and a bit. A run of successful diffs can hide a weekly full that keeps failing.
For Prometheus, one simple approach: have your backup wrapper write a textfile-collector metric on
success, e.g. pgbackrest_last_success_timestamp_seconds{repo="1",type="full"}, and alert on
time() - metric. The textfile collector is part of the
Prometheus node exporter. A
standalone exporter such as pgbackrest_exporter reads info for you instead.
2. WAL is being archived. PostgreSQL tracks archiving in pg_stat_archiver:
sudo -u postgres psql -c "select archived_count, last_archived_time, failed_count, last_failed_time from pg_stat_archiver"
last_archived_time must keep moving. A rising failed_count alone is normal, since PostgreSQL
retries failed segments. Archiving is broken when last_archived_time stops advancing. With
postgres_exporter, pg_stat_archiver_last_archive_age is the seconds since the last archived
segment: alert when it exceeds, say, 900 on a database that writes continuously.
3. The stanza is healthy. Run check periodically; it exits non-zero if archiving to any repo
fails:
sudo -u postgres pgbackrest --stanza=main check
Logs for every command are in /var/log/pgbackrest/main-<command>.log. When run under systemd,
the console output is also in journalctl -u 'pgbackrest-backup@*'.
Step 17: Verify the Repository
verify reads the repository and checks every backup file and WAL segment against the checksums
recorded when they were written. It catches silent corruption in storage before you need the
backup:
sudo -u postgres pgbackrest --stanza=main --repo=1 verify
It reads the whole repository, so it costs bandwidth and, on object storage, request and egress
charges. Monthly is a reasonable cadence. verify does not replace a restore test: the only
proof a backup restores is restoring it. Schedule a real restore to a scratch host, time it, and
write the number down. That’s your RTO.
Step 18: Day-Two Operations
Pause pgBackRest: stop and start
stop prevents new pgBackRest commands for a stanza (or all stanzas without --stanza);
--force also terminates running ones. Archiving fails while stopped, so WAL builds up in
pg_wal:
sudo -u postgres pgbackrest --stanza=main stop
sudo -u postgres pgbackrest --stanza=main start
After a PostgreSQL Major Upgrade: stanza-upgrade
After pg_upgrade (say 17 to 18), the system identifier and version change, and archiving fails
until the stanza is upgraded:
sudo -u postgres pgbackrest --stanza=main stanza-upgrade
sudo -u postgres pgbackrest --stanza=main --type=full backup
Update pg1-path to the new data directory first, and take a new full right away. Backups from
the old version stay in the repo, restorable to the old version only.
Annotate Backups
Add or change metadata on an existing backup. An empty value removes a key:
sudo -u postgres pgbackrest --stanza=main --set=20261028-081210F \
--annotation=reason="pre-migration" annotate
sudo -u postgres pgbackrest --stanza=main --set=20261028-081210F info
Browse the Repository: repo-ls and repo-get
sudo -u postgres pgbackrest --stanza=main --repo=1 repo-ls backup/main
sudo -u postgres pgbackrest --stanza=main --repo=1 repo-get backup/main/backup.info
repo-ls lists paths inside the repo (relative to repo-path). repo-get prints a file,
decrypting and decompressing it. Useful for confirming a partial backup exists, or what is
actually in a bucket, without touching S3 credentials yourself.
Remove a Stanza: stanza-delete
Permanently deletes every backup and WAL of a stanza from a repository:
sudo -u postgres pgbackrest --stanza=main stop
sudo -u postgres pgbackrest --stanza=main --repo=1 stanza-delete
PostgreSQL must be stopped or you need --force. There is no undo.
Emergency: Stop Archiving Without a Restart
If the repository is down for a long time and pg_wal is about to fill, switch archiving to a
no-op and reload:
sudo -u postgres psql -c "ALTER SYSTEM SET archive_command = '/bin/true'"
sudo -u postgres psql -c "SELECT pg_reload_conf()"
Never use an empty string. With archive_mode=on, an empty archive_command means “keep
every segment”, and pg_wal fills. When the repo is back, ALTER SYSTEM RESET archive_command,
reload, and take a new full backup, since the WAL discarded in between is a PITR gap.
Advanced Topologies
Backup from a Standby
To keep backup I/O off the primary, read the data files from a streaming replica:
[main]
pg1-path=/var/lib/pgsql/17/data
pg2-host=standby.example.com
pg2-path=/var/lib/pgsql/17/data
backup-standby=y
backup-standby=y copies files from the standby, while the primary still handles
pg_backup_start and pg_backup_stop and archives WAL. prefer falls back to the primary when
no standby is available. The standby is reached over SSH or pgBackRest’s TLS server, so both
hosts need pgBackRest installed and configured.
Dedicated Repository Host
For fleets of databases, run pgBackRest on a separate repository host that pulls backups from
every database server over SSH or TLS (pgbackrest server, repo1-host, pg1-host). Database
hosts then never hold repository credentials. It’s more to run; for one or two databases, direct
object storage is simpler.
Create a Standby from a Backup
--type=standby restores a backup as a streaming replica that starts in standby mode:
sudo -u postgres pgbackrest --stanza=main --type=standby \
--recovery-option=primary_conninfo="host=primary.example.com user=replicator" \
restore
The new standby fetches missing WAL from the archive and then streams from the primary. Building a
replica from object storage doesn’t load the primary the way pg_basebackup does.
Lessons from Running pgBackRest in Production
These come from running pgBackRest against a ~1 TB PostgreSQL cluster with two object-storage repositories in different regions.
archive_modeneeds a restart. We shipped the config and created the stanza, then waited for a maintenance window to restart. For days every backup failed witharchive_mode must be enabled. Alert on backup age from day one.backuponly writes torepo1unless you pass--repo. Loop over the repos.- Object storage has bad nights. One region returned
504 Gateway Timeoutand dropped connections for 1–2 hours two Saturdays in a row, exactly when the weekly full was uploading. A few short retries can’t outlast that. Retry for hours, not minutes, since resume keeps each attempt’s progress. - A diff in between kills the resume of a failed full, so retry the full first.
- Raise
archive-timeoutfor slow repos. At the 1-minute default, a small share of WAL pushes to a slow region timed out and were retried. 300 seconds made the noise go away. - Mind the network. A full of 1 TB to two repos reads the database twice. With
process-max=8it can saturate a 1 Gbps link that also carries application traffic. Schedule fulls off-peak and give the backup a low priority withNiceandIOSchedulingClass. - Retention per repo can differ. A short window in the fast repo and a longer one in the DR repo costs little with block incremental.
Command Cheat Sheet
| Task | Command |
|---|---|
| Create stanza | pgbackrest --stanza=main stanza-create |
| Check archiving end to end | pgbackrest --stanza=main check |
| Full backup to repo1 | pgbackrest --stanza=main --repo=1 --type=full backup |
| Differential backup | pgbackrest --stanza=main --type=diff backup |
| List backups | pgbackrest --stanza=main info |
| JSON for monitoring | pgbackrest --stanza=main --output=json info |
| Restore latest | pgbackrest --stanza=main restore |
| Delta restore | pgbackrest --stanza=main --delta restore |
| PITR | pgbackrest --stanza=main --type=time "--target=..." --target-action=pause restore |
| One database | pgbackrest --stanza=main --db-include=shop restore |
| Expire one backup | pgbackrest --stanza=main --set=<label> expire |
| Verify repository | pgbackrest --stanza=main --repo=1 verify |
| After major upgrade | pgbackrest --stanza=main stanza-upgrade |
| Pause / resume | pgbackrest --stanza=main stop / start |
| Browse repo | pgbackrest --stanza=main repo-ls backup/main |
| Built-in help | pgbackrest help backup / pgbackrest help restore type |
Run all of them as postgres (sudo -u postgres ...). pgbackrest help <command> <option> prints
the full documentation of any option for your installed version.
Related Content
- How to Backup and Restore a Postgres Database (logical backups with
pg_dump) - How to Install and Configure Postgres 17 on Rocky/Alma Linux 9
- Postgres Permissions: Create, Update, Delete Database Users
- How to Install and Configure Prometheus on a Linux Server
Conclusion
pgBackRest turns PostgreSQL backups from a nightly dump into continuous protection: weekly fulls,
small block-incremental diffs, every WAL segment archived to encrypted object storage in more than
one place, and restores to any second in the retention window. Set it up in this order: archiving
and check, then backups, then retention and a second repository. Then add the parts people
skip: alerts on backup age and archiving, a periodic verify, and a timed restore to a scratch
host. A backup you’ve never restored is a hope, not a plan.