Running PostgreSQL on a single VPS is convenient until you realize that the database and its Docker volume live on that same machine. A failed disk, an accidental docker compose down -v, or one bad deployment can take the data with it.
I wanted a PostgreSQL backup process that was simple enough to understand without introducing another backup platform. The result uses familiar Linux tools:
- Cloudflare R2 as off-site, S3-compatible object storage
- rclone to upload and manage backup files in R2
- a systemd service to define the backup job
- a systemd timer to schedule the job
- flock to prevent two copies of the job from running at once
This article walks through the setup I use for PostgreSQL running in Docker. It creates a transaction-consistent logical dump plus an additional archive of the Docker volume, then uploads both files to Cloudflare R2.
The Backup Flow
Each scheduled run follows the same sequence:
- systemd starts a one-shot service.
flockacquires a lock or exits if the previous run is still active.- A shell script creates the backup in a temporary directory.
- The script validates the generated dump and volume archive.
- rclone uploads it to a private R2 bucket.
- Old remote backups are pruned only after the new upload succeeds.
- The temporary directory is removed, even if the script fails.
The VPS only holds the temporary working copy. R2 holds the retained backups under a dedicated postgres/ prefix.
Before You Start
This guide assumes that:
- Docker and Docker Compose are already installed
- your containers and volumes are already running
- the VPS uses systemd
- you can run commands with
sudo
Install rclone and flock on Debian or Ubuntu. flock is provided by the util-linux package and is normally already installed.
sudo apt updatesudo apt install rclone util-linuxInspect the exact data source before writing a backup script. For a Compose project, start with:
docker compose configdocker volume lsdocker inspect your-containerDo not guess a named volume from the short name in compose.yaml. Compose normally prefixes it with the project name. For example, postgres_data may become your_project_postgres_data.
Set Up Cloudflare R2 and rclone
Create a private R2 bucket in the Cloudflare dashboard. I store PostgreSQL backups under a dedicated prefix:
your-r2-bucket/└── postgres/Then create an R2 API token with Object Read & Write permission, scoped to that bucket. Read permission is needed for listing and restore operations, while write permission is needed for uploads and pruning. Keep the Access Key ID and Secret Access Key safe. Cloudflare only shows the secret once.
Because the systemd service in this guide runs as root, configure rclone as root too:
sudo rclone configCreate a new remote called r2, select the S3 storage type, choose Cloudflare as the provider, then enter the access key, secret, and endpoint:
https://<ACCOUNT_ID>.r2.cloudflarestorage.comIf the token has object-level permissions for a specific bucket, set no_check_bucket = true in the remote configuration. This prevents rclone from performing bucket-level checks that the scoped token cannot authorize.
You can compare your setup with the official Cloudflare rclone guide. Test the remote before building any automation:
sudo rclone lsf r2:your-r2-bucketObject storage does not require you to create the postgres/ prefix in advance. It appears when the first backup is uploaded.
Keep R2 credentials in rclone’s protected configuration instead of repeating them in every service environment file. To see which file root uses, run:
sudo rclone config fileBackup File Structure
Keep the backup configuration beside the PostgreSQL Compose project. The working example uses this structure:
postgres/├── backup-postgres.sh├── docker-compose.yml├── postgres-backup.env.example├── postgres-backup.service└── postgres-backup.timerExample PostgreSQL Docker Compose Setup
If PostgreSQL is not running yet, create docker-compose.yml inside the postgres/ directory. Treat this as a starting point rather than a configuration you must copy exactly: choose the PostgreSQL version, database name, user, networks, and resource settings that fit your application.
services: db: image: postgres:18 restart: unless-stopped environment: POSTGRES_USER: replace_with_database_user POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password POSTGRES_DB: replace_with_database_name healthcheck: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] interval: 10s timeout: 5s retries: 5 start_period: 10s volumes: - postgres_data:/var/lib/postgresql secrets: - postgres_password
secrets: postgres_password: file: ./secrets/postgres_password.txt
volumes: postgres_data:This template uses the official PostgreSQL 18 image, persists its data in a named volume, reads the password from a Compose secret, and adds a health check. Replace the placeholder user and database name before starting it. If you choose a different PostgreSQL major version, check that version’s image documentation for the correct data mount path.
Create the password file before starting PostgreSQL:
mkdir -p secretsopenssl rand -base64 32 > secrets/postgres_password.txtchmod 600 secrets/postgres_password.txtStart the container and wait until its health status is healthy:
docker compose up -ddocker compose psThis example does not publish port 5432 to the host. Services declared in the same Compose file can connect to PostgreSQL with the hostname db. For services in another Compose project, attach both services to a shared Docker network. If a host process needs database access, add an explicit port mapping bound to a private or loopback address rather than exposing PostgreSQL publicly.
Before configuring the backup script, retrieve the actual container and volume names:
docker compose psdocker volume ls --filter name=postgres_dataUse the values from your final Compose configuration and command output as follows:
POSTGRES_CONTAINERis the container name shown bydocker compose ps.POSTGRES_USERis thePOSTGRES_USERvalue.POSTGRES_DBis thePOSTGRES_DBvalue.VOLUME_NAMEis the complete name shown bydocker volume ls, including the Compose project prefix.
Create the PostgreSQL Backup Script
PostgreSQL needs more care. A tar.gz of a live PostgreSQL data directory is not guaranteed to be transaction-consistent, even if Docker mounts the volume read-only for the backup container.
I create two complementary files:
- a
pg_dumpin PostgreSQL’s custom format, which is the primary, consistent database backup - a compressed archive of the complete Docker volume, which is an additional best-effort copy
The database container remains online during both operations.
Create backup-postgres.sh and adjust the first four values for your environment:
#!/usr/bin/env bashset -Eeuo pipefail
# Full-volume and logical backup for PostgreSQL.# The container remains running while both backups are created.# Configure the Rclone destination in /etc/postgres-backup.env.
readonly VOLUME_NAME="REPLACE_WITH_DOCKER_VOLUME_NAME"readonly POSTGRES_CONTAINER="REPLACE_WITH_POSTGRES_CONTAINER_NAME"readonly POSTGRES_USER="REPLACE_WITH_POSTGRES_USER"readonly POSTGRES_DB="REPLACE_WITH_POSTGRES_DATABASE"readonly CONFIG_FILE="${POSTGRES_BACKUP_CONFIG:-/etc/postgres-backup.env}"readonly RETENTION_COUNT=3
if [[ ! -r "${CONFIG_FILE}" ]]; then echo "Missing backup configuration: ${CONFIG_FILE}" >&2 exit 1fi
# shellcheck source=/dev/nullsource "${CONFIG_FILE}"
: "${RCLONE_DEST:?Set RCLONE_DEST, for example r2:my-bucket/postgres}"
if [[ -n "${RCLONE_CONFIG:-}" ]]; then export RCLONE_CONFIGfi
readonly REMOTE_DIR="${RCLONE_DEST%/}"readonly STAGING_DIR="$(mktemp -d /tmp/postgres-backup.XXXXXX)"readonly TIMESTAMP="$(date -u +%Y-%m-%dT%H-%M-%SZ)"readonly ARCHIVE_NAME="postgres-${TIMESTAMP}.tar.gz"readonly ARCHIVE_PATH="${STAGING_DIR}/${ARCHIVE_NAME}"readonly DUMP_NAME="postgres-${POSTGRES_DB}-${TIMESTAMP}.dump"readonly DUMP_PATH="${STAGING_DIR}/${DUMP_NAME}"
cleanup() { rm -rf "${STAGING_DIR}"}trap cleanup EXIT
if ! docker volume inspect "${VOLUME_NAME}" >/dev/null; then echo "PostgreSQL volume does not exist: ${VOLUME_NAME}" >&2 exit 1fi
# pg_dump creates a transaction-consistent logical backup while PostgreSQL is# online. The custom format is compressed and supports selective restoration.docker exec "${POSTGRES_CONTAINER}" \ pg_dump \ --username="${POSTGRES_USER}" \ --dbname="${POSTGRES_DB}" \ --format=custom >"${DUMP_PATH}"
# Confirm that pg_restore can read the completed archive before uploading it.docker exec -i "${POSTGRES_CONTAINER}" pg_restore --list \ <"${DUMP_PATH}" >/dev/null
# Use a temporary container so the archive contains the mounted volume contents# rather than the PostgreSQL container filesystem.docker run --rm \ --mount "type=volume,source=${VOLUME_NAME},target=/data,readonly" \ --mount "type=bind,source=${STAGING_DIR},target=/backup" \ alpine:3.20 \ tar czf "/backup/${ARCHIVE_NAME}" -C /data .
tar -tzf "${ARCHIVE_PATH}" >/dev/null
# Upload must succeed before any old remote backup is deleted.rclone copyto "${ARCHIVE_PATH}" "${REMOTE_DIR}/${ARCHIVE_NAME}" \ --checksum \ --retries 3 \ --low-level-retries 10
rclone copyto "${DUMP_PATH}" "${REMOTE_DIR}/${DUMP_NAME}" \ --checksum \ --retries 3 \ --low-level-retries 10
prune_backups() { local include_pattern="$1" local -a backups
mapfile -t backups < <( rclone lsf "${REMOTE_DIR}" \ --files-only \ --include "${include_pattern}" | sort -r )
if (( ${#backups[@]} > RETENTION_COUNT )); then for old_backup in "${backups[@]:RETENTION_COUNT}"; do [[ -n "${old_backup}" ]] || continue rclone deletefile "${REMOTE_DIR}/${old_backup}" done fi}
# Keep exactly the three newest copies of each backup type.prune_backups 'postgres-*.tar.gz'prune_backups "postgres-${POSTGRES_DB}-*.dump"
echo "Uploaded ${ARCHIVE_NAME} and ${DUMP_NAME} to ${REMOTE_DIR}; retained ${RETENTION_COUNT} of each."The database user needs enough permission to dump the selected database. This example does not pass a password because docker exec runs pg_dump inside the already configured PostgreSQL container.
Create the Environment File Template
Create postgres-backup.env.example in the same directory:
# Rclone remote and bucket/prefix. The remote must already be configured.# Example: r2:my-r2-bucket/postgresRCLONE_DEST="r2:REPLACE_WITH_YOUR_BUCKET/postgres"
# If the systemd job runs as root and the Rclone config is not in root's# default location, uncomment and set this to the config file used by root.# RCLONE_CONFIG="/etc/rclone/rclone.conf"Create the systemd Service
A systemd service describes how to run the job. Create postgres-backup.service beside the script:
[Unit]Description=Back up PostgreSQL volume and database to R2Requires=docker.serviceAfter=docker.service network-online.targetWants=network-online.target
[Service]Type=oneshotUser=rootGroup=rootExecStart=/usr/bin/flock -n /var/lock/postgres-backup.lock /usr/local/sbin/backup-postgresTimeoutStartSec=6hType=oneshot means the command runs to completion instead of staying alive as a daemon. flock -n tries to acquire the lock without waiting. If a previous backup still holds it, the new run exits immediately instead of uploading two backups concurrently.
The job runs as root because it needs access to Docker and root’s rclone configuration.
Create the systemd Timer
Create postgres-backup.timer beside the service:
[Unit]Description=Daily PostgreSQL R2 backup timer
[Timer]OnCalendar=*-*-* 03:00:00 UTCPersistent=trueUnit=postgres-backup.service
[Install]WantedBy=timers.targetPersistent=true tells systemd to catch up after boot if the VPS was off at the scheduled time.
Configure the Backup
After configuring the r2 remote, copy the environment template into /etc, edit its destination, and restrict its permissions:
sudo cp postgres-backup.env.example /etc/postgres-backup.envsudoedit /etc/postgres-backup.envsudo chmod 600 /etc/postgres-backup.envSet the R2 destination in /etc/postgres-backup.env:
RCLONE_DEST="r2:your-r2-bucket/postgres"If root’s rclone configuration is stored somewhere other than its default location, add:
RCLONE_CONFIG="/etc/rclone/rclone.conf"Do not store R2 secrets in this environment file. Keep them in rclone’s protected configuration.
Install the systemd Job
Install the script, service, and timer from the postgres/ directory:
sudo install -m 0750 backup-postgres.sh /usr/local/sbin/backup-postgressudo install -m 0644 \ postgres-backup.service \ postgres-backup.timer \ /etc/systemd/system/sudo systemctl daemon-reloadsudo systemctl enable --now postgres-backup.timerThe install commands copy the files to their runtime locations and apply their required modes. The timer gives this cron-like workflow clear service dependencies, centralized logs in the journal, missed-run handling, and easy manual execution.
Run and Inspect a Backup
Do not wait until 03:00 to learn that the volume name or R2 path is wrong. Start the service manually:
sudo systemctl start postgres-backup.serviceCheck the timer and logs:
sudo systemctl status postgres-backup.timersudo journalctl -u postgres-backup.serviceFinally, confirm that the expected objects exist in R2:
sudo rclone lsf r2:your-r2-bucket/postgresA successful PostgreSQL run uploads two files with the same UTC timestamp:
postgres-2026-08-10T03-00-00Z.tar.gzpostgres-your_database-2026-08-10T03-00-00Z.dumpRestore
A backup is only useful if it can be restored. Download a PostgreSQL dump to a safe test machine or staging server:
rclone copyto \ r2:your-r2-bucket/postgres/postgres-your_database-TIMESTAMP.dump \ ./postgres.dumpAfter creating an empty test database, restore the dump:
docker exec -i your_postgres_container pg_restore \ --username=your_postgres_user \ --dbname=your_restore_test_database \ --clean \ --if-exists \ < postgres.dumpVerify important tables, record counts, and application behavior. Do not test a restore over your production database.
Restoring a raw PostgreSQL volume is more sensitive: stop PostgreSQL, restore into an empty volume, and use the same PostgreSQL major version that created the archive. The logical dump should remain the preferred restore source.
Improvements Worth Adding
This setup intentionally stays small, but a production backup plan should also consider:
- monitoring failed systemd services and missing recent R2 objects
- keeping more than three backups or using daily, weekly, and monthly retention
- configuring an R2 lifecycle rule instead of pruning in the script
- encrypting sensitive backups with rclone crypt before upload
- creating a separate backup in another provider or account
- documenting and rehearsing the full restore procedure
Three copies of a broken archive are still broken. Local validation catches obvious corruption, but only a real restore test proves that the data is usable.
Wrapping Up
This approach has very few moving parts: a script knows how to produce and validate one backup, rclone moves it off the VPS, systemd schedules and logs the job, and flock prevents overlap.
The most important lesson is that a database needs a database-aware backup. For PostgreSQL, that means treating the verified pg_dump as the reliable recovery artifact and the live Docker volume archive only as an additional copy.