How to Back Up Your Self-Hosted Services

How to Back Up Your Self-Hosted Services

How to Back Up Your Self-Hosted Services (Without Losing Sleep)

Here's a fun story: I once ran a Nextcloud instance for two years. Photos, documents, calendar - everything lived there. Then one day, my SSD decided it had lived a good life and died without warning. No backup. Two years of data, gone in an instant.

Don't be me. Let's talk about backups.

Why Backups Matter More Than You Think

When you self-host, you're the IT department. There's no support ticket to file, no cloud provider who magically keeps redundant copies. If your drive fails, your Docker volume gets corrupted, or you accidentally run rm -rf in the wrong directory (we've all been there), that data is gone unless you planned ahead.

The good news? Setting up solid backups isn't complicated. It just requires a bit of upfront work and then it runs on autopilot.

What Actually Needs Backing Up?

Before we dive into the how, let's figure out the what. Most self-hosted services store data in a few predictable places:

  • Docker volumes - where containers persist their data
  • Bind mounts - folders on your host mapped into containers
  • Databases - MySQL, PostgreSQL, MariaDB, etc.
  • Configuration files - docker-compose.yml, .env files, app configs

Your containers themselves? Don't bother backing those up. They're disposable. You can always pull the image again. It's the data and config that matter.

Backing Up Docker Volumes

Docker volumes live in /var/lib/docker/volumes/ by default. You could just copy that directory, but there's a cleaner way that works even while containers are running (for most apps).

Here's a simple script that creates a tarball of a named volume:

#!/bin/bash
# backup-volume.sh - Back up a Docker volume to a tar.gz file

VOLUME_NAME=$1
BACKUP_DIR="/backup/docker-volumes"
DATE=$(date +%Y-%m-%d_%H-%M-%S)

mkdir -p "$BACKUP_DIR"

docker run --rm \
  -v "$VOLUME_NAME":/source:ro \
  -v "$BACKUP_DIR":/backup \
  alpine tar czf "/backup/${VOLUME_NAME}_${DATE}.tar.gz" -C /source .

echo "Backed up $VOLUME_NAME to ${BACKUP_DIR}/${VOLUME_NAME}_${DATE}.tar.gz"

What's happening here? We spin up a tiny Alpine container, mount the volume as read-only, and tar it up. Clean, portable, and it works with any volume.

Use it like this:

./backup-volume.sh nextcloud_data
./backup-volume.sh vaultwarden_data

What About Bind Mounts?

If you use bind mounts (like ./data:/app/data in your compose file), even easier. Just back up those directories directly:

tar czf /backup/jellyfin_$(date +%Y-%m-%d).tar.gz /opt/jellyfin/

Database Backups - Don't Just Copy Files

Here's where people mess up. You can't just copy database files while the database is running. Well, you can, but you might end up with corrupted garbage when you try to restore.

Databases need proper dumps. Here's how to do it for the common ones:

PostgreSQL

# If running in Docker
docker exec postgres_container pg_dumpall -U postgres > /backup/postgres_$(date +%Y-%m-%d).sql

# Compress it (SQL dumps compress really well)
docker exec postgres_container pg_dumpall -U postgres | gzip > /backup/postgres_$(date +%Y-%m-%d).sql.gz

MySQL / MariaDB

# Single database
docker exec mysql_container mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" mydb > /backup/mydb_$(date +%Y-%m-%d).sql

# All databases
docker exec mysql_container mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --all-databases > /backup/all_dbs_$(date +%Y-%m-%d).sql

SQLite

SQLite is simpler since it's just a file, but you should still use the proper backup command to avoid corruption:

sqlite3 /path/to/database.db ".backup '/backup/database_$(date +%Y-%m-%d).db'"

The 3-2-1 Rule

You've probably heard this before, but it bears repeating:

  • 3 copies of your data
  • 2 different storage types
  • 1 copy off-site

That last one is crucial. If your house floods or your server rack catches fire, local backups won't save you. You need something off-site.

Off-Site Backups with Restic

There are lots of tools for this, but I'm a fan of Restic. It's fast, it deduplicates data, it encrypts everything, and it works with tons of backends - local drives, SFTP, S3, Backblaze B2, you name it.

Install it:

# Debian/Ubuntu
apt install restic

# Or grab the latest binary
wget https://github.com/restic/restic/releases/download/v0.16.4/restic_0.16.4_linux_amd64.bz2
bunzip2 restic_*.bz2
chmod +x restic_*
mv restic_* /usr/local/bin/restic

Initialize a repository (using Backblaze B2 as an example):

export B2_ACCOUNT_ID="your_account_id"
export B2_ACCOUNT_KEY="your_account_key"
export RESTIC_PASSWORD="a_very_strong_password_you_wont_forget"

restic -r b2:your-bucket-name:backups init

Back up a directory:

restic -r b2:your-bucket-name:backups backup /backup/

The magic of Restic is deduplication. If you back up 100GB today and only 500MB changes tomorrow, it only uploads the 500MB. Your storage costs stay sane.

Budget-Friendly Off-Site Options

Not everyone wants to pay for cloud storage. Here are some alternatives:

  • A friend's server - trade backup space with another self-hoster
  • Raspberry Pi at a relative's house - cheap and works great for small backups
  • Backblaze B2 - $6/TB/month, hard to beat on price
  • Hetzner Storage Box - affordable and EU-based if that matters to you

Automating Everything with Cron

Manual backups are better than no backups, but let's be honest - you'll forget. Automation is the answer.

Here's a complete backup script that handles volumes, databases, and off-site sync:

#!/bin/bash
# full-backup.sh - Complete backup solution

set -e  # Exit on any error

BACKUP_DIR="/backup"
DATE=$(date +%Y-%m-%d)
LOG_FILE="/var/log/backup.log"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

log "Starting backup..."

# Create dated directory
mkdir -p "$BACKUP_DIR/$DATE"

# Backup Docker volumes
log "Backing up Docker volumes..."
for volume in nextcloud_data vaultwarden_data; do
    docker run --rm \
        -v "$volume":/source:ro \
        -v "$BACKUP_DIR/$DATE":/backup \
        alpine tar czf "/backup/${volume}.tar.gz" -C /source .
    log "  - $volume done"
done

# Backup PostgreSQL
log "Backing up PostgreSQL..."
docker exec postgres pg_dumpall -U postgres | gzip > "$BACKUP_DIR/$DATE/postgres.sql.gz"

# Backup config files
log "Backing up configs..."
tar czf "$BACKUP_DIR/$DATE/configs.tar.gz" /opt/docker-compose/

# Sync to off-site
log "Syncing to off-site storage..."
export RESTIC_PASSWORD_FILE="/root/.restic-password"
restic -r b2:my-bucket:homelab backup "$BACKUP_DIR/$DATE" --tag "$DATE"

# Cleanup old local backups (keep 7 days)
log "Cleaning up old backups..."
find "$BACKUP_DIR" -maxdepth 1 -type d -mtime +7 -exec rm -rf {} \;

# Cleanup old restic snapshots (keep 30 days)
restic -r b2:my-bucket:homelab forget --keep-daily 7 --keep-weekly 4 --keep-monthly 3 --prune

log "Backup complete!"

Make it executable and add it to cron:

chmod +x /root/full-backup.sh

# Edit crontab
crontab -e

# Add this line (runs at 3 AM daily)
0 3 * * * /root/full-backup.sh 2>&1 | tee -a /var/log/backup.log

Testing Your Backups

A backup you've never tested is not a backup. It's just hope.

At least once a month, try restoring something. Spin up a test container, load a database dump, extract a volume backup. Make sure the data is actually there and actually works.

Here's how to restore a volume backup:

# Create a new volume
docker volume create test_restore

# Extract the backup into it
docker run --rm \
  -v test_restore:/target \
  -v /backup/2024-01-15:/backup:ro \
  alpine tar xzf /backup/nextcloud_data.tar.gz -C /target

# Verify the data
docker run --rm -v test_restore:/data alpine ls -la /data

Quick Checklist

Before you close this tab, here's your action list:

  • Identify what data you actually care about
  • Set up local backups for volumes and databases
  • Pick an off-site solution and configure it
  • Automate everything with cron
  • Test a restore at least once
  • Put "test backups" on your calendar for next month

Wrapping Up

Backups aren't glamorous. Nobody shows off their backup scripts on Reddit. But when disaster strikes - and eventually it will - you'll be really glad you spent a few hours setting this up.

Start simple. A basic script running nightly is infinitely better than a perfect solution you never implement. You can always improve it later.

Now go back up your stuff. Future you will thank present you.