Skip to content
Back to blog

Blog

How I Back Up 20+ Docker Services to Dropbox Automatically

6 min
Table of Contents
server and cloud backup in my homelab

I lost everything once.

Not metaphorically. A rm -rf /var/lib/containerd/* during a disk-space cleanup wiped out every Docker volume on my Oracle ARM server. Paperless documents, Joplin notes, Ghostfolio portfolios, Postiz drafts. Gone. I spent a weekend rebuilding the whole stack from memory.

That was the last time I ran a homelab without a real backup strategy.

This post is about what I built after that: a script that dumps all my Postgres databases, copies my Docker configs and .env files, grabs my Paperless documents, and syncs everything to Dropbox every three days. Fully automated, no paid tools, no third-party agents.

If you run a self-hosted homelab on a VPS or home server, this is the setup I wish I had a year ago.

What I’m running

My server is an Oracle Cloud ARM instance (24GB RAM, Ubuntu 24.04) running 20+ Docker services through Cloudflare Tunnel. The stack includes:

Each service has its own Postgres container with a different superuser and database name. That’s the part that makes most generic backup guides useless.

Why not just copy Docker volumes?

The short answer: raw volume copies of a live Postgres container are unreliable. If the database is actively writing when you copy the files, your backup may be inconsistent and unrestorable. The official Postgres documentation recommends using pg_dump specifically because it creates a consistent snapshot without needing to stop the database.

For non-database volumes like Paperless media, rsync is fine. For Postgres, pg_dump is the right tool.

The approach

The backup folder structure diagram for docker

The backup script does four things in order:

  1. Runs pg_dump inside each Postgres container to create a clean SQL dump
  2. Copies all Docker compose files, .env files, and non-database volumes using rsync
  3. Syncs everything to Dropbox via rclone
  4. Deletes Dropbox backup folders older than 30 days

Each backup lands in a dated folder: oracle-server-backups/2026-08-01/. The structure inside looks like this:

oracle-server-backups/
└── 2026-08-01/
    ├── postgres-dumps/
    │   ├── paperless-20260801.sql
    │   ├── ghostfolio-20260801.sql
    │   ├── linkwarden-20260801.sql
    │   └── ... (11 databases total)
    ├── configs/
    │   ├── docker/         ← compose files, .env files, service data
    │   └── rybbit/         ← separate project directory
    └── volumes/
        ├── actual-budget-data/
        ├── paperless-media/
        └── paperless-data/

Setting up rclone with Dropbox on a headless server

The rclone OAuth flow on headless server

This is the part most guides skip over. Because your server has no browser, you cannot use rclone’s default OAuth flow. You need to run rclone authorize on your local machine instead and paste the token back to the server.

Install rclone on your server first:

curl https://rclone.org/install.sh | sudo bash

Then start the config:

rclone config

Walk through the prompts: name the remote (I used Dropbox, capital D matters), choose dropbox as the storage type, leave the client ID and secret blank, and when asked about auto config, choose n since you’re on a headless machine.

It will then tell you to run this on your local machine:

rclone authorize "dropbox"

On Windows, install rclone from rclone.org/downloads and run:

.\rclone.exe authorize "dropbox"

Your browser opens, you log in to Dropbox, and rclone prints a token. Paste that token into the server terminal when prompted. Done.

Verify it works:

rclone ls Dropbox:

If you see your Dropbox files, you’re connected.

Finding the right Postgres user for each container

Here’s the problem no generic guide mentions: each Postgres container in a typical homelab uses a different superuser. Some use postgres, some use the app name, some use a custom name set in the compose file.

Running pg_dump -U postgres on a container where the superuser is paperless will fail with a role error. You need to check each container’s environment variables:

for container in your-db-1 your-db-2; do
  echo "=== $container ==="
  docker inspect "$container" --format '{{range .Config.Env}}{{println .}}{{end}}' \
    | grep -E 'POSTGRES_USER|POSTGRES_DB'
done

This gives you the exact username and database name for each container, which is what you need to pass to pg_dump.

The backup script

Create the file:

nano ~/docker/backup-to-dropbox.sh
#!/bin/bash
set -euo pipefail
 
BACKUP_DIR="/tmp/oracle-backup-$(date +%Y%m%d-%H%M%S)"
DROPBOX_DEST="Dropbox:/oracle-server-backups"
LOG="/var/log/dropbox-backup.log"
RETAIN_DAYS=30
 
echo "" >> "$LOG"
echo "=== Backup started: $(date) ===" >> "$LOG"
 
mkdir -p "$BACKUP_DIR/postgres-dumps"
mkdir -p "$BACKUP_DIR/configs"
mkdir -p "$BACKUP_DIR/volumes"
 
# Postgres dumps
# Format: ["container_name"]="db_user:db_name:dump_filename"
declare -A CONTAINERS=(
  ["paperless-db-1"]="paperless:paperless:paperless"
  ["ghostfolio-postgres"]="ghostfolio:ghostfolio-db:ghostfolio"
  ["linkwarden-postgres-1"]="postgres:postgres:linkwarden"
  ["postiz-postgres"]="postiz-user:postiz-db-local:postiz"
  ["joplin-postgres"]="joplin:joplin:joplin"
  ["dawarich_db"]="dawarich:dawarich_production:dawarich"
  ["yamtrack-db"]="yamtrack:yamtrack:yamtrack"
  ["sparkyfitness-db"]="sparky:sparkyfitness_db:sparkyfitness"
  ["coolify-db"]="coolify:coolify:coolify"
  ["temporal-postgresql"]="temporal:temporal:temporal"
  ["postgres"]="xahidex:analytics:analytics"
)
 
for container in "${!CONTAINERS[@]}"; do
  IFS=':' read -r db_user db_name dump_name <<< "${CONTAINERS[$container]}"
  echo -n "Dumping $dump_name... " >> "$LOG"
  if docker exec "$container" pg_dump -U "$db_user" "$db_name" \
    > "$BACKUP_DIR/postgres-dumps/${dump_name}-$(date +%Y%m%d).sql" 2>> "$LOG"; then
    echo "OK" >> "$LOG"
  else
    echo "FAILED" >> "$LOG"
  fi
done
 
# Docker configs, compose files, .env files
echo "Copying Docker configs..." >> "$LOG"
sudo rsync -a \
  --exclude='audiobookshelf/audiobooks/' \
  --exclude='audiobookshelf/podcasts/' \
  --exclude='linkwarden/pgdata/' \
  --exclude='linkwarden/meili_data/' \
  --exclude='onetimesecret/data/redis/' \
  ~/docker/ "$BACKUP_DIR/configs/docker/" 2>> "$LOG" || true
 
# Rybbit (lives outside ~/docker)
echo "Copying Rybbit..." >> "$LOG"
cp -r ~/rybbit "$BACKUP_DIR/configs/rybbit/" 2>> "$LOG" || true
 
# Actual Budget
echo "Copying Actual Budget..." >> "$LOG"
cp -r ~/actual-budget-data "$BACKUP_DIR/volumes/actual-budget-data/" 2>> "$LOG" || true
 
# Paperless documents and search index
echo "Copying Paperless media..." >> "$LOG"
sudo rsync -a /mnt/media/docker/paperless/media/ \
  "$BACKUP_DIR/volumes/paperless-media/" 2>> "$LOG" || true
 
echo "Copying Paperless data..." >> "$LOG"
sudo rsync -a /mnt/media/docker/paperless/data/ \
  "$BACKUP_DIR/volumes/paperless-data/" 2>> "$LOG" || true
 
# Sync to Dropbox
DATED_DEST="$DROPBOX_DEST/$(date +%Y-%m-%d)"
echo "Syncing to $DATED_DEST..." >> "$LOG"
rclone sync "$BACKUP_DIR" "$DATED_DEST" \
  --log-file="$LOG" \
  --log-level INFO
 
# Clean up local temp
rm -rf "$BACKUP_DIR"
echo "Local temp cleaned up." >> "$LOG"
 
# Delete old Dropbox backups
echo "Pruning backups older than ${RETAIN_DAYS} days..." >> "$LOG"
rclone lsf "$DROPBOX_DEST" --dirs-only | while read -r dir; do
  dir_date=$(echo "$dir" | tr -d '/')
  dir_epoch=$(date -d "$dir_date" +%s 2>/dev/null || echo 0)
  cutoff_epoch=$(date -d "-${RETAIN_DAYS} days" +%s)
  if [ "$dir_epoch" -lt "$cutoff_epoch" ] && [ "$dir_epoch" -ne 0 ]; then
    echo "Deleting old backup: $dir" >> "$LOG"
    rclone purge "$DROPBOX_DEST/$dir" >> "$LOG" 2>&1
  fi
done
 
echo "=== Backup complete: $(date) ===" >> "$LOG"

Make it executable and set up the log file:

chmod +x ~/docker/backup-to-dropbox.sh
sudo touch /var/log/dropbox-backup.log
sudo chown ubuntu:ubuntu /var/log/dropbox-backup.log

Run it once manually to verify:

~/docker/backup-to-dropbox.sh && tail -50 /var/log/dropbox-backup.log

Scheduling with cron

crontab -e

Add this line to run at 3am every three days:

0 3 */3 * * /home/ubuntu/docker/backup-to-dropbox.sh

