This tutorial builds an automated PostgreSQL backup to S3 workflow on Ubuntu 24.04. You will create a consistent custom-format dump with pg_dump, encrypt it with Restic, store it in Raff Object Storage, apply retention, schedule backups with systemd, and prove recovery by restoring into a separate database.
Raff Object Storage is S3-compatible at https://s3.raffusercloud.com. Check the Raff Object Storage product page for the current region, pricing, included capacity, transfer allowances, and service commitments. Restic supports S3-compatible repositories, so the workflow uses the same repository model it documents for non-AWS S3 services.
This is a logical backup and restore workflow. It is suitable when your recovery point objective can be met by scheduled dumps. It is not point-in-time recovery (PITR): PostgreSQL uses base backups plus WAL archiving for recovery to a specific point between backups.
Prerequisites:
- A Raff Linux VM running Ubuntu 24.04
- PostgreSQL installed and a database you can dump locally
- SSH and sudo access
- A private Raff Object Storage bucket with a scoped read-write S3 key
- AWS CLI configured with a
raffprofile if you want to run the bucket verification command in Step 3 - Enough temporary disk space for one PostgreSQL dump
- A password manager or another secure off-VM location for the Restic repository password
📌 Recovery rule: A completed backup job is not proof of recoverability. The final step restores the latest recovery point into an isolated test database.
The original workflow was tested on a Raff 2 vCPU / 4 GB RAM Ubuntu 24.04 VM. For this refresh, the PostgreSQL dump/restore behavior, Restic S3 configuration, password-file handling, retention, pruning, and repository checks were re-verified against current PostgreSQL and Restic documentation in September 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
Check the PostgreSQL server version:
sudo -u postgres psql -tAc 'SHOW server_version;'
Use a pg_dump client from the same PostgreSQL major version as the server when practical. PostgreSQL documents that pg_dump can dump older servers, but it cannot dump a server newer than its own major version. See the official pg_dump reference.
Verification: restic version, pg_dump --version, and the server-version query should all complete successfully before you continue.
Step 2 — Identify and validate the source database
List the databases on the server:
sudo -u postgres psql -c '\l'
This tutorial uses raffapp. Replace that name everywhere with your real database name.
Create a temporary custom-format dump:
sudo -u postgres pg_dump \ --format=custom \ --file=/tmp/raffapp-test.dump \ raffapp
Validate the archive catalog and confirm the file is non-empty:
sudo -u postgres pg_restore --list /tmp/raffapp-test.dump | head test -s /tmp/raffapp-test.dump && echo 'Dump validation passed'
Remove the temporary archive:
sudo rm -f /tmp/raffapp-test.dump
PostgreSQL separates SQL dumps, filesystem-level backups, and continuous archiving as different backup approaches. For this tutorial, pg_dump creates a portable logical archive that pg_restore can reconstruct later. See PostgreSQL's Backup and Restore documentation.
Verification: the archive catalog should be readable and Dump validation passed should print before the test file is removed.
Step 3 — Prepare S3-compatible Raff Object Storage
Use a dedicated private bucket for database backups. For example:
company-production-postgres-backups
Create a scoped access key that has read-write access only to the backup bucket. Raff Object Storage currently supports per-bucket grants, so the backup process does not need credentials for unrelated buckets.
If your AWS CLI raff profile is already configured, verify access without uploading data:
aws s3api head-bucket \ --bucket company-production-postgres-backups \ --endpoint-url https://s3.raffusercloud.com \ --profile raff
A successful head-bucket command returns no output. If you still need the CLI profile, follow Raff Object Storage with AWS CLI first.
Verification: confirm the bucket is private, the backup key is scoped to it, and head-bucket completes without an access error.
Step 4 — Store Restic credentials without putting the repository password in the environment
Restic supports RESTIC_PASSWORD_FILE for automated jobs. Keeping the repository password in a root-only file avoids placing the password itself in the service environment.
Generate a strong repository password into a protected file:
openssl rand -base64 32 | \ sudo tee /etc/restic-postgres.password >/dev/null sudo chown root:root /etc/restic-postgres.password sudo chmod 600 /etc/restic-postgres.password
Copy that password into a password manager or another secure recovery location outside the VM:
sudo cat /etc/restic-postgres.password
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 the following values and replace the placeholders:
RESTIC_REPOSITORY="s3:https://s3.raffusercloud.com/company-production-postgres-backups/postgresql/raffapp" RESTIC_PASSWORD_FILE="/etc/restic-postgres.password" AWS_ACCESS_KEY_ID="REPLACE_WITH_RAFF_ACCESS_KEY" AWS_SECRET_ACCESS_KEY="REPLACE_WITH_RAFF_SECRET_KEY" AWS_DEFAULT_REGION="us-east" PGDATABASE="raffapp" DUMP_DIR="/var/backups/postgresql-restic"
Restic documents the s3:https://server/bucket form for non-AWS S3-compatible services and supports an explicit region through AWS_DEFAULT_REGION. Raff's current Object Storage documentation identifies the service region as us-east.
Check both files without printing their secrets:
sudo stat -c '%A %U:%G %n' \ /etc/restic-postgres.env \ /etc/restic-postgres.password
Verification: both files should be owned by root:root with -rw------- permissions.
Step 5 — Initialize the encrypted Restic repository
Load the protected environment and initialize the repository:
sudo bash -c ' set -a source /etc/restic-postgres.env set +a restic init '
Do not run restic init again if the same repository path is already initialized.
Verify repository access:
sudo bash -c ' set -a source /etc/restic-postgres.env set +a restic snapshots '
A new repository should open successfully and report that no snapshots exist yet.
Verification: restic snapshots must open the repository without asking for a password or returning an S3 authentication error.
Step 6 — Create a private local staging directory
Create a directory that only PostgreSQL and root can use for the temporary logical dump:
sudo install -d \ -m 700 \ -o postgres \ -g postgres \ /var/backups/postgresql-restic
Verify permissions:
sudo stat -c '%A %U:%G %n' \ /var/backups/postgresql-restic
Expected ownership is postgres:postgres with mode drwx------.
Verification: do not continue if another local user can read the staging directory.
Step 7 — Create the PostgreSQL backup to S3 script
Create the backup script:
sudo nano /usr/local/sbin/postgres-restic-backup.sh
Add:
#!/usr/bin/env bash set -Eeuo pipefail umask 077 ENV_FILE="/etc/restic-postgres.env" 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_FILE:?Missing RESTIC_PASSWORD_FILE}" : "${AWS_ACCESS_KEY_ID:?Missing AWS_ACCESS_KEY_ID}" : "${AWS_SECRET_ACCESS_KEY:?Missing AWS_SECRET_ACCESS_KEY}" : "${AWS_DEFAULT_REGION:?Missing AWS_DEFAULT_REGION}" : "${PGDATABASE:?Missing PGDATABASE}" : "${DUMP_DIR:?Missing DUMP_DIR}" install -d -m 700 -o postgres -g postgres "$DUMP_DIR" DUMP_FILE="${DUMP_DIR}/${PGDATABASE}.dump" exec 9>"$LOCK_FILE" if ! flock -n 9; then echo "Another PostgreSQL backup is already running" >&2 exit 1 fi cleanup() { rm -f "$DUMP_FILE" } trap cleanup EXIT echo "Creating PostgreSQL archive" runuser -u postgres -- pg_dump \ --format=custom \ --file="$DUMP_FILE" \ "$PGDATABASE" test -s "$DUMP_FILE" runuser -u postgres -- pg_restore \ --list "$DUMP_FILE" >/dev/null echo "Backing up encrypted archive to Restic" restic backup "$DUMP_FILE" \ --tag postgresql \ --tag "$PGDATABASE" echo "Applying snapshot retention" restic forget \ --keep-daily 7 \ --keep-weekly 4 \ --keep-monthly 12 echo "Backup completed successfully"
The dump path stays stable from run to run. That matters because Restic's retention policy groups snapshots by host and path by default; a stable path lets the daily, weekly, and monthly policy operate on the same backup set instead of treating every timestamped filename as a different group.
Make the script root-owned and executable, then check its syntax:
sudo chown root:root /usr/local/sbin/postgres-restic-backup.sh sudo chmod 750 /usr/local/sbin/postgres-restic-backup.sh sudo bash -n /usr/local/sbin/postgres-restic-backup.sh
Verification: bash -n should return no output and exit successfully.
Step 8 — Run and verify the first backup
Run the script manually:
sudo /usr/local/sbin/postgres-restic-backup.sh
List repository snapshots:
sudo bash -c ' set -a source /etc/restic-postgres.env set +a restic snapshots '
Inspect the latest snapshot contents:
sudo bash -c ' set -a source /etc/restic-postgres.env set +a restic ls latest '
Confirm the local dump was removed after Restic committed the snapshot:
sudo find /var/backups/postgresql-restic \ -maxdepth 1 -type f -print
The final command should print no files after a successful run.
Verification: you should see a new Restic snapshot containing raffapp.dump, while the staging directory is empty.
Step 9 — Schedule nightly PostgreSQL backups with systemd
Create the service:
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 nightly timer:
sudo nano /etc/systemd/system/postgres-restic-backup.timer
Add:
[Unit] Description=Run PostgreSQL Restic backup nightly [Timer] OnCalendar=*-*-* 02:30:00 Persistent=true RandomizedDelaySec=15m Unit=postgres-restic-backup.service [Install] WantedBy=timers.target
Enable it:
sudo systemctl daemon-reload sudo systemctl enable --now postgres-restic-backup.timer
Verify the schedule and run the service once through systemd:
systemctl list-timers postgres-restic-backup.timer sudo systemctl start postgres-restic-backup.service sudo journalctl -u postgres-restic-backup.service -n 100 --no-pager
Persistent=true causes a missed timer to run after the VM comes back online.
Verification: the timer should show a next-run time and the manual systemd run should finish without an error in the journal.
Step 10 — Schedule prune and repository checks separately
The backup script removes old snapshot references with restic forget, but it deliberately does not prune repository data during every nightly backup. Restic warns that prune can take significant time and locks the repository, so run it during a separate maintenance window. Restic also recommends checking the repository after pruning.
Create a maintenance service:
sudo nano /etc/systemd/system/postgres-restic-maintenance.service
Add:
[Unit] Description=Prune and check PostgreSQL Restic repository Wants=network-online.target After=network-online.target [Service] Type=oneshot EnvironmentFile=/etc/restic-postgres.env ExecStart=/usr/bin/restic prune ExecStart=/usr/bin/restic check User=root Group=root Nice=15 IOSchedulingClass=idle PrivateTmp=true NoNewPrivileges=true
Create a weekly timer:
sudo nano /etc/systemd/system/postgres-restic-maintenance.timer
Add:
[Unit] Description=Run weekly Restic repository maintenance [Timer] OnCalendar=Sun *-*-* 04:00:00 Persistent=true RandomizedDelaySec=30m Unit=postgres-restic-maintenance.service [Install] WantedBy=timers.target
Enable and verify it:
sudo systemctl daemon-reload sudo systemctl enable --now postgres-restic-maintenance.timer systemctl list-timers postgres-restic-maintenance.timer
For larger repositories, schedule maintenance when it cannot overlap a backup window.
Verification: the maintenance timer should be enabled and show its next weekly run.
Step 11 — Restore the latest backup into a clean test database
A PostgreSQL backup and restore process is complete only when the archive can be used by PostgreSQL again. Restore the latest Restic snapshot into a temporary directory:
sudo rm -rf /var/tmp/postgres-restic-restore sudo install -d -m 700 -o root -g root \ /var/tmp/postgres-restic-restore sudo bash -c ' set -a source /etc/restic-postgres.env set +a restic restore latest \ --target /var/tmp/postgres-restic-restore '
Locate the restored dump:
RESTORED_DUMP="$(sudo find /var/tmp/postgres-restic-restore \ -type f -name 'raffapp.dump' -print -quit)" test -n "$RESTORED_DUMP" && test -s "$RESTORED_DUMP" printf '%s\n' "$RESTORED_DUMP"
Inspect the archive before executing it. PostgreSQL warns that restoring a dump executes commands contained in the source archive, so only restore archives from a trusted source:
sudo -u postgres pg_restore \ --list "$RESTORED_DUMP" | head
Create an isolated recovery database:
sudo -u postgres dropdb --if-exists raffapp_restore_test sudo -u postgres createdb raffapp_restore_test
Restore schema and data without requiring the original role ownership and grants:
sudo -u postgres pg_restore \ --exit-on-error \ --no-owner \ --no-privileges \ --dbname=raffapp_restore_test \ "$RESTORED_DUMP"
Verify tables and run an application-specific integrity query:
sudo -u postgres psql \ --dbname=raffapp_restore_test \ --command='\dt'
The structural test above does not recreate cluster-wide PostgreSQL roles or prove application semantics. For a production recovery runbook, also document required roles, extensions, secrets, and application-level checks.
Verification: the restore must complete without pg_restore --exit-on-error stopping, the test database must contain the expected tables, and your application-specific integrity checks must pass.
Cleanup (Optional)
Remove the restore-test database and temporary files:
sudo -u postgres dropdb --if-exists raffapp_restore_test sudo rm -rf /var/tmp/postgres-restic-restore unset RESTORED_DUMP
To stop future backups and maintenance:
sudo systemctl disable --now \ postgres-restic-backup.timer \ postgres-restic-maintenance.timer
Remove the local units and scripts only when you intend to retire the workflow:
sudo rm -f \ /etc/systemd/system/postgres-restic-backup.service \ /etc/systemd/system/postgres-restic-backup.timer \ /etc/systemd/system/postgres-restic-maintenance.service \ /etc/systemd/system/postgres-restic-maintenance.timer \ /usr/local/sbin/postgres-restic-backup.sh sudo systemctl daemon-reload
Remove local credentials only after the Restic repository password and any required recovery credentials are stored safely elsewhere:
sudo rm -f \ /etc/restic-postgres.env \ /etc/restic-postgres.password sudo rm -rf /var/backups/postgresql-restic
These cleanup commands do not delete the encrypted Restic repository from Object Storage.
Troubleshooting
Restic returns Access Denied
Confirm the endpoint, region, bucket name, repository path, and scoped key permissions. You can safely print only the non-secret settings:
sudo grep -E \ '^(RESTIC_REPOSITORY|RESTIC_PASSWORD_FILE|AWS_DEFAULT_REGION)=' \ /etc/restic-postgres.env
The Raff endpoint is https://s3.raffusercloud.com and the current region identifier is us-east.
pg_dump reports a server version mismatch
Compare the client and server versions:
pg_dump --version sudo -u postgres psql -tAc 'SHOW server_version;'
Install a PostgreSQL client version that is the same major version as the server or newer.
The timer runs but no new Restic snapshot appears
Inspect the service and journal:
sudo systemctl status postgres-restic-backup.service --no-pager sudo journalctl \ -u postgres-restic-backup.service \ -n 120 --no-pager
Then run /usr/local/sbin/postgres-restic-backup.sh manually to expose the first failing command.
restic prune overlaps the nightly backup
Move the maintenance timer to a wider low-traffic window. Pruning locks the repository, so a concurrent backup may have to wait or fail depending on timing.
A restore succeeds but ownership or permissions differ
This tutorial deliberately uses --no-owner --no-privileges for an isolated structural restore test. If production recovery requires original ownership and grants, recreate the required PostgreSQL roles first and test that recovery path separately.
You need point-in-time recovery instead of nightly dumps
A pg_dump archive represents the database at one backup point. For PITR, use PostgreSQL base backups plus continuous WAL archiving or a managed database service that provides the recovery objective you need. PostgreSQL documents this separately under Continuous Archiving and Point-in-Time Recovery.
Conclusion
You now have a PostgreSQL backup to S3 workflow that creates a PostgreSQL-aware logical archive, encrypts it with Restic, stores it outside the source VM, applies retention, keeps expensive repository maintenance out of the nightly backup path, and proves the recovery path with an isolated restore test.
For the broader recovery design, read Database Backup Strategy for SaaS Apps and PostgreSQL Replication vs Backups vs Snapshots. For the S3 client setup, follow the AWS CLI procedure linked in Step 3.
Use Raff Object Storage for portable S3-compatible recovery points and Raff Data Protection as a separate VM-level recovery layer. Choose the backup frequency and recovery method from your actual RPO and RTO rather than from a generic schedule.
