PostgreSQL Backup and Point-in-Time Recovery
Learn PostgreSQL PITR: configure WAL archiving, take base backups with pg_basebackup, manage production backups with pgBackRest, and restore to a specific timestamp.
Before you start
- ▸PostgreSQL 15 or 16 installed and a cluster already running
- ▸A separate backup destination with sufficient free space (at minimum 2× data directory size)
- ▸sudo or root access to edit postgresql.conf and restart the service
- ▸Basic familiarity with psql and PostgreSQL data directory layout
Losing a PostgreSQL database to hardware failure or accidental data deletion is survivable — if you have a proper backup strategy. A dump from pg_dump gets you to the previous night; point-in-time recovery (PITR) gets you to the second before the DROP TABLE. This guide covers both layers: base backups with pg_basebackup, WAL archiving, and a production-grade workflow using pgBackRest, finishing with a concrete PITR restore.
Prerequisites and Concepts
PostgreSQL's PITR relies on two things working together: a base backup (a consistent snapshot of the data directory) and a continuous stream of Write-Ahead Log (WAL) segments that record every change after that snapshot. To recover to a specific moment, Postgres replays WAL on top of the base backup until it reaches your target time or transaction ID.
- Running PostgreSQL 15 or 16 (commands are identical for both; note any 14 differences)
- A separate backup destination — another disk, NFS mount, or object storage
- Enough disk space: base backup ≈ size of your data directory; WAL retention multiplies that
sudo/ root access and the ability to editpostgresql.conf
Step 1: Configure WAL Archiving
WAL archiving tells Postgres to copy completed WAL segments to a safe location instead of recycling them. Edit postgresql.conf — usually at /etc/postgresql/16/main/postgresql.conf on Debian/Ubuntu or /var/lib/pgsql/16/data/postgresql.conf on Fedora/RHEL.
# Find the config file if unsure
sudo -u postgres psql -c "SHOW config_file;"
Add or update these parameters:
wal_level = replica
archive_mode = on
archive_command = 'test ! -f /mnt/pgarchive/%f && cp %p /mnt/pgarchive/%f'
archive_timeout = 300 # archive incomplete segments every 5 minutes
The archive_command must return exit code 0 only on success. The example above copies to a local path — replace with an rsync or pgbackrest archive-push call in production. Create and permission the archive directory:
sudo mkdir -p /mnt/pgarchive
sudo chown postgres:postgres /mnt/pgarchive
sudo chmod 700 /mnt/pgarchive
Restart PostgreSQL for archive_mode to take effect (a reload is not enough):
# Debian/Ubuntu
sudo systemctl restart postgresql@16-main
# Fedora/RHEL/Rocky
sudo systemctl restart postgresql-16
# Arch
sudo systemctl restart postgresql
Verify archiving is active:
sudo -u postgres psql -c "SELECT name, setting FROM pg_settings WHERE name IN ('wal_level','archive_mode','archive_command');"
Step 2: Take a Base Backup with pg_basebackup
pg_basebackup connects to a running cluster and streams a consistent backup. The postgres OS user must be able to authenticate as a replication-capable role.
sudo -u postgres pg_basebackup \
--pgdata=/mnt/pgbackup/base_$(date +%Y%m%d) \
--format=tar \
--gzip \
--checkpoint=fast \
--wal-method=stream \
--progress \
--verbose
--format=tar --gzip: produces compressed archives (base.tar.gz+ WAL tar)--wal-method=stream: streams WAL generated during the backup, so the backup is self-contained even if archiving lags--checkpoint=fast: starts a checkpoint immediately rather than waiting; fine for backups, but generates I/O
The output directory will contain base.tar.gz and pg_wal.tar.gz. Record the backup label — you'll need it during recovery.
Step 3: Production Workflow with pgBackRest
pg_basebackup is enough for learning PITR, but pgBackRest is what you want for production: parallel compression, incremental backups, built-in WAL archiving, retention policies, and S3/Azure/GCS support.
Install pgBackRest
# Debian/Ubuntu
sudo apt install pgbackrest
# Fedora/RHEL/Rocky (requires PGDG repo)
sudo dnf install pgbackrest
# Arch (AUR)
yay -S pgbackrest
Configure pgBackRest
Create /etc/pgbackrest/pgbackrest.conf:
[global]
repo1-path=/mnt/pgbackup/pgbackrest
repo1-retention-full=2
log-level-console=info
log-level-file=debug
start-fast=y
[mydb]
pg1-path=/var/lib/postgresql/16/main
pg1-port=5432
The stanza name (mydb) is arbitrary but must be consistent. Initialise the repository:
sudo -u postgres pgbackrest --stanza=mydb stanza-create
Update archive_command in postgresql.conf to use pgBackRest:
archive_command = 'pgbackrest --stanza=mydb archive-push %p'
restore_command = 'pgbackrest --stanza=mydb archive-get %f "%p"'
Reload Postgres after this change (no restart needed if archive_mode was already on):
sudo -u postgres psql -c "SELECT pg_reload_conf();"
Run a Full Backup
sudo -u postgres pgbackrest --stanza=mydb --type=full backup
Schedule incremental backups via systemd timers or cron. A common pattern: full backup weekly, incremental nightly.
# Verify backup integrity
sudo -u postgres pgbackrest --stanza=mydb info
Output will list backups with timestamps, WAL ranges, and sizes — confirm the latest backup's WAL stop LSN.
Step 4: Point-in-Time Recovery
Scenario: it's 14:37 and someone ran DELETE FROM orders WHERE 1=1; at 14:22. You want to recover to 14:21:59.
Stop PostgreSQL
sudo systemctl stop postgresql@16-main # Debian/Ubuntu
sudo systemctl stop postgresql-16 # Fedora/RHEL
Restore the Base Backup
With pgBackRest, restore directly over the data directory. The --target flag specifies the recovery timestamp (in cluster timezone):
sudo -u postgres pgbackrest --stanza=mydb \
--delta \
--type=time \
--target="2025-07-15 14:21:59" \
--target-action=promote \
restore
--delta only replaces changed files, which is faster when restoring in place. --target-action=promote tells Postgres to become writable once it reaches the target rather than pausing.
Start and Verify
sudo systemctl start postgresql@16-main
# Watch recovery progress
sudo tail -f /var/log/postgresql/postgresql-16-main.log
You'll see lines like recovery stopping before commit of transaction and finally database system is ready to accept connections. Then verify:
sudo -u postgres psql -c "SELECT COUNT(*) FROM orders;"
If the count matches expectations, the recovery succeeded. If you overshot or undershot, stop Postgres, adjust --target, and re-restore.
Manual PITR Without pgBackRest
If using raw pg_basebackup output, extract the tarball to the data directory, then create a recovery.signal file and set parameters in postgresql.conf (PostgreSQL 12+):
touch /var/lib/postgresql/16/main/recovery.signal
# In postgresql.conf:
restore_command = 'cp /mnt/pgarchive/%f %p'
recovery_target_time = '2025-07-15 14:21:59'
recovery_target_action = 'promote'
Start Postgres normally; it will enter recovery mode automatically when it finds recovery.signal.
Verification Checklist
- WAL segments appear in the archive directory within
archive_timeoutseconds of a checkpoint pg_stat_archivershowslast_failed_walas empty:SELECT * FROM pg_stat_archiver;- pgBackRest
infocommand shows a valid WAL range covering your backup - Test restores — schedule a quarterly drill to a separate server
Troubleshooting
- Archive command failing silently: Check
pg_stat_archiver.last_failed_walandlast_error. Run thearchive_commandmanually as thepostgresuser to see the real error. - Recovery stalls at startup: Missing WAL segment. Ensure your archive path and
restore_commandare correct. The Postgres log will name the exact missing file. - pg_basebackup: could not connect to server: The connecting role needs
REPLICATIONprivilege or be a superuser. Checkpg_hba.conffor areplicationdatabase entry. - pgBackRest stanza-create fails: Confirm the
pg1-pathis the actual data directory (SHOW data_directory;) and that thepostgresuser can write torepo1-path. - Disk space: WAL archiving accumulates fast on busy systems. Set
repo1-retention-archivein pgBackRest to cap WAL retention independently of full backup retention.
Frequently asked questions
- How is PITR different from a nightly pg_dump?
- pg_dump gives you a snapshot at one moment — typically last night. PITR lets you replay WAL to any second within your retention window, minimising data loss to minutes or seconds rather than hours.
- Can I do PITR to a different server, not the original?
- Yes, and you should — that's how you test. Point repo1-path and the archive to shared storage or copy the files over, run the restore on the target, and start Postgres there. The cluster UUID will differ but data is identical.
- How much disk space does WAL archiving consume?
- On a busy OLTP system, WAL can generate several gigabytes per hour. Use pgBackRest's repo1-retention-archive setting to keep only as many days of WAL as your recovery objectives require, separate from how many full backups you retain.
- Does pg_basebackup lock the database during the backup?
- No. It uses a non-exclusive backup mode (the only mode available since PostgreSQL 15 removed exclusive backup lock). Normal reads and writes continue; the --checkpoint=fast flag just triggers an immediate checkpoint to start the backup sooner.
- What happens if archive_command fails for a segment?
- Postgres keeps the WAL segment on disk and retries on the next WAL switch. It will not overwrite or recycle that segment until archiving succeeds, so your data directory can grow if archiving is broken for an extended period. Monitor pg_stat_archiver.last_failed_wal in your alerting stack.
Related guides
Configure Prometheus Alertmanager
Configure Prometheus Alertmanager with routing trees, receivers, inhibition rules, grouping, Go templates, and PagerDuty/Slack on-call integrations.
Build an Intranet Server on Linux
Set up a complete small-office intranet on one Linux box: Nginx web server, dnsmasq local DNS, Samba file sharing, and a Wiki.js team wiki.
Build an nftables Firewall Script
Build a complete nftables firewall from scratch: tables, chains, sets, default-deny input policy, service allowlisting, and persistent systemd configuration.
Caddy as a Reverse Proxy
Set up Caddy as a reverse proxy with automatic HTTPS, load balancing, WebSocket passthrough, reusable snippets, and header control — no certbot required.