Data and storageWritten by GLWS15 min readUbuntu 24.04 LTS / AlmaLinux 9
Commands run on your server unless a section says otherwise. Replace example usernames, addresses and paths with your own values. Keep a working session open when changing remote access.

What a logical backup is

A logical backup, made with pg_dump, is a consistent copy of one database that can be restored on any server and often on a newer PostgreSQL version. It is the right starting point for small and medium databases. It does not capture roles and other cluster-wide objects, so they need a separate dump.

This guide assumes PostgreSQL is installed as in the database guide. Commands run as the postgres user with sudo, so no password is needed.

Both systems
sudo -u postgres psql -c "\l"
sudo -u postgres psql -Atc "SELECT pg_size_pretty(pg_database_size('appdb'));"
Capture application files and a consistent database backup. Keep an encrypted copy off the VPS, restore it separately, and verify the restored data before relying on the backup.
Capture application files and a consistent database backup. Keep an encrypted copy off the VPS, restore it separately, and verify the restored data before relying on the backup.

Take a dump in custom format

The custom format compresses the dump and lets pg_restore restore selected parts, in parallel and into any database name. Prefer it to plain SQL for anything you will restore with a tool. Write it into a folder only postgres can read.

Both systems
sudo install -d -o postgres -g postgres -m 700 /var/backups/postgresql
sudo -u postgres pg_dump -Fc appdb -f /var/backups/postgresql/appdb-$(date +%F).dump
sudo ls -lh /var/backups/postgresql/

Save roles and settings

Roles, their passwords and tablespaces belong to the whole cluster. Dump them with pg_dumpall using the globals-only option. Without this file, a restore on a new server would have no users and ownership would not match.

Both systems
sudo -u postgres pg_dumpall --globals-only -f /var/backups/postgresql/globals-$(date +%F).sql
sudo chmod 600 /var/backups/postgresql/globals-*.sql
The globals file contains password hashes. Keep it as private as the dumps themselves.

Write a backup script with retention

The script dumps every database except the templates, saves the globals and deletes backups older than 14 days. It stops at the first error, so a failed run is visible in the journal. Adjust the retention to the amount of disk space you have, and copy the files off the server as well, for example with restic.

Both systems: create /usr/local/bin/pg-backup
sudo tee /usr/local/bin/pg-backup >/dev/null <<'EOF'
#!/bin/bash
set -euo pipefail
umask 077
DEST=/var/backups/postgresql
DAY=$(date +%F)
install -d -o postgres -g postgres -m 700 "$DEST"
sudo -u postgres pg_dumpall --globals-only -f "$DEST/globals-$DAY.sql"
while IFS= read -r db; do
  sudo -u postgres pg_dump -Fc "$db" -f "$DEST/$db-$DAY.dump"
  echo "dumped $db"
done < <(sudo -u postgres psql -Atc "SELECT datname FROM pg_database WHERE NOT datistemplate AND datname <> 'postgres' ORDER BY datname")
find "$DEST" -type f -mtime +14 -delete
EOF
sudo chmod 750 /usr/local/bin/pg-backup
sudo /usr/local/bin/pg-backup

Schedule it

Run the script daily with a systemd timer. The timer catches up if the server was off at the scheduled time. Check the journal after the first run and look at the files it produced.

Both systems
sudo tee /etc/systemd/system/pg-backup.service >/dev/null <<'EOF'
[Unit]
Description=PostgreSQL backup

[Service]
Type=oneshot
Nice=10
ExecStart=/usr/local/bin/pg-backup
EOF
sudo tee /etc/systemd/system/pg-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Daily PostgreSQL backup

[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true

[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now pg-backup.timer
systemctl list-timers pg-backup.timer
sudo systemctl start pg-backup.service
sudo journalctl -u pg-backup.service --no-pager | tail -n 10

Restore into a separate test database

A backup only counts once you have restored it. Restore into a new database with a different name, so that the live one is never touched. Check that the dump can be read, restore it, then compare table row counts with the original.

Compare actual count(*) results, not n_live_tup: that is a statistics estimate and may be zero immediately after a restore. Counting large tables can be expensive. Also verify application totals, sample records and a test read through the app.

Both systems
LATEST=$(ls -t /var/backups/postgresql/appdb-*.dump | head -n 1)
sudo -u postgres pg_restore --list "$LATEST" | head -n 15
sudo -u postgres createdb restore_test
sudo -u postgres pg_restore --no-owner -d restore_test "$LATEST"
Compare with the original
for d in appdb restore_test; do
  echo "== $d"
  sudo -u postgres psql -d "$d" -P pager=off <<'SQL'
SELECT format('SELECT %L AS table_name, count(*) AS rows FROM %I.%I;',
              schemaname || '.' || tablename, schemaname, tablename)
FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY schemaname, tablename;
\gexec
SQL
done
Remove the test database
sudo -u postgres dropdb restore_test
RECORDED LAB OUTPUT · AlmaLinux 9
--------------------------------------------------------------------------------
(1 row)
   table_name    | rows 
-----------------+------
 public.evidence |    3
(1 row)
--------------------------------------------------------------------------------
(1 row)
   table_name    | rows 
-----------------+------
 public.evidence |    3
(1 row)
Recorded count of the lab evidence table after restoring into a separate database. Your table names and row counts will differ.
n_live_tup is an estimate. For a precise check, run SELECT count(*) on the tables that matter to you.

Restore for real after a disaster

On a new server, install PostgreSQL, restore the globals file first so roles exist, then create the database and restore the dump into it. Write down this procedure and keep it with your backups. Practise it so that you know how long it takes.

Both systems: on the replacement server
sudo -u postgres psql -f globals-YYYY-MM-DD.sql
sudo -u postgres createdb -O appuser appdb
sudo -u postgres pg_restore -d appdb appdb-YYYY-MM-DD.dump

Next steps

Copy the backup folder off the server (see the restic guide), because backups on the same disk are lost with the server. For very large databases, or when you cannot lose more than a day of data, look at continuous archiving and point-in-time recovery in the PostgreSQL documentation. Monitor the backup job so that you hear about a failure.

Both systems: check the age of the newest backup
find /var/backups/postgresql -name "*.dump" -mtime -1 | grep -q . && echo "recent backup found" || echo "NO RECENT BACKUP"

Official documentation

These upstream references provide more detail on the commands and workflows in this guide.