pgBackRest Tutorial: Back Up PostgreSQL Properly
Chat2DB TeamHand-rolled PostgreSQL backups start as a pg_basebackup in cron and an archive_command that copies WAL to a directory. They work until the database grows, and then the full backup takes six hours, nobody has verified a restore in a year, and the archive directory has quietly filled the disk.
pgBackRest exists to replace that script. It does parallel compressed backups, incremental and differential backups, checksums on every file, retention policies, encryption, and restores directly to a point in time — with a command set small enough to memorise.
Install and lay out the repository
pgBackRest is packaged for most distributions and available from the PGDG repositories:
sudo apt install pgbackrest # Debian / Ubuntu
sudo dnf install pgbackrest # RHEL / Rocky / FedoraThe most important design decision comes first: where the repository lives. Putting it on the same machine as the database is the mistake that turns a disk failure into data loss. Use a dedicated backup host or object storage.
Create the directories and set ownership on the repository host:
sudo mkdir -p /var/lib/pgbackrest
sudo chmod 750 /var/lib/pgbackrest
sudo chown postgres:postgres /var/lib/pgbackrestConfigure pgbackrest.conf
The config file is /etc/pgbackrest/pgbackrest.conf. A minimal local-repository setup looks like this:
[global]
repo1-path=/var/lib/pgbackrest
repo1-retention-full=2
repo1-retention-diff=4
repo1-bundle=y
repo1-block=y
process-max=4
log-level-console=info
log-level-file=detail
start-fast=y
[global:archive-push]
compress-level=3
[app]
pg1-path=/var/lib/postgresql/17/main
pg1-port=5432The [app] section defines a stanza — pgBackRest's name for one PostgreSQL cluster and its backups. You will pass --stanza=app to every command.
A few settings earn their place:
process-max=4parallelises compression and transfer. On a machine with spare cores this is the single biggest speed win; set it to roughly half your core count.repo1-retention-full=2keeps two full backups and everything needed to restore from them, expiring the rest automatically.start-fast=yissues an immediate checkpoint instead of waiting for a spread one, so backups begin promptly.repo1-bundle=ypacks small files together, which dramatically speeds up backups of databases with thousands of tiny relations.
For S3-compatible object storage, swap the repository block:
[global]
repo1-type=s3
repo1-path=/pgbackrest
repo1-s3-bucket=my-pg-backups
repo1-s3-endpoint=s3.us-east-1.amazonaws.com
repo1-s3-region=us-east-1
repo1-s3-key=AKIA...
repo1-s3-key-secret=...
repo1-retention-full=4
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=a-long-random-passphraseEncryption is client-side: pgBackRest encrypts before upload, so the storage provider never sees plaintext. Store that passphrase somewhere you will still have it after losing the database host — without it the backups are unreadable.
Point PostgreSQL at pgBackRest
In postgresql.conf:
archive_mode = on
archive_command = 'pgbackrest --stanza=app archive-push %p'
wal_level = replica
max_wal_senders = 3archive_mode and wal_level require a restart. Then create the stanza and verify:
sudo -u postgres pgbackrest --stanza=app stanza-create
sudo -u postgres pgbackrest --stanza=app checkcheck is the command to trust. It forces a WAL segment switch, confirms the segment lands in the repository, and validates that the configuration on both ends agrees. If check passes, archiving genuinely works — which is more than a green cron job tells you.
Taking backups
# Full: copies everything
sudo -u postgres pgbackrest --stanza=app --type=full backup
# Differential: changes since the last full
sudo -u postgres pgbackrest --stanza=app --type=diff backup
# Incremental: changes since the last backup of any type
sudo -u postgres pgbackrest --stanza=app --type=incr backupA workable schedule for a mid-sized database: a full backup weekly, a differential daily, and incrementals every few hours. Differentials keep restores simple, because a restore needs only the last full plus one differential plus any incrementals after it — not a chain of seven days of increments.
Inspect what you have:
sudo -u postgres pgbackrest --stanza=app infostanza: app
status: ok
cipher: aes-256-cbc
db (current)
wal archive min/max (17): 000000010000000000000021/00000001000000000000004F
full backup: 20260818-020000F
timestamp start/stop: 2026-08-18 02:00:00 / 2026-08-18 02:11:43
database size: 142.3GB, database backup size: 142.3GB
repo1: backup set size: 31.7GB, backup size: 31.7GB
incr backup: 20260820-020000F_20260820-140000I
timestamp start/stop: 2026-08-20 14:00:00 / 2026-08-20 14:01:22
database size: 143.1GB, database backup size: 1.2GB
repo1: backup set size: 31.9GB, backup size: 260MBTwo things to read here. The backup label encodes lineage: F is a full, D a differential, I an incremental, and the prefix names its parent. And "backup size" versus "backup set size" shows compression at work — 142 GB of database in 32 GB of repository is typical with the default compression.
Restoring
The plain restore replaces the data directory with the latest backup:
sudo systemctl stop postgresql
sudo -u postgres pgbackrest --stanza=app --delta restore
sudo systemctl start postgresql--delta compares checksums and copies only files that differ, which turns a multi-hour restore into minutes when the existing directory is mostly intact. It is the flag to reach for when recovering from a partial failure rather than a bare machine.
For point-in-time recovery, name the target:
sudo systemctl stop postgresql
sudo -u postgres pgbackrest --stanza=app \
--delta \
--type=time \
--target='2026-08-20 14:37:29+00' \
--target-action=pause \
restore
sudo systemctl start postgresqlpgBackRest picks the right backup set, writes restore_command and the recovery target into postgresql.auto.conf, and creates recovery.signal for you. With --target-action=pause the server stops at the target and waits, so you can inspect the data before committing:
SELECT pg_is_in_recovery();
SELECT count(*) FROM orders WHERE created_at::date = '2026-08-20';
SELECT * FROM schema_migrations ORDER BY applied_at DESC LIMIT 3;Query the paused instance with any client — connecting with Chat2DB (opens in a new tab) lets you keep the recovered database and production side by side while you decide. When satisfied:
SELECT pg_wal_replay_resume();To restore a single table rather than the whole cluster, restore to a scratch directory on a spare port with --pg1-path, start it there, and pg_dump the table across. pgBackRest restores clusters, not individual relations.
Verification and retention
Retention runs automatically from the repo1-retention-* settings, expiring backups and the WAL they no longer need. To run it on demand:
sudo -u postgres pgbackrest --stanza=app expireVerify repository integrity — every file checksum, every WAL segment present — with:
sudo -u postgres pgbackrest --stanza=app verifyRun verify on a schedule. It catches bit rot and missing segments while you still have another backup to fall back on.
None of this replaces an actual restore rehearsal. Once a quarter, restore the newest backup onto a spare host, start it, run a handful of application queries, and write down how long it took. That number is your real recovery time objective.
Common failures and their causes
unable to find primary cluster— the stanza'spg1-pathorpg1-portdoes not match the running server. Compare withSHOW data_directory;.archive-pushfailing,pg_walgrowing — usually repository permissions or a full disk on the backup host. Checkpg_stat_archiverforfailed_countand read/var/log/pgbackrest/.WAL segment ... was not archived before timeout— archiving cannot keep up with write volume. Raiseprocess-maxin[global:archive-push]and enablearchive-async=y.- Restore starts but recovery never ends — the target is beyond the newest archived WAL. Check the
wal archive min/maxline ininfoagainst your target time.
The pattern behind all four is the same: pgBackRest reports problems loudly, but only if someone reads the logs. Wire check and verify into monitoring, and the backups will be there the day you need them.
