Back to blog
Self-Hosting
Backups

Backing Up a Self-Hosted App the Right Way: The 3-2-1 Rule in Practice

Mohsen Karimi4 min read
Backing Up a Self-Hosted App the Right Way: The 3-2-1 Rule in Practice header image

Ask ten people running self-hosted software how their backups work and most will describe a single tar command writing to the same disk the app runs on. That is not a backup. It is a copy that dies with the machine.

The fix is an old piece of sysadmin wisdom called the 3-2-1 rule, and it takes an afternoon to set up properly.

What 3-2-1 actually means

  • 3 copies of your data: the live one, plus two backups.
  • 2 different media or locations, so one failure does not take out both backups.
  • 1 copy off-site, physically away from the server.

For a typical self-hosted setup that translates to: the running database and files, a nightly backup on a separate volume or machine, and a copy pushed to object storage in another region.

Step 1: Know what you are actually backing up

For most apps, "the data" is four things:

  1. The database. Postgres, MySQL, or a SQLite file.
  2. User-uploaded files. Avatars, attachments, exports. Often a data/ or uploads/ directory, sometimes an S3 bucket.
  3. Configuration. Your .env file, docker-compose.yml, reverse-proxy config.
  4. Secrets you cannot regenerate. Encryption keys, OAuth client secrets. Losing these can make an otherwise-good backup useless.

Write these paths down. Everything else (the OS, the container images) you can rebuild from scratch.

Step 2: Dump the database properly

Copying a live database file while it is being written to gives you a corrupt backup. Use the database's own dump tool.

Postgres, from a Docker Compose setup:

docker compose exec -T db pg_dump -U appuser --format=custom appdb \
  > /backups/staging/appdb-$(date +%F).dump

SQLite — do not cp it. Use the backup command, which is safe against a running app:

sqlite3 /srv/app/data/app.db ".backup '/backups/staging/app-$(date +%F).db'"

Step 3: Bundle everything and send it off-site

restic is a good default here: it deduplicates, encrypts client-side, and talks to almost any storage backend. One-time setup:

export RESTIC_REPOSITORY="s3:https://s3.example-region.com/my-backups"
export RESTIC_PASSWORD="a-long-random-passphrase-stored-in-your-password-manager"
restic init

Then a nightly script:

#!/usr/bin/env bash
set -euo pipefail
STAGING=/backups/staging
mkdir -p "$STAGING"

# 1. database
docker compose -f /srv/app/docker-compose.yml exec -T db \
  pg_dump -U appuser --format=custom appdb > "$STAGING/appdb.dump"

# 2. files + config
restic backup \
  "$STAGING/appdb.dump" \
  /srv/app/data \
  /srv/app/.env \
  /srv/app/docker-compose.yml

# 3. retention: keep 7 daily, 4 weekly, 6 monthly
restic forget --prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6

Run it from cron or a systemd timer at, say, 03:30. That single script gives you copy #2 (staging on a separate volume) and copy #3 (encrypted, off-site, in another region). Copy #1 is the live app.

Step 4: The part everyone skips — test the restore

A backup you have never restored is a guess. Put a recurring reminder in your calendar, quarterly, and actually do this:

# pull the latest snapshot into a scratch directory
restic restore latest --target /tmp/restore-test

# stand up a throwaway Postgres and load the dump
docker run -d --name pg-restore -e POSTGRES_PASSWORD=x postgres:16
docker exec -i pg-restore createdb -U postgres appdb
docker exec -i pg-restore pg_restore -U postgres -d appdb \
  < /tmp/restore-test/backups/staging/appdb.dump

# spot-check
docker exec -it pg-restore psql -U postgres -d appdb \
  -c "select count(*) from users; select max(created_at) from events;"

If the row counts look right and the newest record is from yesterday, your pipeline works. If pg_restore throws errors, better to find out now than during an outage.

A worked retention example

With --keep-daily 7 --keep-weekly 4 --keep-monthly 6 you can recover to:

ScenarioRestore point
Fat-fingered a delete this morningLast night
Noticed corruption after 5 daysAny of the last 7 days
A bug quietly mangled data 3 weeks agoThe relevant weekly snapshot
Need last quarter's state for an auditA monthly snapshot

restic's deduplication means those ~17 snapshots usually cost only slightly more storage than one, because unchanged files are stored once.

What usually goes wrong

  • The backup writes to the same disk as the app. When the disk fails or fills, you lose both at once.
  • Secrets are not in the backup. The database restores fine, but the app cannot decrypt its own fields because the key lived only in a .env you did not include.
  • The off-site credentials are stored on the server being backed up. If ransomware or a wipe hits, it can reach the backup repo too. Use an append-only or separate-account credential for the backup target.
  • Nobody tested a restore. The format changed, a path moved, the dump was empty for a month because a container was renamed. A quarterly drill catches all of these.

Set it up once, prove it works, and self-hosting stops being scary. The whole point of running your own software is that when something breaks, recovery is a script you have already run, not a support ticket.

Related posts

More on Self-Hosting, Backups.