What You'll End Up With
By the end of this guide you'll have a production-ready PostgreSQL backup automation script that runs on Ubuntu 24.04. It uses pg_dump to create compressed .sql.gz archives, stores them in a dedicated directory, rotates files older than 7 days, and sends an email alert on failure. A systemd timer (not cron) triggers it nightly. No third-party backup SaaS required — this runs on any $6/month VPS.
Prerequisites
- Ubuntu 24.04 server with PostgreSQL 16 installed (
postgresql-16package) - A Unix user named
postgres(created automatically by the package) mailutilsinstalled if you want failure email alerts- Root or
sudoaccess - Basic familiarity with
systemdandbash
1. Create the Backup Directory
Run the following to create a dedicated backup directory owned by the postgres user:
sudo mkdir -p /var/backups/postgresql
sudo chown postgres:postgres /var/backups/postgresql
sudo chmod 750 /var/backups/postgresql
This keeps backup files readable only by postgres and root, preventing other system users from accessing raw database dumps.
2. Write the Backup Script
Create the script at /usr/local/bin/pg_backup.sh:
sudo nano /usr/local/bin/pg_backup.sh
Paste the following content exactly:
#!/usr/bin/env bash
set -euo pipefail
# ── Configuration ────────────────────────────────────────────
BACKUP_DIR="/var/backups/postgresql"
RETENTION_DAYS=7
DATE=$(date +%Y-%m-%d_%H-%M-%S)
ALERT_EMAIL="ops@example.com" # set to "" to disable alerts
LOG_FILE="/var/log/pg_backup.log"
# ── Helpers ──────────────────────────────────────────────────
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
fail() {
log "ERROR: $*"
if [[ -n "$ALERT_EMAIL" ]]; then
echo "PostgreSQL backup failed on $(hostname): $*" \
| mail -s "[ALERT] pg_backup failed" "$ALERT_EMAIL"
fi
exit 1
}
# ── Trap unexpected errors ────────────────────────────────────
trap 'fail "Unexpected error on line $LINENO"' ERR
# ── Fetch database list ───────────────────────────────────────
DATABASES=$(psql -U postgres -At -c \
"SELECT datname FROM pg_database WHERE datistemplate = false AND datname <> 'postgres';")
if [[ -z "$DATABASES" ]]; then
log "No user databases found. Exiting."
exit 0
fi
# ── Dump each database ───────────────────────────────────────
for DB in $DATABASES; do
OUTFILE="${BACKUP_DIR}/${DB}_${DATE}.sql.gz"
log "Backing up database: $DB → $OUTFILE"
pg_dump -U postgres -Fp "$DB" | gzip -9 > "$OUTFILE"
log "Completed: $OUTFILE ($(du -sh "$OUTFILE" | cut -f1))"
done
# ── Rotate old backups ────────────────────────────────────────
log "Rotating backups older than ${RETENTION_DAYS} days..."
find "$BACKUP_DIR" -name '*.sql.gz' -mtime +"$RETENTION_DAYS" -delete
log "Rotation complete."
log "All backups finished successfully."
Save and close (Ctrl+O, Enter, Ctrl+X).
What each section does:
set -euo pipefail— aborts on any unhandled error, undefined variable, or pipe failure.fail()— logs the error and optionally emails you before exiting non-zero.trap ... ERR— catches unexpected failures thatset -etriggers.- The
psqlquery excludes template databases and the defaultpostgresDB so you only back up your actual application databases. pg_dump -Fpproduces plain SQL piped directly intogzip -9— no intermediate uncompressed file touches disk.find ... -mtime +7 -deleteremoves archives older than 7 days.
3. Set Permissions on the Script
Make the script executable and restrict ownership:
sudo chown postgres:postgres /usr/local/bin/pg_backup.sh
sudo chmod 750 /usr/local/bin/pg_backup.sh
Create the log file with correct ownership:
sudo touch /var/log/pg_backup.log
sudo chown postgres:postgres /var/log/pg_backup.log
The script runs as the postgres user, which already has peer-authenticated access to PostgreSQL — no password needed.
4. Test the Script Manually
Run it once as the postgres user to confirm it works before wiring up the timer:
sudo -u postgres /usr/local/bin/pg_backup.sh
Expected output (example with a database named myapp):
[2025-01-15 14:32:01] Backing up database: myapp → /var/backups/postgresql/myapp_2025-01-15_14-32-01.sql.gz
[2025-01-15 14:32:03] Completed: /var/backups/postgresql/myapp_2025-01-15_14-32-01.sql.gz (4.2M)
[2025-01-15 14:32:03] Rotating backups older than 7 days...
[2025-01-15 14:32:03] Rotation complete.
[2025-01-15 14:32:03] All backups finished successfully.
Confirm the file exists:
ls -lh /var/backups/postgresql/
-rw-r--r-- 1 postgres postgres 4.2M Jan 15 14:32 myapp_2025-01-15_14-32-01.sql.gz
5. Create the systemd Service and Timer
Using a systemd timer instead of cron gives you dependency handling, logging via journald, and missed-run tracking.
Create the service unit at /etc/systemd/system/pg-backup.service:
sudo nano /etc/systemd/system/pg-backup.service
[Unit]
Description=PostgreSQL Backup Automation Script
After=postgresql.service
Requires=postgresql.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/local/bin/pg_backup.sh
StandardOutput=journal
StandardError=journal
Create the timer unit at /etc/systemd/system/pg-backup.timer:
sudo nano /etc/systemd/system/pg-backup.timer
[Unit]
Description=Run PostgreSQL backup nightly at 02:30
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target
Persistent=true means if the server was off at 02:30, the backup runs at next boot — critical for VPS instances that get rebooted.
Reload systemd and enable the timer:
sudo systemctl daemon-reload
sudo systemctl enable --now pg-backup.timer
Expected output:
Created symlink /etc/systemd/system/timers.target.wants/pg-backup.timer → /etc/systemd/system/pg-backup.timer.
6. Verify It Works
Check the timer is active and shows the next trigger time:
systemctl status pg-backup.timer
● pg-backup.timer - Run PostgreSQL backup nightly at 02:30
Loaded: loaded (/etc/systemd/system/pg-backup.timer; enabled; preset: enabled)
Active: active (waiting) since Wed 2025-01-15 14:35:00 UTC; 5s ago
Trigger: Thu 2025-01-16 02:30:00 UTC; 11h left
Trigger the service immediately to confirm end-to-end:
sudo systemctl start pg-backup.service
Check the journal for output:
journalctl -u pg-backup.service -n 30 --no-pager
List all backup files:
ls -lht /var/backups/postgresql/
Verify a dump is restorable (run on a test server, not production):
gunzip -c /var/backups/postgresql/myapp_2025-01-15_14-32-01.sql.gz | psql -U postgres myapp_restore
Always test restores. A backup you've never restored is a backup you don't have.
Troubleshooting
psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed
PostgreSQL is not running. Run sudo systemctl start postgresql@16-main and retry.
permission denied when writing to /var/backups/postgresql/
The script is not running as postgres. Confirm User=postgres in the service unit and re-run sudo systemctl daemon-reload.
gzip: stdout: No space left on device
The backup directory is full. Check disk usage with df -h /var/backups. Reduce RETENTION_DAYS or mount a larger volume.
No email alert received
Confirm mailutils is installed (dpkg -l mailutils) and that your server's MTA is configured. Test with echo test | mail -s test ops@example.com.
Timer never fires
Run systemctl list-timers pg-backup.timer and confirm the NEXT column shows a future time. If the unit shows failed, inspect with journalctl -u pg-backup.timer.
Backup file is 0 bytes
pg_dump failed silently before the pipe. Remove set -o pipefail temporarily and run manually to see the raw error, then fix the underlying issue and restore the flag.
Next Steps
This PostgreSQL backup automation script covers the core loop: dump, compress, rotate, alert. From here, consider:
- Off-site replication — sync
/var/backups/postgresql/to an S3-compatible bucket withrcloneoraws s3 syncafter each run. Add the sync command at the end ofpg_backup.shbefore the final log line. - Backup encryption — pipe
pg_dumpoutput throughgpg --symmetric --cipher-algo AES256beforegzipif the backup directory is on shared storage. - Slack or PagerDuty alerts — replace the
mailcommand infail()with acurlPOST to a webhook for tighter incident response. - Custom format dumps — switch
pg_dump -Fptopg_dump -Fc(custom format) for faster selective restores withpg_restore, at the cost of losing human-readable SQL. - Monitoring — push a heartbeat to a dead-man's-switch service (e.g., Healthchecks.io) at the end of a successful run so you get paged if the backup stops running at all.