What I learned the hard way

Each Postgres container uses a different superuser. This tripped me up on almost every container. docker inspect is your best friend here.

Raw volume copies of Postgres are not reliable backups. pg_dump takes longer but the output is portable and consistent. A raw data directory copy of a running database can be corrupted.

Permission denied errors are common. Some service containers write files as root, so their data directories are owned by root even on the host. Adding sudo to the rsync calls fixes this. For containers like Linkwarden’s Meilisearch index, it’s simpler to just exclude those directories since the index can be rebuilt from the database anyway.

The remote name in rclone is case-sensitive. I named mine Dropbox with a capital D during setup, then spent twenty minutes debugging why dropbox:/ returned an error. Check your remote name with rclone listremotes.

Headless OAuth on Windows requires .\rclone.exe, not rclone.exe. PowerShell does not run executables from the current directory without the .\ prefix.

What gets backed up and what doesn’t

After running this setup, here is an honest picture of recoverability:

Fully recoverable:

  • All 11 Postgres databases
  • All Docker compose files and .env files
  • Paperless documents (219MB of actual PDFs)
  • Actual Budget data
  • Audiobookshelf metadata and library structure
  • Linkwarden bookmarks and archived pages
  • Uptime Kuma monitors and history
  • ntfy config, Joplin data, Dawarich location history

Not backed up (intentional):

  • 16GB of audiobook files. These are large and can be re-added manually. Backing them up would eat most of a 2TB Dropbox plan on its own.
  • Clickhouse analytics data from Rybbit. The compose files are backed up so the service can be reinstalled, but historical analytics would be lost.
  • Meilisearch indexes. These are search indexes that get rebuilt automatically from the underlying database data.

The total backup size runs around 950MB per run, well within Dropbox’s limits even keeping 30 days of history.

Monitoring it

Check the last run any time with:

tail -50 /var/log/dropbox-backup.log

Verify what is in Dropbox:

rclone ls Dropbox:/oracle-server-backups

The log will show you exactly which databases dumped successfully, which volumes synced, and the total transfer size. If anything fails, it logs the error message inline so you can see exactly what went wrong.

Resources

The full script is in the post above. Adjust the container names, database users, and paths for your own stack. The pattern is the same regardless of which services you run.