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.
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.
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:
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.
| Tool | Datastore | Backup shape | Live file copy safe |
|---|---|---|---|
| AdGuard Home | A single YAML config file plus BoltDB files for stats and sessions; the query log is a JSON lines file | Embedded key-value store | No |
| Diun | bbolt, a single file at `/data/diun.db` | Embedded key-value store | No |
| Portainer | BoltDB key-value file at /data/portainer.db | Embedded key-value store | No |
| Stalwart | RocksDB by default; PostgreSQL, MySQL, SQLite, FoundationDB, S3 and Redis also supported | Embedded key-value store | No |
| Caddy | Filesystem (certificates, keys and ACME account data in the data directory) | File copy | Yes |
| Docker Engine | None; image and container metadata under /var/lib/docker, containerd image store by default since 29.0 | File copy | Yes |
| Dozzle | None for logs; a small /data directory for users and UI settings | File copy | Yes |
| ESPHome | YAML files on disk, no database | File copy | Yes |
| Glance | None (YAML config plus an in-memory cache) | File copy | Yes |
| Homepage | None (YAML files on disk) | File copy | Yes |
| LocalAI | None; model files and per-model YAML on disk | File copy | Yes |
| MinIO | None external. Objects and their metadata live on the raw drives, with erasure coding and internal state under .minio.sys | File copy | Yes |
| Node-RED | JSON files on disk (flows.json and an encrypted credentials file) | File copy | Yes |
| Ollama | None; a content-addressed blob store on disk | File copy | Yes |
| SearXNG | None; Valkey or Redis only if you enable the limiter | File copy | Yes |
| Technitium DNS Server | Flat files under the config directory (zone files, settings, stats); no external database | File copy | Yes |
| Traefik | None. Certificates in a single acme.json file; routing state rebuilt from providers at every start | File copy | Yes |
| Unraid | Flat configuration files on the boot device, loaded into a RAM filesystem at boot | File copy | Yes |
| Watchtower | none, it reads the Docker API | File copy | Yes |
| Zigbee2MQTT | Flat files in the data directory (database.db, a JSON lines file) | File copy | Yes |
| code-server | None; state is files on disk | File copy | Yes |
| rclone | None. A single INI-style config file at ~/.config/rclone/rclone.conf | File copy | Yes |
| BorgBackup | Its own repository format, plus a local chunks index and files cache under ~/.cache/borg | Mixed | No |
| File Browser | Bolt, a single embedded `filebrowser.db` file | Mixed | No |
| Kopia | Its own content-addressed repository with an epoch-based index, plus a local cache directory | Mixed | No |
| Prometheus | Its own TSDB on local disk, no external database | Mixed | No |
| restic | Its own content-addressed repository (files in a directory or bucket), no database service | Mixed | No |
| Komodo | MongoDB (FerretDB supported as a drop-in for hosts that cannot run current MongoDB) | Mongo dump | No |
| BookStack | MySQL 8.0+ or MariaDB 10.6+ | MySQL dump | No |
| Firefly III | MariaDB or MySQL by default, PostgreSQL supported, SQLite possible but discouraged | MySQL dump | No |
| Nextcloud | MariaDB 10.11+/11.x or PostgreSQL 14 to 18, plus Redis for file locking and caching | MySQL dump | No |
| PhotoPrism | MariaDB 10.5.12+ recommended; SQLite supported for small libraries | MySQL dump | No |
| Seafile | MariaDB/MySQL holding ccnet_db, seafile_db and seahub_db, plus Redis for cache; file content lives as blocks on disk or S3 | MySQL dump | No |
| mailcow: dockerized | MariaDB 10.11 plus Redis | MySQL dump | No |
| Coolify | PostgreSQL, with Redis for queues and a Soketi realtime service | Postgres dump | No |
| Docmost | PostgreSQL with Redis | Postgres dump | No |
| Dokploy | PostgreSQL 16 (Docker Swarm service, dokploy-postgres volume) | Postgres dump | No |
| GitLab CE | PostgreSQL plus Redis (both bundled in Omnibus) | Postgres dump | No |
| Harbor | PostgreSQL plus Redis, both bundled | Postgres dump | No |
| HedgeDoc | PostgreSQL (MariaDB/MySQL supported, SQLite discouraged) | Postgres dump | No |
| Immich | PostgreSQL 14 with the VectorChord extension, plus Valkey/Redis for the job queue | Postgres dump | No |
| Keycloak | PostgreSQL, MariaDB/MySQL, MSSQL or Oracle; a dev-file H2 database for development only | Postgres dump | No |
| Mattermost | PostgreSQL 14+ | Postgres dump | No |
| Miniflux | PostgreSQL only | Postgres dump | No |
| Outline | PostgreSQL 14+ with Redis 4+ | Postgres dump | No |
| PLANKA | PostgreSQL | Postgres dump | No |
| Paperless-ngx | PostgreSQL (SQLite and MariaDB also supported) | Postgres dump | No |
| Synapse | PostgreSQL | Postgres dump | No |
| Wiki.js | PostgreSQL 9.5+ recommended (MySQL, MariaDB, MSSQL and SQLite still supported in 2.x) | Postgres dump | No |
| authentik | PostgreSQL | Postgres dump | No |
| Wazuh | Wazuh indexer, a fork of OpenSearch 2.x | Rebuildable index | Yes |
| Actual Budget | SQLite on the client; the server keeps account.sqlite plus per-budget binary files | SQLite backup | No |
| Audiobookshelf | SQLite | SQLite backup | No |
| Authelia | SQLite (default), PostgreSQL or MySQL; sessions in memory or Redis | SQLite backup | No |
| Beszel | SQLite via PocketBase | SQLite backup | No |
| Calibre-Web | SQLite (Calibre's metadata.db plus its own app.db) | SQLite backup | No |
| CasaOS | SQLite files and INI config under /var/lib/casaos and /etc/casaos | SQLite backup | No |
| CrowdSec | SQLite by default (MySQL and PostgreSQL supported) | SQLite backup | No |
| Duplicati | SQLite: one server database plus one local database per backup job | SQLite backup | No |
| Emby | SQLite | SQLite backup | No |
| Fail2ban | SQLite at `/var/lib/fail2ban/fail2ban.sqlite3` | SQLite backup | No |
| Forgejo | SQLite (default), PostgreSQL or MySQL optional | SQLite backup | No |
| FreshRSS | SQLite by default; PostgreSQL 10+, MariaDB 10.6+ or MySQL 8.0+ supported | SQLite backup | No |
| Frigate | SQLite (frigate.db) for metadata, video segments on disk | SQLite backup | No |
| Garage | LMDB for metadata (SQLite optional), content-addressed data blocks on disk | SQLite backup | No |
| Gatus | In-memory by default, SQLite or PostgreSQL optional | SQLite backup | No |
| Gitea | SQLite (default), PostgreSQL or MySQL optional | SQLite backup | No |
| Gotify | SQLite (MySQL and PostgreSQL supported) | SQLite backup | No |
| Grafana | SQLite 3 by default, MySQL 8.0+ or PostgreSQL 12+ supported | SQLite backup | No |
| Headscale | SQLite (recommended); PostgreSQL supported but in maintenance mode | SQLite backup | No |
| Homarr | SQLite by default; MySQL or PostgreSQL optional | SQLite backup | No |
| Home Assistant | SQLite (recorder), MariaDB or PostgreSQL optional | SQLite backup | No |
| Jellyfin | SQLite | SQLite backup | No |
| Jellyseerr | SQLite | SQLite backup | No |
| Karakeep | SQLite for data, Meilisearch for the search index | SQLite backup | No |
| Kavita | SQLite | SQLite backup | No |
| LLDAP | SQLite (default), PostgreSQL or MySQL/MariaDB | SQLite backup | No |
| Mealie | SQLite by default, PostgreSQL optional | SQLite backup | No |
| Navidrome | SQLite | SQLite backup | No |
| Netdata | dbengine, its own tiered on-disk store, plus SQLite for metadata | SQLite backup | No |
| Nginx Proxy Manager | SQLite by default; MariaDB/MySQL or PostgreSQL optional | SQLite backup | No |
| Open WebUI | SQLite by default at DATA_DIR/webui.db, PostgreSQL optional | SQLite backup | No |
| Pi-hole | SQLite (gravity.db and pihole-FTL.db) plus a TOML config file | SQLite backup | No |
| Plex Media Server | SQLite | SQLite backup | No |
| Pocket ID | SQLite (default) or PostgreSQL | SQLite backup | No |
| Podman | SQLite state database under ~/.local/share/containers (BoltDB support removed in 6.0) | SQLite backup | No |
| Prowlarr | SQLite (PostgreSQL optional) | SQLite backup | No |
| Proxmox VE | SQLite via pmxcfs (/var/lib/pve-cluster/config.db), replicated by Corosync | SQLite backup | No |
| Radarr | SQLite (PostgreSQL optional) | SQLite backup | No |
| Scrutiny | InfluxDB 2 for metrics plus SQLite for devices and settings | SQLite backup | No |
| Sonarr | SQLite (PostgreSQL optional) | SQLite backup | No |
| Syncthing | SQLite since 2.0 (LevelDB before that), local to each device | SQLite backup | No |
| Trilium Notes | SQLite (`document.db`) | SQLite backup | No |
| TrueNAS Community Edition | SQLite configuration database on the boot pool, plus ZFS datasets for everything else | SQLite backup | No |
| Uptime Kuma | SQLite by default, MariaDB optional in v2 | SQLite backup | No |
| Vaultwarden | SQLite (default), PostgreSQL or MySQL/MariaDB | SQLite backup | No |
| Vikunja | SQLite by default, PostgreSQL or MySQL/MariaDB supported | SQLite backup | No |
| Woodpecker CI | SQLite (default), PostgreSQL or MySQL optional | SQLite backup | No |
| grocy | SQLite | SQLite backup | No |
| k3s | SQLite by default (kine); embedded etcd for HA, or external MySQL or PostgreSQL | SQLite backup | No |
| linkding | SQLite by default; PostgreSQL supported via LD_DB_ENGINE | SQLite backup | No |
| n8n | SQLite by default, PostgreSQL for anything serious; Redis required in queue mode | SQLite backup | No |
| ntfy | SQLite (PostgreSQL supported since 2.x) | SQLite backup | No |
| wallabag | SQLite by default in Docker; MySQL/MariaDB or PostgreSQL supported and preferred at scale | SQLite backup | No |
| wg-easy | SQLite since v15; v14 and earlier used a JSON file | SQLite backup | No |
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:
docker exec -t immich_postgres pg_dumpall --clean --if-exists -U postgres \
| gzip > /srv/backup/immich-$(date +%F).sql.gzFor 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#
- SQLite documentation, the online backup API and why file copies fail
- SQLite documentation, write-ahead logging and the -wal file
- PostgreSQL documentation, file system level backup and consistent snapshots
- linkding backups, the warning against copying the SQLite file
- Vaultwarden wiki, backing up your vault
- Seafile admin manual, backup and recovery ordering
- GitLab documentation, back up GitLab and the secrets file
- Outline documentation, backups and the SECRET_KEY warning
- Paperless-ngx administration docs, document_exporter and version pinning
- Nextcloud admin manual, backup procedure
Published . Last reviewed . Found something out of date? Tell us and we will fix it and log the change.