Tech Digest

Research

The backup blind spot

The gap between what people think their backup does and what it does is almost always the same gap: a file copy taken while a database was writing.

Last reviewed

Can you back up a self-hosted app by copying its files while it runs?

Usually not. Of the 105 tools in the index, only 19 can be backed up safely by copying their files while they are running. The other 86, 81.9 percent, keep state in PostgreSQL, MySQL, SQLite or a similar embedded store, where a copy taken mid-write can restore corrupt or silently miss committed transactions. The safe options are a database dump, an atomic filesystem snapshot, or stopping the service for the duration of the copy.

86 of the 105 tools in the index, 81.9 percent, cannot be safely backed up by copying their files while they run. Only 19 can.

The distribution is not a gentle slope. Two shapes account for most of it: 46 tools land in the Postgres bucket and 26 in the SQLite bucket. Then 23 are genuinely file-shaped, 5 are MySQL or MariaDB, and a thin tail holds two mixed cases plus one each of a rebuildable cache, a Mongo database and a search index. So the practical picture is three jobs, not eight: dump the SQL databases, handle SQLite properly, copy the rest.

SQLite backup54 tools
File copy18 tools
Postgres dump16 tools
MySQL dump6 tools
Mixed5 tools
Embedded key-value store4 tools
Mongo dump1 tools
Rebuildable index1 tools

Why the failure is invisible until the restore#

A nightly rsync or a restic run over /opt/stacks finishes in ninety seconds, exits zero, and produces a repository containing data/db.sqlite3 and a pgdata directory that look exactly like the real thing. The snapshot list grows. The dashboard is green. Nothing about the backup announces that it is not a backup.

The mechanics are worth stating precisely, because "it might be inconsistent" is too vague to act on.

Torn reads. A copy tool walks a directory over time. PostgreSQL writes 8 KB pages and buffers internally; your filesystem writes in its own units on its own schedule. A file read at 03:00:04 and another read at 03:00:11 can reflect two different transaction states, and a single large file can be half old and half new. PostgreSQL's documentation is blunt about this: the server must be shut down to get a usable file system backup, and half measures like refusing connections do not work, because tar does not take an atomic snapshot and the server buffers internally.

SQLite and WAL. In WAL mode the -wal file holds committed transactions that are not yet in the main database file. SQLite's documentation says the WAL file is part of the persistent state of the database, and that separating a database from its WAL can lose previously committed transactions or corrupt the file. So cp app.db backup.db on a running app is not merely stale, it can be structurally broken. This is why linkding's own backup page says copying the database file is not transaction safe and may result in a corrupted database, and why Vaultwarden users who ran a naive nightly cp data/db.sqlite3 discover the problem at the worst possible moment.

The safe version costs nothing:

bash
docker exec vaultwarden sqlite3 /data/db.sqlite3 ".backup '/data/backup.sqlite3'"

That uses SQLite's online backup API, which copies pages under a consistent view while writers continue, and produces a file that opens.

Why a snapshot is different from a copy. A ZFS, btrfs or LVM snapshot is atomic: every file in the dataset is frozen at the same instant. Restoring one looks to the database exactly like recovering from a power cut, which is a case every serious database is built to survive by replaying its write-ahead log. PostgreSQL's docs explicitly endorse consistent snapshots taken while the server runs, with one condition worth reading twice: if the data directory spans multiple filesystems or tablespaces, the snapshots have to be simultaneous, and if they cannot be, you cannot use the technique. A snapshot is safe because of atomicity, not because it is a fancy copy. ZFS, btrfs, mdadm or one disk covers how to get that atomicity on your storage.

The ordering problem. When an app keeps rows in a database and blobs on disk, the two have to agree. Seafile documents the rule: dump the three databases first, then copy the data directory. Reverse it and the file copy misses objects written during the dump, so the database references blocks that are not there and libraries restore corrupt. The documented cost of the correct order is losing the last few seconds of uploads. That is the right thing to lose.

