Guide
Backing up a running database without corrupting it
Copying a database file while the database is writing produces a file that looks fine, backs up fine, and restores into a corrupt database. Here is exactly why, and what to run instead.
Can you back up a database by copying its files while it is running?
No. A file copy takes minutes and reads different parts of the database at different moments, so you get a mixture of states that never existed, plus half-written pages. PostgreSQL's own documentation says the server must be shut down for a file-level backup to be usable. Use a logical dump instead (pg_dump, mariadb-dump --single-transaction, SQLite's .backup), or take an atomic filesystem snapshot, which is safe precisely because it captures one instant and the database's crash recovery can replay from there.
The most common backup script in self-hosting is some variation on tar -czf backup.tar.gz /var/lib/docker/volumes/. It runs nightly, it exits zero, the archive has a plausible size, and roughly 81.9 percent of the tools in this index cannot be restored from it.
The reason is not exotic. It is that a copy takes time and a database does not stop.
Why cp of a live data directory is not a backup#
PostgreSQL's documentation states it as directly as anyone could ask for:
The database server must be shut down in order to get a usable backup. Half-way measures such as disallowing all connections will not work (in part because
tarand similar tools do not take an atomic snapshot of the state of the file system, but also because of internal buffering within the server).
Two separate failures are packed into that sentence.
Time skew. Your copy of base/16384/1259 was read at 03:00:02 and your copy of pg_wal/000000010000000000000042 at 03:04:17. In between, transactions committed. The resulting directory is a mixture of two different moments, and there is no moment in history at which those files were all correct together. Recovery cannot fix this, because there is nothing coherent to recover to.
Torn pages. PostgreSQL organizes its files into 8 KB pages, and the storage stack below it commits in smaller units. At any instant, a page being written may be half old and half new on disk. The database survives this in a real crash because full_page_writes puts a complete image of each page into the WAL after a checkpoint, and recovery uses it to repair the page. Your cp copies the torn page and none of that context. What you have backed up is a page that cannot exist.
The same reasoning applies to MariaDB's InnoDB tablespaces and to SQLite. It applies to Immich, Nextcloud, Paperless-ngx, Vaultwarden and Uptime Kuma identically, because it is a property of files and processes, not of any particular project.
The one case where copying files IS safe#
A filesystem snapshot is safe, and understanding why tells you everything else.
A snapshot is atomic. Every block is captured at one instant, which means the result is byte for byte what the disk would contain if you had yanked the power cable at that moment. Databases are engineered for exactly that event: on start they find an unclean shutdown, replay the write-ahead log, roll back uncommitted transactions and come up consistent. PostgreSQL's docs describe this outcome plainly, noting that a snapshot backup saves the files "in a state as if the database server was not properly shut down", so starting on it replays WAL.
cp is not an instant. That is the entire difference. A crash is a point; a copy is an interval.
Two conditions have to hold:
- The snapshot must be atomic across every filesystem the database touches. If the data directory is on one dataset and the WAL on another, snapshotting them one after the other reintroduces the skew you were trying to avoid.
zfs snapshot -rcreates all descendant snapshots at the same time, which is the property you need. PostgreSQL's documentation is explicit that if the database spans multiple filesystems and you cannot freeze them simultaneously, snapshot backup may not be usable at all. - The database has to actually crash-recover correctly, which every engine covered here does, provided its storage did not lie about flushes. This is why running a database on an NFS or SMB mount is a recurring corruption story.
ZFS, btrfs, mdadm or one disk covers getting the pool layout right in the first place.
PostgreSQL, in Docker, for real#
pg_dump produces a consistent export while the database is being used concurrently, and it does not block readers or writers. That is the default answer for a self-hosted app.
# Logical dump, custom format: compressed, selective, parallel-restorable
docker compose exec -T database \
pg_dump -U immich -d immich -Fc \
> /srv/backup/immich-$(date +%F).dump
# pg_dump covers ONE database. Roles and tablespaces are cluster level.
docker compose exec -T database \
pg_dumpall -U postgres --globals-only \
> /srv/backup/globals-$(date +%F).sql
# Restore, into an empty database on the new host
docker compose exec -T database psql -U postgres -c 'CREATE DATABASE immich;'
docker compose exec -T database \
pg_restore -U postgres -d immich --clean --if-exists --no-owner \
< /srv/backup/immich-2026-09-07.dumpThree things worth internalizing:
- A dump loads into a newer major version.
pg_dumpoutput is designed to be loaded into servers newer than the version that produced it. It cannot dump from a server newer than itself, and loading into an older major is not guaranteed. This asymmetry is why a logical dump is the right artifact for moving a service: the new machine's image is almost always newer. pg_basebackupis a different tool for a different job. It copies the cluster physically, restores only onto the same major version, and exists for replication and point-in-time recovery. It is not what you want in your nightly job for a home server.- Follow the project when it says something specific. Immich tells you to use
pg_dumpallrather than snapshotting its Postgres data directory, and it uses the VectorChord extension, so the restore target needs that extension present. Read the project's own backup page before you standardize.
MariaDB and MySQL#
# Credentials in a file, not on the command line where ps can read them
cat > /srv/backup/.my.cnf <<'CNF'
[client]
user=root
password=REPLACE_ME
CNF
chmod 600 /srv/backup/.my.cnf
docker compose exec -T db \
mariadb-dump --defaults-extra-file=/backup/.my.cnf \
--single-transaction --quick --default-character-set=utf8mb4 \
nextcloud \
| gzip > /srv/backup/nextcloud-$(date +%F).sql.gz
# Restore
zcat /srv/backup/nextcloud-2026-09-07.sql.gz \
| docker compose exec -T db mariadb --defaults-extra-file=/backup/.my.cnf nextcloud--single-transaction opens a transaction with a consistent read, so InnoDB tables are dumped as of one moment without locking anyone out. Know its two limits, both documented upstream:
- Only InnoDB is covered. MyISAM and MEMORY tables can change during the dump. Most modern apps are all-InnoDB, but a legacy schema or a plugin table may not be.
- DDL breaks it. A consistent read is not isolated from
ALTER TABLE,CREATE TABLE,DROP TABLE,RENAME TABLEorTRUNCATE TABLE. If a container restarts mid-dump and runs its schema migrations, the dump can silently contain wrong contents. This is a real risk on hosts with automatic updates, so do not let your updater and your backup timer overlap.
It is also mutually exclusive with --lock-tables, because LOCK TABLES implicitly commits the transaction. Passing both silently disables the locking.
For Nextcloud specifically, put the instance into maintenance mode first (occ maintenance:mode --on), or the dump and the files will disagree about which uploads exist.
SQLite, and the file that is not the database#
SQLite in WAL mode keeps recent committed transactions in a -wal file alongside the .db. The documentation is unambiguous: the WAL file is part of the persistent state of the database and must be kept with it, because a database separated from its WAL may lose committed transactions or become corrupted.
So cp app.db backup.db is not a backup of a WAL-mode database. It is a backup of an old version of it, possibly torn, possibly missing your last several hours of writes.
# Correct: the online backup API, from a separate connection, no downtime
docker compose exec -T vaultwarden \
sqlite3 /data/db.sqlite3 ".backup '/data/db-backup.sqlite3'"
# Alternative: a compacted, defragmented copy (SQLite 3.27 and later)
docker compose exec -T vaultwarden \
sqlite3 /data/db.sqlite3 "VACUUM INTO '/data/db-backup.sqlite3'"
# Always verify before you trust it
docker compose exec -T vaultwarden \
sqlite3 /data/db-backup.sqlite3 "PRAGMA integrity_check;"
# If the image has no sqlite3 binary, use a sidecar on the same volume
docker run --rm -v vaultwarden_data:/data alpine:3 \
sh -c 'apk add -q --no-cache sqlite && \
sqlite3 /data/db.sqlite3 ".backup \"/data/db-backup.sqlite3\""'The backup API copies incrementally, taking locks only for the brief periods it is actually reading, so other users keep working. The result is a bit-for-bit consistent snapshot as of the moment the copy started. Several projects wrap this for you: Vaultwarden ships a vaultwarden backup command, and Home Assistant has a backup integration that now works on every install type.
Two restore details that bite:
- Delete any leftover
-walfile before restoring. Dropping a restoreddb.sqlite3next to a staledb.sqlite3-walfrom the old instance is a documented way to corrupt the vault. The-shmfile regenerates itself and should never be backed up. - Verify the copy, not the original.
PRAGMA integrity_checkon a file you just wrote takes seconds and is the only cheap proof you have.
The ordering problem: files and database must agree#
Every app in this category is really two datastores that reference each other. Paperless-ngx has documents on disk and the filing system (tags, correspondents, custom fields) in Postgres. Immich has originals under the upload location and every album, face and person in the database. Nextcloud has the data directory and the file cache table.
If the dump happens at 03:00 and the file copy finishes at 03:40, anything deleted in that window is still referenced by the dump and no longer present on disk. Anything created in that window is on disk with no database row. The second case is usually harmless clutter. The first case is the one that produces broken thumbnails, 404s inside the app, and, in the worst cases, a service that will not start.
The fix is to make both captures happen in the same window:
- Put the app into maintenance mode, or stop the application container while leaving its database running.
- Dump the database to a file that lives on the same filesystem you are about to snapshot.
- Take the atomic snapshot.
- Bring the app back up. Total downtime is seconds to a minute.
- Back up from the snapshot at your leisure.
Some projects sidestep this entirely by giving you one export that contains both. Paperless-ngx's document_exporter writes originals, archive PDFs and a manifest.json with all the metadata, and rerunning it updates in place so you can rsync the result. When a project ships an exporter like that, use it instead of assembling your own; it is the only artifact the project promises to be able to import.
Restore order is the mirror image: database first, then files, then start the application. Starting the app against a restored database and an empty file tree is how people end up with an app that helpfully "cleans up" the missing entries.
What to do next#
Check what each of your services actually needs. Every tool profile in this index carries a derived backup shape based on its declared datastore, and the derivation rules are on our methodology. Backup planner turns your stack into a per-service list of dump-or-copy decisions. Then wire it into a real repository with retention and an off-site copy: Backups that actually restore covers the schedule, the append-only requirement and the restore rehearsal. When you are moving a service rather than protecting it, Moving a service to a new machine covers the version mismatches that turn a valid dump into a failed restore.
Questions#
What is a torn read?
PostgreSQL writes in 8 KB pages while the storage stack commits in smaller blocks, so a page can be half old and half new on disk at any instant. A database survives that because crash recovery replays the write-ahead log and repairs the page. A cp running alongside the server copies the torn page with no matching WAL state, so nothing repairs it. The copy is not just stale, it contains a page that is internally impossible.
Why is a ZFS or btrfs snapshot safe when cp is not?
Atomicity. A snapshot captures every block at one instant, so the copy is exactly what the disk would look like if you had pulled the power cord. Databases are built to survive that: on start they replay the write-ahead log and come up consistent. A cp is not one instant, it is a smear across however long the copy takes, and no recovery mechanism can reconstruct a coherent state from a smear. The caveat is that the snapshot must cover every filesystem the database uses, all at the same moment.
Is copying a SQLite .db file safe if I copy the -wal file too?
Only if the two are captured at the same instant, which cp of two files cannot guarantee. SQLite's documentation states that the WAL file is part of the persistent state and that a database separated from its WAL can lose committed transactions or become corrupted. Use sqlite3 db ".backup out.db" or VACUUM INTO, both of which take proper locks and produce a single self-contained file. Never back up the -shm file; it is regenerated.
What does mysqldump --single-transaction not protect?
It gives you a consistent read only for InnoDB. MyISAM and MEMORY tables can change underneath the dump. More importantly, the MariaDB documentation warns that a consistent read is not isolated from DDL: if any connection runs ALTER TABLE, CREATE TABLE, DROP TABLE, RENAME TABLE or TRUNCATE TABLE during the dump, the output can be wrong or the dump can fail. That includes an application running its own schema migration on restart.
pg_dump or pg_basebackup?
pg_dump for a self-hosted app: it is logical, consistent while the database is in use, and its output loads into newer PostgreSQL major versions, which is what saves you when the new machine ships with a newer image. pg_basebackup copies the whole cluster physically and can only be restored onto the same major version, so it is for replication and point-in-time recovery, not for moving a service. Note that pg_dump covers one database and excludes roles and tablespaces; use pg_dumpall --globals-only for those.
In what order do I back up an app's files and its database?
Both in the same quiesced window, or neither. If the dump is taken at 03:00 and the files at 03:40, anything deleted in between is still referenced by the dump but missing on disk, and that is the failure that actually breaks a restore. Put the app in maintenance mode or stop it, dump into the same filesystem you are about to snapshot, take the snapshot, then release. On restore the order reverses: database first, then files, then start.
How do I know which of my services need a dump?
Look at the datastore. Anything backed by PostgreSQL, MariaDB, MySQL, MongoDB or a WAL-mode SQLite file needs a dump or a snapshot, not a copy. In this index that is 81.9 percent of profiled tools. Each tool profile shows a derived backup shape based on its declared datastore, and the derivation is documented on our methodology.
Sources#
- PostgreSQL documentation, file system level backup and snapshots
- PostgreSQL documentation, pg_dump
- SQLite, the online backup API
- SQLite, write-ahead logging and the -wal file
- MariaDB documentation, mariadb-dump and --single-transaction
- OpenZFS manual, zfs-snapshot and atomic recursive snapshots
- Vaultwarden wiki, backing up your vault
- Nextcloud admin manual, backup procedure
- Paperless-ngx administration docs, document_exporter
Published . Last reviewed . Found something out of date? Tell us and we will fix it and log the change.