In this tutorial, you’ll create a consistent PostgreSQL backup with pg_dump, encrypt and upload it to Raff Object Storage with Restic, schedule the workflow with a systemd timer, apply retention rules, verify repository health, and restore the latest backup into a clean test database.
Restic is an encrypted, deduplicating backup tool that supports S3-compatible object storage. PostgreSQL should not be backed up by copying a live data directory. A safer portable workflow is to create a logical archive with pg_dump, validate it with pg_restore, and then let Restic encrypt and store that archive off the VM.
Raff Object Storage exposes the S3-compatible endpoint https://s3.raffusercloud.com. Keeping database backups outside the source VM protects recovery points from VM deletion, filesystem failure, failed deployments, and accidental local data loss.
Prerequisites:
- A Raff Linux VM running Ubuntu 24.04
- PostgreSQL installed and a database you can read with
pg_dump - SSH and sudo access
- A private Raff Object Storage bucket
- An S3 access key and secret key with access to that bucket
- A secure external place to store the Restic repository password
- Enough temporary disk space for one compressed PostgreSQL dump
📌 Recovery rule: A successful backup command is not proof of recoverability. This tutorial finishes with a restore into a separate test database.
The original workflow was tested on a Raff 2 vCPU / 4 GB RAM VM. The PostgreSQL archive, Restic S3, retention, repository-check, and systemd guidance was reviewed against current PostgreSQL and Restic documentation in July 2026.
Step 1 — Install Restic and PostgreSQL client tools
Update Ubuntu and install the required packages:
sudo apt update sudo apt upgrade -y sudo apt install -y restic postgresql-client
Verify the tools:
restic version pg_dump --version pg_restore --version
Use a pg_dump client from the same PostgreSQL major version as the server when possible. A newer pg_dump can usually dump an older server, but an older client cannot dump a newer PostgreSQL server.
Query the server version:
sudo -u postgres psql -tAc 'SHOW server_version;'
Compare it with:
pg_dump --version
PostgreSQL documents version compatibility in its pg_dump reference.
Step 2 — Identify and test the source database
List available databases:
sudo -u postgres psql -c '\l'
This tutorial uses raffapp. Replace it with the real database name throughout the workflow.
Create a temporary custom-format dump:
sudo -u postgres pg_dump \ --format=custom \ --file=/tmp/raffapp-test.dump \ raffapp
Validate that PostgreSQL can read the archive catalog:
sudo -u postgres pg_restore \ --list \ /tmp/raffapp-test.dump | head
Confirm the file is non-empty:
test -s /tmp/raffapp-test.dump && echo 'Dump validation passed'
Remove the test archive:
sudo rm -f /tmp/raffapp-test.dump
⚠️ Consistency rule: Use
pg_dumpor another PostgreSQL-aware backup method. Do not copy/var/lib/postgresqlwhile the server is running and assume the result is a valid portable backup.
Step 3 — Create a private Object Storage bucket
Create a dedicated private bucket from the Raff dashboard. A clear naming pattern is:
company-production-postgres-backups
Create S3 credentials scoped to the backup bucket when your access model supports it. Do not use public bucket access for database archives.
To verify an existing AWS CLI profile, run:
aws s3api head-bucket \ --bucket company-production-postgres-backups \ --endpoint-url https://s3.raffusercloud.com \ --profile raff
A successful command returns no output.
Review Raff Object Storage with AWS CLI when you need to create or inspect the bucket from the command line.
Step 4 — Store Restic settings in a root-only environment file
Generate a repository password without placing it directly in shell history:
RESTIC_REPOSITORY_PASSWORD="$(openssl rand -hex 32)" printf 'Store this Restic repository password externally: %s\n' \ "$RESTIC_REPOSITORY_PASSWORD"
Store the displayed password in a password manager or another recovery location outside the VM. Losing it makes the encrypted repository unusable.
Create the environment file:
sudo install -m 600 -o root -g root /dev/null /etc/restic-postgres.env sudo nano /etc/restic-postgres.env
Add:
RESTIC_REPOSITORY="s3:https://s3.raffusercloud.com/company-production-postgres-backups/postgresql" AWS_ACCESS_KEY_ID="REPLACE_WITH_RAFF_ACCESS_KEY" AWS_SECRET_ACCESS_KEY="REPLACE_WITH_RAFF_SECRET_KEY" AWS_DEFAULT_REGION="us-east-1" RESTIC_PASSWORD="REPLACE_WITH_THE_EXTERNAL_REPOSITORY_PASSWORD" PGDATABASE="raffapp"
Do not include export; the systemd service will read these values with EnvironmentFile=.
Verify permissions without printing secrets:
sudo stat -c '%A %U:%G %n' /etc/restic-postgres.env
Expected output:
-rw------- root:root /etc/restic-postgres.env
Remove the temporary shell variable:
unset RESTIC_REPOSITORY_PASSWORD
Step 5 — Initialize and verify the Restic repository
Load the environment file into a root shell and initialize the repository:
sudo bash -c ' set -a source /etc/restic-postgres.env set +a restic init '
Expected output includes:
created restic repository
If the repository already exists, restic init reports that it has already been initialized. Do not create a second repository over the same path.
Verify access:
sudo bash -c ' set -a source /etc/restic-postgres.env set +a restic snapshots '
A new repository should open successfully and report no snapshots.
Step 6 — Create the private staging directory
Create a local directory for the temporary PostgreSQL archive:
sudo install -d \ -m 700 \ -o postgres \ -g postgres \ /var/backups/postgresql-restic
Verify it:
sudo stat -c '%A %U:%G %n' /var/backups/postgresql-restic
Expected output:
drwx------ postgres:postgres /var/backups/postgresql-restic
The dump remains in this directory only until Restic completes successfully.
Step 7 — Create the backup script
Create the script:
sudo nano /usr/local/sbin/postgres-restic-backup.sh
Add:
#!/usr/bin/env bash set -Eeuo pipefail ENV_FILE="/etc/restic-postgres.env" DUMP_DIR="/var/backups/postgresql-restic" LOCK_FILE="/run/lock/postgres-restic-backup.lock" if [[ ! -r "$ENV_FILE" ]]; then echo "Cannot read $ENV_FILE" >&2 exit 1 fi set -a source "$ENV_FILE" set +a : "${RESTIC_REPOSITORY:?Missing RESTIC_REPOSITORY}" : "${RESTIC_PASSWORD:?Missing RESTIC_PASSWORD}" : "${AWS_ACCESS_KEY_ID:?Missing AWS_ACCESS_KEY_ID}" : "${AWS_SECRET_ACCESS_KEY:?Missing AWS_SECRET_ACCESS_KEY}" : "${PGDATABASE:?Missing PGDATABASE}" install -d -m 700 -o postgres -g postgres "$DUMP_DIR" exec 9>"$LOCK_FILE" if ! flock -n 9; then echo "Another PostgreSQL backup is already running" >&2 exit 1 fi TIMESTAMP="$(date -u +'%Y-%m-%dT%H-%M-%SZ')" DUMP_FILE="${DUMP_DIR}/${PGDATABASE}_${TIMESTAMP}.dump" cleanup() { rm -f "$DUMP_FILE" } trap cleanup EXIT echo "Creating PostgreSQL archive: $DUMP_FILE" sudo -u postgres pg_dump \ --format=custom \ --file="$DUMP_FILE" \ "$PGDATABASE" test -s "$DUMP_FILE" sudo -u postgres pg_restore --list "$DUMP_FILE" >/dev/null echo "Uploading encrypted archive to Restic" restic backup "$DUMP_FILE" \ --tag postgresql \ --tag "$PGDATABASE" echo "Applying retention policy" restic forget \ --tag postgresql \ --tag "$PGDATABASE" \ --keep-daily 7 \ --keep-weekly 4 \ --keep-monthly 12 \ --prune echo "Verifying repository metadata" restic check echo "Backup completed successfully"
Make it root-owned and executable:
sudo chown root:root /usr/local/sbin/postgres-restic-backup.sh sudo chmod 750 /usr/local/sbin/postgres-restic-backup.sh
Validate the shell syntax:
sudo bash -n /usr/local/sbin/postgres-restic-backup.sh
The script uses flock to prevent overlapping runs and a shell trap to remove the temporary dump whether the backup succeeds or fails.
📌 Performance note:
restic forget --prunecan become expensive on large repositories. For larger backup sets, runforgetdaily but schedulepruneless frequently in a separate maintenance service.
Step 8 — Run and verify the first backup
Run the script manually:
sudo /usr/local/sbin/postgres-restic-backup.sh
List PostgreSQL snapshots:
sudo bash -c ' set -a source /etc/restic-postgres.env set +a restic snapshots --tag postgresql '
Expected output includes one snapshot tagged with:
postgresql raffapp
Verify that the local staging directory no longer contains the archive:
sudo find /var/backups/postgresql-restic \ -maxdepth 1 \ -type f \ -print
The command should return no files after a successful run.
Inspect the latest snapshot contents:
sudo bash -c ' set -a source /etc/restic-postgres.env set +a restic ls latest --tag postgresql '
Step 9 — Schedule backups with systemd
Create the service unit:
sudo nano /etc/systemd/system/postgres-restic-backup.service
Add:
[Unit] Description=Back up PostgreSQL to Raff Object Storage with Restic Wants=network-online.target After=network-online.target postgresql.service [Service] Type=oneshot EnvironmentFile=/etc/restic-postgres.env ExecStart=/usr/local/sbin/postgres-restic-backup.sh User=root Group=root Nice=10 IOSchedulingClass=best-effort IOSchedulingPriority=7 PrivateTmp=true NoNewPrivileges=true
Create the timer:
sudo nano /etc/systemd/system/postgres-restic-backup.timer
Add:
[Unit] Description=Run the PostgreSQL Restic backup nightly [Timer] OnCalendar=*-*-* 02:30:00 Persistent=true RandomizedDelaySec=15m Unit=postgres-restic-backup.service [Install] WantedBy=timers.target
Reload systemd and enable the timer:
sudo systemctl daemon-reload sudo systemctl enable --now postgres-restic-backup.timer
Verify the schedule:
systemctl list-timers postgres-restic-backup.timer
Run the service through systemd once:
sudo systemctl start postgres-restic-backup.service sudo journalctl \ -u postgres-restic-backup.service \ -n 100 \ --no-pager
Persistent=true runs a missed timer after the VM returns online.
Step 10 — Restore the latest backup into a test database
Create a temporary restore directory:
sudo rm -rf /var/tmp/postgres-restic-restore sudo install -d -m 700 -o root -g root \ /var/tmp/postgres-restic-restore
Restore the latest PostgreSQL-tagged snapshot:
sudo bash -c ' set -a source /etc/restic-postgres.env set +a restic restore latest \ --tag postgresql \ --target /var/tmp/postgres-restic-restore '
Locate the restored archive:
RESTORED_DUMP="$(sudo find /var/tmp/postgres-restic-restore \ -type f \ -name '*.dump' \ -print \ -quit)" printf '%s\n' "$RESTORED_DUMP"
Fail safely if no archive was restored:
test -n "$RESTORED_DUMP" && test -s "$RESTORED_DUMP"
Inspect the archive catalog:
sudo -u postgres pg_restore \ --list \ "$RESTORED_DUMP" | head
Create a clean restore-test database:
sudo -u postgres dropdb --if-exists raffapp_restore_test sudo -u postgres createdb raffapp_restore_test
Restore without modifying the production database:
sudo -u postgres pg_restore \ --exit-on-error \ --no-owner \ --no-privileges \ --dbname=raffapp_restore_test \ "$RESTORED_DUMP"
List restored tables:
sudo -u postgres psql \ --dbname=raffapp_restore_test \ --command='\dt'
Run application-specific row-count or integrity queries before declaring the restore successful.
Clean up the test environment:
sudo -u postgres dropdb raffapp_restore_test sudo rm -rf /var/tmp/postgres-restic-restore unset RESTORED_DUMP
⚠️ Restore safety: Never test a restore over the production database. Use a separate database or isolated recovery VM.
Step 11 — Monitor retention and repository health
List recent snapshots:
sudo bash -c ' set -a source /etc/restic-postgres.env set +a restic snapshots --tag postgresql --latest 10 '
Review timer and service status:
systemctl status postgres-restic-backup.timer --no-pager systemctl status postgres-restic-backup.service --no-pager
Review recent logs:
sudo journalctl \ -u postgres-restic-backup.service \ --since '7 days ago' \ --no-pager
Run a metadata check:
sudo bash -c ' set -a source /etc/restic-postgres.env set +a restic check '
Periodically read a sample of stored pack data:
sudo bash -c ' set -a source /etc/restic-postgres.env set +a restic check --read-data-subset=10% '
Repository checks confirm Restic structure and sampled storage readability. Restore tests confirm that PostgreSQL can actually use the archive. Production backup operations need both.
