What You'll End With
By the end of this guide you'll have a working PostgreSQL backup strategy for small servers that runs on Ubuntu 24.04: nightly logical dumps via pg_dump, compressed and timestamped, rotated locally, and uploaded automatically to an S3-compatible bucket (Backblaze B2, Hetzner Object Storage, or AWS S3). You'll also have a tested restore procedure so the backups are actually useful.
Prerequisites:
- Ubuntu 24.04 VPS with PostgreSQL 16 installed (
postgresql-16package) - A database user with at least
pg_read_all_dataprivileges - An S3-compatible bucket and credentials (
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY, endpoint URL) awscliv2 installed (/usr/local/bin/aws)- Root or a user with
sudoandcronaccess - At least 2× your database size in free disk space on
/var/backups
Why Logical Dumps Over Physical Backups on Small Servers
Physical backups (base backup + WAL streaming) are powerful but add operational overhead: you need enough disk for WAL segments, a standby or archive server, and tools like pgBackRest or Barman. On a $5–$20 VPS that's often overkill.
pg_dump produces a self-contained, compressed SQL or custom-format file you can restore on any Postgres version equal to or newer than the source. For databases under ~50 GB, a nightly dump with offsite upload gives you an RPO (recovery point objective) of 24 hours at near-zero cost. If you need a lower RPO, the WAL archiving section at the end extends this guide.
Step 1 — Create a Dedicated Backup User
Never run backups as postgres superuser in scripts. Create a restricted role.
1.1 Open a psql session as the postgres system user:
sudo -u postgres psql
1.2 Create the role and grant read access:
CREATE ROLE backup_user WITH LOGIN PASSWORD 'change_this_strong_password';
GRANT pg_read_all_data TO backup_user;
\q
1.3 Store the password in a .pgpass file so scripts never expose it in process lists:
sudo mkdir -p /var/backups/postgres
sudo bash -c 'echo "localhost:5432:*:backup_user:change_this_strong_password" > /var/backups/postgres/.pgpass'
sudo chmod 600 /var/backups/postgres/.pgpass
sudo chown root:root /var/backups/postgres/.pgpass
The .pgpass file tells libpq to authenticate without prompting or exposing credentials in shell history.
Step 2 — Write the Backup Script
Create /usr/local/bin/pg_backup.sh. This script dumps every non-template database, compresses with gzip, and deletes local copies older than 7 days.
2.1 Create the script:
sudo tee /usr/local/bin/pg_backup.sh > /dev/null << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
### Configuration ###
BACKUP_DIR="/var/backups/postgres"
PGPASS_FILE="${BACKUP_DIR}/.pgpass"
RETENTION_DAYS=7
S3_BUCKET="s3://your-bucket-name/postgres"
S3_ENDPOINT="https://s3.us-west-004.backblazeb2.com" # change for your provider
DATE=$(date +%Y-%m-%dT%H-%M-%S)
PGUSER="backup_user"
PGHOST="localhost"
PGPORT="5432"
export PGPASSFILE="${PGPASS_FILE}"
### Create today's backup directory ###
MKDIR_PATH="${BACKUP_DIR}/${DATE}"
mkdir -p "${MKDIR_PATH}"
### Dump each database ###
DATABASES=$(psql -U "${PGUSER}" -h "${PGHOST}" -p "${PGPORT}" -d postgres \
-t -c "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname;")
for DB in ${DATABASES}; do
OUTFILE="${MKDIR_PATH}/${DB}.dump.gz"
echo "[$(date +%H:%M:%S)] Dumping ${DB} -> ${OUTFILE}"
pg_dump -U "${PGUSER}" -h "${PGHOST}" -p "${PGPORT}" \
--format=custom --compress=6 "${DB}" | gzip > "${OUTFILE}"
done
### Upload to S3 ###
echo "[$(date +%H:%M:%S)] Uploading to ${S3_BUCKET}/${DATE}"
aws s3 cp "${MKDIR_PATH}" "${S3_BUCKET}/${DATE}/" \
--recursive \
--endpoint-url "${S3_ENDPOINT}" \
--storage-class STANDARD
### Rotate local backups ###
find "${BACKUP_DIR}" -mindepth 1 -maxdepth 1 -type d \
-mtime +${RETENTION_DAYS} -exec rm -rf {} \;
echo "[$(date +%H:%M:%S)] Backup complete."
EOF
2.2 Make it executable:
sudo chmod 700 /usr/local/bin/pg_backup.sh
2.3 Set your S3 credentials as environment variables for the root cron environment. Edit /etc/environment to add them, or use a dedicated credentials file:
sudo mkdir -p /root/.aws
sudo tee /root/.aws/credentials > /dev/null << 'EOF'
[default]
aws_access_key_id = YOUR_ACCESS_KEY_ID
aws_secret_access_key = YOUR_SECRET_ACCESS_KEY
EOF
sudo chmod 600 /root/.aws/credentials
Step 3 — Schedule with Cron
Run the backup at 02:30 UTC every night. Root's crontab ensures access to the .pgpass file owned by root.
3.1 Open root's crontab:
sudo crontab -e
3.2 Add this line:
30 2 * * * /usr/local/bin/pg_backup.sh >> /var/log/pg_backup.log 2>&1
The >> appends both stdout and stderr to a single log file you can tail when troubleshooting.
3.3 Verify the cron entry was saved:
sudo crontab -l
Expected output:
30 2 * * * /usr/local/bin/pg_backup.sh >> /var/log/pg_backup.log 2>&1
Step 4 — Test a Manual Run and Restore
A backup you've never restored is not a backup. Run the script manually and immediately test a restore.
4.1 Run the script now:
sudo /usr/local/bin/pg_backup.sh
Expected output (example with a myapp database):
[02:30:01] Dumping myapp -> /var/backups/postgres/2025-01-15T02-30-01/myapp.dump.gz
[02:30:04] Uploading to s3://your-bucket-name/postgres/2025-01-15T02-30-01
upload: .../myapp.dump.gz to s3://your-bucket-name/postgres/2025-01-15T02-30-01/myapp.dump.gz
[02:30:07] Backup complete.
4.2 Restore into a test database to verify integrity:
sudo -u postgres createdb myapp_restore_test
zcat /var/backups/postgres/$(ls -t /var/backups/postgres | head -1)/myapp.dump.gz \
| sudo -u postgres pg_restore --dbname=myapp_restore_test --no-owner --role=postgres
4.3 Spot-check row counts match your production database:
sudo -u postgres psql -d myapp_restore_test -c "\dt"
4.4 Drop the test database when done:
sudo -u postgres dropdb myapp_restore_test
Step 5 — Optional: WAL Archiving for Lower RPO
If 24-hour RPO is too long (e.g., high-transaction e-commerce), enable WAL archiving to ship WAL segments to S3 continuously. This reduces RPO to minutes.
5.1 Edit /etc/postgresql/16/main/postgresql.conf:
sudo nano /etc/postgresql/16/main/postgresql.conf
Set or uncomment these lines:
wal_level = replica
archive_mode = on
archive_command = 'aws s3 cp %p s3://your-bucket-name/wal/%f --endpoint-url https://s3.us-west-004.backblazeb2.com'
archive_timeout = 300
archive_timeout = 300 forces a WAL segment switch every 5 minutes even on low-traffic servers, capping your RPO at ~5 minutes.
5.2 Reload PostgreSQL:
sudo systemctl reload postgresql@16-main.service
5.3 Confirm archiving is active:
sudo -u postgres psql -c "SELECT name, setting FROM pg_settings WHERE name IN ('archive_mode','wal_level','archive_command');"
Expected output:
name | setting
---------------+----------------------------------------------------------
archive_command| aws s3 cp %p s3://your-bucket-name/wal/%f ...
archive_mode | on
wal_level | replica
Note: WAL archiving works alongside nightly pg_dump. The dumps act as base restore points; WAL segments let you replay forward to any point in time between dumps.
Verify It Works
Run each check after initial setup and after any server change:
# 1. Confirm local dump files exist and are non-zero
ls -lh /var/backups/postgres/$(ls -t /var/backups/postgres | head -1)/
# 2. Confirm S3 upload succeeded
aws s3 ls s3://your-bucket-name/postgres/ --endpoint-url https://s3.us-west-004.backblazeb2.com
# 3. Check last backup log for errors
tail -50 /var/log/pg_backup.log
# 4. Verify .pgpass permissions (must be 600)
stat -c "%a %U" /var/backups/postgres/.pgpass
# Expected: 600 root
Troubleshooting
pg_dump: error: connection to server failed: FATAL: password authentication failed
The .pgpass file permissions are wrong or the path in PGPASSFILE doesn't match. Run stat /var/backups/postgres/.pgpass — it must be 600 and owned by the user running the script.
aws: command not found in cron
Cron uses a minimal PATH. Either use the full path /usr/local/bin/aws in the script, or add PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin at the top of the cron file.
Dump file is 0 bytes
set -euo pipefail will abort on errors, but check /var/log/pg_backup.log for the exact pg_dump error. Common causes: backup_user lacks pg_read_all_data, or the database name contains a space (wrap ${DB} in quotes — the script already does this).
S3 upload fails with SignatureDoesNotMatch
Your endpoint URL doesn't match the bucket region. Confirm the endpoint in your storage provider's dashboard and update S3_ENDPOINT in the script.
Local disk fills up before rotation runs
Reduce RETENTION_DAYS or move the backup directory to a separate volume. Check current usage with du -sh /var/backups/postgres/*/.
archive_command fails silently
PostgreSQL retries failed archive commands indefinitely. Check sudo tail -100 /var/log/postgresql/postgresql-16-main.log for archive command failed lines. The most common cause is missing AWS credentials for the postgres system user — add /var/lib/postgresql/.aws/credentials with the same content as /root/.aws/credentials, owned by postgres:postgres with mode 600.
Next Steps
This PostgreSQL backup strategy for small servers covers the 80% case at minimal cost. From here:
- Add alerting: Pipe backup failures to a Slack webhook or email using a wrapper script that checks exit codes.
- Encrypt dumps at rest: Pipe
pg_dumpoutput throughgpg --symmetricbefore uploading to S3 if your bucket is not private. - Test restores monthly: Schedule a monthly cron job that downloads the latest dump, restores to a throwaway database, runs a row-count query, and logs the result.
- Upgrade to pgBackRest: Once your database exceeds 50 GB or you need point-in-time recovery with a proper catalog, pgbackrest on a second Hetzner VPS (~€4/month) is the next logical step.