Backing Up a Self-Hosted App the Right Way: The 3-2-1 Rule in Practice
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:
- The database. Postgres, MySQL, or a SQLite file.
- User-uploaded files. Avatars, attachments, exports. Often a
data/oruploads/directory, sometimes an S3 bucket. - Configuration. Your
.envfile,docker-compose.yml, reverse-proxy config. - 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:
| Scenario | Restore point |
|---|---|
| Fat-fingered a delete this morning | Last night |
| Noticed corruption after 5 days | Any of the last 7 days |
| A bug quietly mangled data 3 weeks ago | The relevant weekly snapshot |
| Need last quarter's state for an audit | A 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
.envyou 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.
A Docker Compose Starter Stack for Your First Self-Hosted Setup
The handful of pieces every self-hosted setup needs before the apps: a reverse proxy, automatic HTTPS, backups, and updates. A calm walkthrough you can finish in an afternoon.
How to Migrate Off Google Workspace Without Breaking Your Week
A calm, step-by-step approach to moving email, docs, calendar, and files away from Google Workspace to open-source and self-hosted tools, without a risky big-bang cutover.
Self-Hosting vs. SaaS in 2026: The Real Cost Breakdown
Self-hosting looks free until you add up your time. Here is a practical way to compare the true cost of running open-source software yourself versus paying for a SaaS subscription.