Set up a backup that survives a dead disk, and learn the habit that proves it works: restoring from it. The examples use restic and a Compose project in ~/apps/myapp, one folder per app as in Where your data lives, here /home/alice/apps/myapp. Adjust the names to yours.
A copy on the same disk is not a backup
If the disk dies, the original and the copy die together. The same goes for a stolen laptop, a house fire or a mistaken rm -rf.
The usual rule of thumb is called 3-2-1. CISA, the US cyber agency, states it like this:
- 3 copies of any important file: the live one plus 2 backups.
- 2 different kinds of storage, such as an internal disk and an external one.
- 1 copy off-site, meaning outside your home.
For a home server, that can be as small as: the live data, a backup on a USB disk, and a second backup in a cloud bucket or on a friend's machine.
Copying live database files can give you a broken backup
Many apps keep their data in a database. A database stays busy even when nobody uses the app. It holds changes in memory and writes them in pieces. A file copy taken mid-write can look fine and fail to open later.
PostgreSQL's documentation is blunt: for a plain file copy, the server must be shut down to get a usable backup. SQLite's documentation warns that a copy taken in the middle of a transaction might contain some old and some new content, and thus be corrupt.
You have two safe options.
- Ask the database for a dump. A dump is a file the database writes itself, in a consistent state, while the app keeps running. This is the better default.
- Stop the stack, copy, start it again. This is fine for a small home setup where a few minutes of downtime at 03:30 hurts nobody.
Dump commands
Run these from the folder that holds your compose.yaml. Replace db with your database service name, and app with your database user and name. The -T flag turns off the terminal, which is needed when you redirect output to a file.
PostgreSQL. pg_dump makes a consistent dump of one database and does not block other users. The -Fc flag picks a compressed format.
mkdir -p dumps
docker compose exec -T db pg_dump -U app -Fc app > dumps/app.dump
To dump every database plus users and roles, use pg_dumpall:
docker compose exec -T db pg_dumpall -U app > dumps/all.sql
To restore the first file into a running database:
docker compose exec -T db pg_restore -U app -d app --clean --if-exists < dumps/app.dump
MySQL. mysqldump writes SQL text. The password variable below is read inside the container, as in the official MySQL image docs.
docker compose exec -T db sh -c 'exec mysqldump --all-databases -uroot -p"$MYSQL_ROOT_PASSWORD"' > dumps/all-databases.sql
MariaDB. The tool is now called mariadb-dump. Since MariaDB 11.0 the official image no longer ships the old mysqldump name. --single-transaction makes the dump of InnoDB tables (the default table type) consistent without blocking the app.
docker compose exec -T db sh -c 'exec mariadb-dump --all-databases --single-transaction -uroot -p"$MARIADB_ROOT_PASSWORD"' > dumps/all-databases.sql
SQLite. Many small apps keep one .db file. Install the tool on the host with sudo apt install sqlite3. Then use the .backup command, which copies a live database safely. Stopping the app first also works.
sudo mkdir -p /home/alice/apps/myapp/dumps
sudo sqlite3 /home/alice/apps/myapp/data/app.db ".backup '/home/alice/apps/myapp/dumps/app.db'"
Use restic
restic is a command-line backup tool. Its README says it uses cryptography to guarantee confidentiality and integrity, and that duplicate data is removed before it is written (deduplication, so a second backup of mostly unchanged files is small and fast). It writes to a local disk, another machine over SFTP, or an S3-compatible bucket. BorgBackup and Kopia do similar jobs. Pick one and stick with it.
Install it with sudo apt install restic. Debian 13 ships 0.18 and Ubuntu 24.04 ships 0.16, which is older but runs every command below. The restic docs warn that distro packages can lag.
Create a password file. The password locks the repository (the folder where restic keeps your backups). The restic docs say that losing it means your data is irrecoverably lost. Keep a second copy in your password manager, not only on the server.
sudo sh -c 'umask 077; head -c 24 /dev/urandom | base64 > /root/.restic-password'
sudo cat /root/.restic-password
Create the repository on your backup disk. Here the disk is mounted at /mnt/backup:
sudo restic -r /mnt/backup/restic-repo --password-file /root/.restic-password init
Back up the project, skipping the live database folder, because the dump covers it:
sudo restic -r /mnt/backup/restic-repo --password-file /root/.restic-password backup /home/alice/apps/myapp --exclude /home/alice/apps/myapp/pgdata
List snapshots. A snapshot is one dated backup run:
sudo restic -r /mnt/backup/restic-repo --password-file /root/.restic-password snapshots
Old snapshots pile up. This command keeps 7 daily, 5 weekly and 12 monthly ones and removes the rest:
sudo restic -r /mnt/backup/restic-repo --password-file /root/.restic-password forget --keep-daily 7 --keep-weekly 5 --keep-monthly 12 --prune
Check the repository after pruning, as the restic docs advise:
sudo restic -r /mnt/backup/restic-repo --password-file /root/.restic-password check
Off-site targets
The repository can live anywhere restic can write. Change the -r value:
- A USB disk you take away. Same as above. Unplug it and keep it somewhere else. A disk that stays plugged in next to the server is a second copy, not an off-site one.
- A second machine.
sftp:user@host:/srv/restic-repo, with passwordless SSH login. - An S3-compatible bucket.
s3:https://server:port/bucket_name, withAWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYset in the environment. Create a key that can write to that one bucket only.
Because restic encrypts before upload, the storage provider sees only noise.
Schedule it
A backup run by hand is a backup you forget. Put the steps in one script:
sudo tee /usr/local/bin/backup-myapp.sh > /dev/null <<'SCRIPT'
#!/bin/sh
set -eu
export RESTIC_REPOSITORY=/mnt/backup/restic-repo
export RESTIC_PASSWORD_FILE=/root/.restic-password
cd /home/alice/apps/myapp
mkdir -p dumps
docker compose exec -T db pg_dump -U app -Fc app > dumps/app.dump
restic backup /home/alice/apps/myapp --exclude /home/alice/apps/myapp/pgdata
restic forget --keep-daily 7 --keep-weekly 5 --keep-monthly 12 --prune
SCRIPT
sudo chmod 755 /usr/local/bin/backup-myapp.sh
set -eu stops the script on the first error, so a failed dump never reaches restic. Run it once by hand and read the output.
Then add a systemd timer. The service runs the script. The timer starts it daily and, with Persistent=true, runs it after boot if the machine was off at 03:30.
sudo tee /etc/systemd/system/backup-myapp.service > /dev/null <<'UNIT'
[Unit]
Description=Back up myapp with restic
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-myapp.sh
UNIT
sudo tee /etc/systemd/system/backup-myapp.timer > /dev/null <<'UNIT'
[Unit]
Description=Daily myapp backup
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
[Install]
WantedBy=timers.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now backup-myapp.timer
systemctl list-timers backup-myapp.timer
After the first scheduled run, check the result with sudo journalctl -u backup-myapp.service.
Test a restore
Until you restore, you have a hope, not a backup. Restore into a fresh folder, never over the live data:
sudo restic -r /mnt/backup/restic-repo --password-file /root/.restic-password restore latest --target /tmp/restore-test
sudo find /tmp/restore-test -maxdepth 6
Restic keeps the original path, so your files appear under /tmp/restore-test/home/alice/apps/myapp. Only root can open that folder, so use sudo to inspect a few files. Then prove the dump loads. This starts a throwaway Postgres, waits until it accepts connections, loads the dump and lists the tables. Use the same major version as your live database, here 17:
sudo docker run -d --name restore-test -e POSTGRES_USER=app -e POSTGRES_PASSWORD=test -e POSTGRES_DB=app postgres:17
until sudo docker exec restore-test pg_isready -h 127.0.0.1 -U app; do sleep 2; done
sudo cat /tmp/restore-test/home/alice/apps/myapp/dumps/app.dump | sudo docker exec -i restore-test pg_restore -U app -d app --no-owner
sudo docker exec restore-test psql -U app -d app -c '\dt'
sudo docker rm -f restore-test
sudo rm -rf /tmp/restore-test
Repeat this test on a schedule. Once a quarter is a reasonable start. A backup can fail silently for months, for example after a full disk or a renamed folder.
What to back up per app
- Gogs. Your repositories, its data and custom folders and its database. Gogs has
gogs backup, which packs all four into one zip file. The simpler route is a dump of the database plus a copy of the whole data folder. - AFFiNE. Your documents sit in two places: Postgres, and the uploads folder. Its current Compose setup keeps them in
./data/postgresand./data/storage, with settings in./config. Older installs set these paths in.env. Dump the database, and back up the uploads and config folders. - Navidrome. Its
/datafolder holds its database, with your users and playlists, and a cache. Back that up. The music library is often already a copy of files kept elsewhere, so you may not need to back it up again. - OpenPlate. The diary lives in your browser, not on the server. Use the in-app JSON export and file it with your other backups. Server backups will not hold it.
Restore drill checklist
- Mount or reach the backup target, and confirm the latest snapshot is recent (
restic snapshots). - Run
restic check. - Restore
latestinto an empty folder. - Open real files from the restored copy.
- Load each database dump into a throwaway container.
- Check you can find the restic password without the server.
- Write the date, and set the next drill.
Next: Update without breaking.