ToolDatastoreBackup shapeLive file copy safe
AdGuard HomeA single YAML config file plus BoltDB files for stats and sessions; the query log is a JSON lines fileEmbedded key-value storeNo
Diunbbolt, a single file at `/data/diun.db`Embedded key-value storeNo
PortainerBoltDB key-value file at /data/portainer.dbEmbedded key-value storeNo
StalwartRocksDB by default; PostgreSQL, MySQL, SQLite, FoundationDB, S3 and Redis also supportedEmbedded key-value storeNo
CaddyFilesystem (certificates, keys and ACME account data in the data directory)File copyYes
Docker EngineNone; image and container metadata under /var/lib/docker, containerd image store by default since 29.0File copyYes
DozzleNone for logs; a small /data directory for users and UI settingsFile copyYes
ESPHomeYAML files on disk, no databaseFile copyYes
GlanceNone (YAML config plus an in-memory cache)File copyYes
HomepageNone (YAML files on disk)File copyYes
LocalAINone; model files and per-model YAML on diskFile copyYes
MinIONone external. Objects and their metadata live on the raw drives, with erasure coding and internal state under .minio.sysFile copyYes
Node-REDJSON files on disk (flows.json and an encrypted credentials file)File copyYes
OllamaNone; a content-addressed blob store on diskFile copyYes
SearXNGNone; Valkey or Redis only if you enable the limiterFile copyYes
Technitium DNS ServerFlat files under the config directory (zone files, settings, stats); no external databaseFile copyYes
TraefikNone. Certificates in a single acme.json file; routing state rebuilt from providers at every startFile copyYes
UnraidFlat configuration files on the boot device, loaded into a RAM filesystem at bootFile copyYes
Watchtowernone, it reads the Docker APIFile copyYes
Zigbee2MQTTFlat files in the data directory (database.db, a JSON lines file)File copyYes
code-serverNone; state is files on diskFile copyYes
rcloneNone. A single INI-style config file at ~/.config/rclone/rclone.confFile copyYes
BorgBackupIts own repository format, plus a local chunks index and files cache under ~/.cache/borgMixedNo
File BrowserBolt, a single embedded `filebrowser.db` fileMixedNo
KopiaIts own content-addressed repository with an epoch-based index, plus a local cache directoryMixedNo
PrometheusIts own TSDB on local disk, no external databaseMixedNo
resticIts own content-addressed repository (files in a directory or bucket), no database serviceMixedNo
KomodoMongoDB (FerretDB supported as a drop-in for hosts that cannot run current MongoDB)Mongo dumpNo
BookStackMySQL 8.0+ or MariaDB 10.6+MySQL dumpNo
Firefly IIIMariaDB or MySQL by default, PostgreSQL supported, SQLite possible but discouragedMySQL dumpNo
NextcloudMariaDB 10.11+/11.x or PostgreSQL 14 to 18, plus Redis for file locking and cachingMySQL dumpNo
PhotoPrismMariaDB 10.5.12+ recommended; SQLite supported for small librariesMySQL dumpNo
SeafileMariaDB/MySQL holding ccnet_db, seafile_db and seahub_db, plus Redis for cache; file content lives as blocks on disk or S3MySQL dumpNo
mailcow: dockerizedMariaDB 10.11 plus RedisMySQL dumpNo
CoolifyPostgreSQL, with Redis for queues and a Soketi realtime servicePostgres dumpNo
DocmostPostgreSQL with RedisPostgres dumpNo
DokployPostgreSQL 16 (Docker Swarm service, dokploy-postgres volume)Postgres dumpNo
GitLab CEPostgreSQL plus Redis (both bundled in Omnibus)Postgres dumpNo
HarborPostgreSQL plus Redis, both bundledPostgres dumpNo
HedgeDocPostgreSQL (MariaDB/MySQL supported, SQLite discouraged)Postgres dumpNo
ImmichPostgreSQL 14 with the VectorChord extension, plus Valkey/Redis for the job queuePostgres dumpNo
KeycloakPostgreSQL, MariaDB/MySQL, MSSQL or Oracle; a dev-file H2 database for development onlyPostgres dumpNo
MattermostPostgreSQL 14+Postgres dumpNo
MinifluxPostgreSQL onlyPostgres dumpNo
OutlinePostgreSQL 14+ with Redis 4+Postgres dumpNo
PLANKAPostgreSQLPostgres dumpNo
Paperless-ngxPostgreSQL (SQLite and MariaDB also supported)Postgres dumpNo
SynapsePostgreSQLPostgres dumpNo
Wiki.jsPostgreSQL 9.5+ recommended (MySQL, MariaDB, MSSQL and SQLite still supported in 2.x)Postgres dumpNo
authentikPostgreSQLPostgres dumpNo
WazuhWazuh indexer, a fork of OpenSearch 2.xRebuildable indexYes
Actual BudgetSQLite on the client; the server keeps account.sqlite plus per-budget binary filesSQLite backupNo
AudiobookshelfSQLiteSQLite backupNo
AutheliaSQLite (default), PostgreSQL or MySQL; sessions in memory or RedisSQLite backupNo
BeszelSQLite via PocketBaseSQLite backupNo
Calibre-WebSQLite (Calibre's metadata.db plus its own app.db)SQLite backupNo
CasaOSSQLite files and INI config under /var/lib/casaos and /etc/casaosSQLite backupNo
CrowdSecSQLite by default (MySQL and PostgreSQL supported)SQLite backupNo
DuplicatiSQLite: one server database plus one local database per backup jobSQLite backupNo
EmbySQLiteSQLite backupNo
Fail2banSQLite at `/var/lib/fail2ban/fail2ban.sqlite3`SQLite backupNo
ForgejoSQLite (default), PostgreSQL or MySQL optionalSQLite backupNo
FreshRSSSQLite by default; PostgreSQL 10+, MariaDB 10.6+ or MySQL 8.0+ supportedSQLite backupNo
FrigateSQLite (frigate.db) for metadata, video segments on diskSQLite backupNo
GarageLMDB for metadata (SQLite optional), content-addressed data blocks on diskSQLite backupNo
GatusIn-memory by default, SQLite or PostgreSQL optionalSQLite backupNo
GiteaSQLite (default), PostgreSQL or MySQL optionalSQLite backupNo
GotifySQLite (MySQL and PostgreSQL supported)SQLite backupNo
GrafanaSQLite 3 by default, MySQL 8.0+ or PostgreSQL 12+ supportedSQLite backupNo
HeadscaleSQLite (recommended); PostgreSQL supported but in maintenance modeSQLite backupNo
HomarrSQLite by default; MySQL or PostgreSQL optionalSQLite backupNo
Home AssistantSQLite (recorder), MariaDB or PostgreSQL optionalSQLite backupNo
JellyfinSQLiteSQLite backupNo
JellyseerrSQLiteSQLite backupNo
KarakeepSQLite for data, Meilisearch for the search indexSQLite backupNo
KavitaSQLiteSQLite backupNo
LLDAPSQLite (default), PostgreSQL or MySQL/MariaDBSQLite backupNo
MealieSQLite by default, PostgreSQL optionalSQLite backupNo
NavidromeSQLiteSQLite backupNo
Netdatadbengine, its own tiered on-disk store, plus SQLite for metadataSQLite backupNo
Nginx Proxy ManagerSQLite by default; MariaDB/MySQL or PostgreSQL optionalSQLite backupNo
Open WebUISQLite by default at DATA_DIR/webui.db, PostgreSQL optionalSQLite backupNo
Pi-holeSQLite (gravity.db and pihole-FTL.db) plus a TOML config fileSQLite backupNo
Plex Media ServerSQLiteSQLite backupNo
Pocket IDSQLite (default) or PostgreSQLSQLite backupNo
PodmanSQLite state database under ~/.local/share/containers (BoltDB support removed in 6.0)SQLite backupNo
ProwlarrSQLite (PostgreSQL optional)SQLite backupNo
Proxmox VESQLite via pmxcfs (/var/lib/pve-cluster/config.db), replicated by CorosyncSQLite backupNo
RadarrSQLite (PostgreSQL optional)SQLite backupNo
ScrutinyInfluxDB 2 for metrics plus SQLite for devices and settingsSQLite backupNo
SonarrSQLite (PostgreSQL optional)SQLite backupNo
SyncthingSQLite since 2.0 (LevelDB before that), local to each deviceSQLite backupNo
Trilium NotesSQLite (`document.db`)SQLite backupNo
TrueNAS Community EditionSQLite configuration database on the boot pool, plus ZFS datasets for everything elseSQLite backupNo
Uptime KumaSQLite by default, MariaDB optional in v2SQLite backupNo
VaultwardenSQLite (default), PostgreSQL or MySQL/MariaDBSQLite backupNo
VikunjaSQLite by default, PostgreSQL or MySQL/MariaDB supportedSQLite backupNo
Woodpecker CISQLite (default), PostgreSQL or MySQL optionalSQLite backupNo
grocySQLiteSQLite backupNo
k3sSQLite by default (kine); embedded etcd for HA, or external MySQL or PostgreSQLSQLite backupNo
linkdingSQLite by default; PostgreSQL supported via LD_DB_ENGINESQLite backupNo
n8nSQLite by default, PostgreSQL for anything serious; Redis required in queue modeSQLite backupNo
ntfySQLite (PostgreSQL supported since 2.x)SQLite backupNo
wallabagSQLite by default in Docker; MySQL/MariaDB or PostgreSQL supported and preferred at scaleSQLite backupNo
wg-easySQLite since v15; v14 and earlier used a JSON fileSQLite backupNo

The parts a backup shape cannot tell you#

The shape column above is derived by us from each project's declared datastore, not stated by the project. The derivation rules are on the our methodology page: PostgreSQL implies a dump, SQLite implies the backup API or a stop, flat configuration implies a copy. That makes the number reliable as a floor and useless as a complete plan, for three reasons the data cannot capture.

The first is keys held outside the database. Outline's own backup documentation warns that losing SECRET_KEY makes all encrypted database content inaccessible, so a perfect Postgres dump restores an unreadable wiki. GitLab CE's gitlab-backup create deliberately skips /etc/gitlab/gitlab-secrets.json, and without it every encrypted column, CI variable, integration token and two-factor seed decrypts to garbage. Authelia encrypts TOTP secrets and WebAuthn credentials with a key from its config file, so a restore onto an instance with a different key means every user re-enrolls. Node-RED invents a credentialSecret and stores it in .config.runtime.json if you do not set one. Coolify keeps its Laravel key in a bind-mounted .env that volume backups miss.

The second is files the database does not know about. Outline with the default FILE_STORAGE=local puts every uploaded image in a volume that the backup page does not mention. mailcow stores mail encrypted with keys in the crypt volume, separate from vmail, so a vmail backup restores a maildir tree nobody can open.

The third is version coupling. Paperless-ngx states plainly that an export cannot be imported into a different version, because the manifest mirrors the database layout and migrations change it. Store the image tag next to every export. Immich does not support downgrades at all, even within a minor, so recovery from a bad upgrade is restoring the pre-upgrade dump rather than pulling the old tag. Both facts turn "we have backups" into "we have backups and the tag that reads them", which is a different sentence.

How much to trust the shape#

One artifact is worth knowing about before you read the table, because it is ours and not the projects'. The derivation matches the first database it finds in the declared datastore string, so an app that ships on SQLite but also supports PostgreSQL is counted in the Postgres bucket. Vaultwarden, linkding and Sonarr all default to SQLite in a normal single-household install, and all three appear above as SQL dump cases. Uptime Kuma is the same story with MariaDB.

That does not move the headline: every one of those apps still fails a live file copy, which is what 81.9 percent measures. It does mean the shape can name a database you are not running. Treat the column as the answer to "can I just copy this", which it gets right, rather than as the exact command, which depends on which engine you chose at install time. Each app profile states the engine it actually ships with.

The sharpest version of the same artifact is Stalwart. It defaults to RocksDB, a single embedded store holding messages, blobs and index together, and because its datastore string also lists PostgreSQL as an option it lands in the Postgres bucket. Rsyncing /var/lib/stalwart while it runs can produce a copy with a partially written write-ahead log that never opens, and no pg_dump will save you, because there is no Postgres running. The two entries that genuinely land in Mixed, Kopia and Prometheus, are there because their datastore is neither a SQL server nor flat configuration, and Mixed means stop the service and copy the directory.

The three commands that cover the whole index#

For Postgres apps, dump on a schedule and back up the dump, not the directory:

bash
docker exec -t immich_postgres pg_dumpall --clean --if-exists -U postgres \
  | gzip > /srv/backup/immich-$(date +%F).sql.gz

For SQLite apps, use the backup API as shown above, or stop the container for the thirty seconds a copy takes. Uptime Kuma, Trilium Notes and linkding are all fine with a brief stop, and linkding additionally ships manage.py full_backup which packages the database and data folder together.

For file-shaped apps, copy freely, and remember that Syncthing replicating a directory is not a backup of it: deletions propagate, which the project's own FAQ says outright.

Then wrap all three in one tool with retention and encryption. restic vs BorgBackup and restic vs Kopia cover the transport choice; the consistency job stays separate and runs first.

Next step#

Work through your own stack in Backup planner, which reads the derived shape for each app and tells you which of the three patterns it needs. Then read Backing up a running database for the dump and snapshot details, and Backups that actually restore for the part that most people skip, which is proving that any of it comes back.

Questions#

Is a ZFS or LVM snapshot good enough for PostgreSQL?

Yes, if the snapshot is atomic across every filesystem the data directory touches. PostgreSQL's own documentation says a consistent snapshot works while the server is running, because restoring it looks to Postgres exactly like recovering from a crash and the write-ahead log replays on startup. The caveat is real: if your data directory spans multiple volumes or tablespaces, the snapshots must be simultaneous, and if they are not, you have an inconsistent copy that may still start.

Why can copying a SQLite .db file restore corrupt?

In WAL mode the -wal file is part of the database's persistent state. SQLite's documentation says directly that separating a database file from its WAL file can lose committed transactions or corrupt the database. Even without WAL, cp is not atomic: the file changes underneath you as pages are written. The online backup API, which is what sqlite3 db '.backup out.db' uses, copies pages under a consistent view and gives you a file that opens.

Does restic or Borg make this safe?

No. Backup tools solve encryption, deduplication and retention. None of them freeze your application. restic walking a live Postgres data directory reads different files at different moments and stores an inconsistent set, which is exactly the problem. Use the tool to store a dump, or to store a snapshot mount, and keep the consistency job separate from the transport job.

What order do you back up files and a database in?

Dump the database first, then copy the data directory. Seafile documents this explicitly: doing it the other way round means new objects written during the file copy are absent from a dump written afterwards, so libraries restore corrupt. The documented tradeoff is losing the last few seconds of uploads, which is the correct thing to lose. On restore, reverse it: files first, then the dump.

What does the backup shape in this dataset actually mean?

It is derived by us from each project's declared datastore, not stated by the project. PostgreSQL implies a dump, SQLite implies the backup API or a stop, flat files imply a copy. It is a reliable floor and not a complete plan: it does not know about encryption keys held outside the database, and it cannot tell you the ordering constraints between an app's files and its rows. Read the app profile and the upstream docs before you trust a generated command.

How do I know my backup actually restores?

Restore it. Bring the dump up in a throwaway container with a different port, point the app at it, log in, and open one object you recognize. That takes about twenty minutes and it is the only test that distinguishes a repository full of files from a backup. Do it once per app when you set it up, and again after any upgrade that runs a schema migration.

Sources#

Published . Last reviewed . Found something out of date? Tell us and we will fix it and log the change.