pgBackRest Guide: PostgreSQL Backup, Restore and PITR

A complete pgBackRest guide for PostgreSQL: install, S3 repos, encryption, full/diff/incr backups, WAL archiving, restore and PITR, with options explained.

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 DELETE without a WHERE.
  • 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.

  1. A base backup. pgBackRest tells PostgreSQL a backup is starting (pg_backup_start), copies the data directory while the database keeps running, then calls pg_backup_stop. Files copied mid-write are inconsistent on their own.
  2. The WAL archive. PostgreSQL writes every change to WAL in 16 MB segments. With archive_mode=on, PostgreSQL hands each finished segment to archive_command, which is pgbackrest 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

TermMeaning
StanzaThe 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 backupA 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.
ExpireRemoving backups (and the WAL only they need) beyond the retention policy.
Backup setOne 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:

PathPurpose
/etc/pgbackrest/pgbackrest.confMain 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:

OptionMeaning
repo1-pathWhere repo1 stores backups and WAL. Default /var/lib/pgbackrest. For object stores, a path inside the bucket.
repo1-retention-fullHow many full backups to keep (see Step 8). With no retention set, pgBackRest warns and keeps everything.
log-level-consoleWhat prints to the terminal: off, error, warn (default), info, detail, debug, trace. info shows progress.
log-level-fileWhat goes to /var/log/pgbackrest/<stanza>-<command>.log. Default info; detail records every file copied.
start-fastDefault 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-pathThe 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 calls archive_command for every finished WAL segment. Needs a restart, not just a reload.
  • archive_command: %p is the path of the segment. PostgreSQL only recycles a segment after this command exits 0, so a failing archive makes pg_wal grow 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 to minimal.
  • 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:

OptionDefaultMeaning
--typeincrfull, diff or incr.
--start-fastnForce a checkpoint so the backup starts immediately.
--process-max1Parallel processes for compression and transfer. The single biggest speed knob. Don’t starve the database: leave most cores for PostgreSQL.
--compress-typegznone, bz2, gz, lz4 or zst. zst is the best size/speed trade-off; lz4 is fastest.
--compress-levelper typezst 3, gz 6, lz4 1, bz2 9. Higher is smaller and slower.
--deltanDecide which files changed by checksum instead of timestamp. Slower; use it if you can’t trust file timestamps.
--resumeyResume a failed backup of the same type instead of starting over (see Step 13).
--archive-checkyWait until the WAL needed to make this backup consistent has been archived. Leave on.
--archive-copynAlso copy that WAL into the backup itself, so it restores even if the WAL archive is lost.
--expire-autoyRun expire automatically after a successful backup.
--annotationnoneAttach key=value metadata, e.g. --annotation=reason="before v2 migration". Repeatable.
--repofirst repoWhich repository to back up to. With several repos you must pick one per run (Step 10).
--excludenoneSkip 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 as no valid backups or missing 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:

OptionMeaning
--output=jsonMachine-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.

OptionDefaultMeaning
repo1-retention-fullnoneFulls to keep: a count, or days when the type is time.
repo1-retention-full-typecountcount: keep the newest N fulls. time: keep fulls so that at least one is N days old.
repo1-retention-diffunsetDiffs to keep. Unset keeps every diff until its full expires. Fulls count as diffs here.
repo1-retention-archivederivedBackups’ worth of continuous WAL to keep. Leave unset: WAL is then expired only with the backups that need it.
repo1-retention-archive-typefullfull, diff or incr: what retention-archive counts.
repo1-retention-history365Days 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:

OptionMeaning
repo1-typeposix (default, a local or mounted path), s3, azure, gcs, sftp or cifs.
repo1-s3-bucketThe bucket name.
repo1-s3-endpointThe S3 API host, e.g. s3.eu-west-1.amazonaws.com or your provider’s endpoint.
repo1-s3-regionThe bucket’s region. Required even for providers that ignore it.
repo1-s3-key / repo1-s3-key-secretAccess key pair, when repo1-s3-key-type=shared (default).
repo1-s3-key-typeshared (keys in config), auto (EC2 instance role), web-id (EKS IRSA).
repo1-s3-uri-stylehost (default, bucket.endpoint) or path (endpoint/bucket). MinIO usually needs path.
repo1-storage-verify-tlsDefault y. Set n only for a test endpoint with a self-signed certificate.
repo1-cipher-typenone or aes-256-cbc. Encryption happens on the client before upload, so the storage provider never sees plaintext.
repo1-cipher-passThe passphrase. Cannot be changed after stanza-create for that repo.
repo1-bundlePack small files into bundles: far fewer objects and requests on object storage. Needed for repo1-block.
repo1-blockBlock 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-maxIn [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-push writes WAL to every repo.

  • backup writes to one repo, repo1 unless 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 backup
    

    This catches people out: with two repos and a plain backup, repo2 only ever gets WAL, never a base backup.

  • restore looks for a suitable backup in repo order unless you pass --repo.

  • info shows every repo; --repo narrows 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
OptionDefaultMeaning
archive-asyncnPush 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/pgbackrestLocal directory for acknowledgements and prefetched WAL. Must be local disk, not NFS.
archive-push-queue-maxunlimitedWhen 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-size16GiBMax WAL pushed per async run; lower values recheck the queue limit more often.
archive-get-queue-max128MiBHow much WAL archive-get prefetches into the spool during recovery. Raise it to speed up long replays.
archive-timeout1mHow 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)1Parallel 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

OptionDefaultMeaning
--setlatestWhich backup to restore, by label.
--typedefaultRecovery 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).
--targetnoneThe target for time, xid, lsn or name. A time is "2026-10-28 09:59:00+00".
--target-actionpauseWhat PostgreSQL does at the target: pause (read-only, check the data, then SELECT pg_wal_replay_resume()), promote (open for writes), shutdown.
--target-exclusivenWith y, stop just before the target instead of just after it.
--target-timelinelatestWhich timeline to follow after earlier promotions.
--deltanReuse files that match by checksum.
--forcenWith --delta, overwrite a directory that doesn’t look like a PostgreSQL data directory.
--db-includeallRestore only these databases (repeatable). Others are restored as empty sparse files and cannot be connected to.
--db-excludenoneRestore everything except these databases.
--tablespace-maporiginalRestore a tablespace elsewhere: --tablespace-map=ts_01=/data/ts_01. --tablespace-map-all moves them all.
--link-maporiginalChange a symlink’s destination, e.g. a separate pg_wal volume. --link-all restores all links.
--archive-modepreserveoff 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-optionnoneAdd a recovery setting, e.g. --recovery-option=primary_conninfo=.... Repeatable.
--reposearch in orderRestore from a specific repository.
--process-max1Parallel 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_mode needs a restart. We shipped the config and created the stanza, then waited for a maintenance window to restart. For days every backup failed with archive_mode must be enabled. Alert on backup age from day one.
  • backup only writes to repo1 unless you pass --repo. Loop over the repos.
  • Object storage has bad nights. One region returned 504 Gateway Timeout and 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-timeout for 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=8 it can saturate a 1 Gbps link that also carries application traffic. Schedule fulls off-peak and give the backup a low priority with Nice and IOSchedulingClass.
  • 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

TaskCommand
Create stanzapgbackrest --stanza=main stanza-create
Check archiving end to endpgbackrest --stanza=main check
Full backup to repo1pgbackrest --stanza=main --repo=1 --type=full backup
Differential backuppgbackrest --stanza=main --type=diff backup
List backupspgbackrest --stanza=main info
JSON for monitoringpgbackrest --stanza=main --output=json info
Restore latestpgbackrest --stanza=main restore
Delta restorepgbackrest --stanza=main --delta restore
PITRpgbackrest --stanza=main --type=time "--target=..." --target-action=pause restore
One databasepgbackrest --stanza=main --db-include=shop restore
Expire one backuppgbackrest --stanza=main --set=<label> expire
Verify repositorypgbackrest --stanza=main --repo=1 verify
After major upgradepgbackrest --stanza=main stanza-upgrade
Pause / resumepgbackrest --stanza=main stop / start
Browse repopgbackrest --stanza=main repo-ls backup/main
Built-in helppgbackrest 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.

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.

comments powered by Disqus
Citizix Ltd
Built with Hugo
Theme Stack designed by Jimmy