Backing Up Docker Appdata on unRAID Without Stopping Every Container Every Night
How I moved the unRAID Appdata Backup plugin to "do not stop" by default, took a consistent Home Assistant SQLite copy with a pre-container hook, and hit the containerOrder and \r\n gotchas.
HomeLab, Backups. Updated . 4 min read.
Until early September my NUC restarted almost every container at 3 AM so a backup plugin could tar their folders. Home Assistant was down for 5 minutes a night. DNS blipped, the reverse proxy dropped every URL, and Zigbee and MQTT restarted, all for an archive that did not need any of it.
The plugin can back up live. The work was deciding which containers must still stop, and making the one database I would not stop safe to copy anyway.
TL;DR: the unRAID Appdata Backup plugin now defaults to "Skip stopping of container" = yes and verify = no. The 28 containers that hold a database get a per-container "no", which means stop and verify. Home Assistant's recorder DB is excluded from its tar and copied by a pre-container hook with SQLite's online backup API. Along the way: containers missing from containerOrder ignore the skip setting, a list field in config.json splits on \r\n only, and a folder of Immich dumps stretched the run by 26 minutes.
What a nightly stop cost
The plugin runs at 03:00, keeps 7 days of snapshots with a minimum of 3, and by default stops each container, tars its appdata, and starts it again. Before I changed it:
| Container | What the stop did |
|---|---|
| Home Assistant | down 5 minutes, a 4.7 GB tar |
| Zigbee2MQTT, Mosquitto | restarted |
| AdGuard | DNS blip for the whole house |
| Caddy | every proxied URL dropped |
| eufy | re-logged in to its cloud |
Most of this state is small files and atomically written config. Stopping it buys nothing.
The split: stop only what has a database
Since 2026-09-05 the global defaults are "Skip stopping" = yes and verify = no. Verify is tar --diff against the source folder. Against a live folder it proves nothing, because the folder has moved on by the time the diff runs.
The containers that hold a database carry a per-container "no": stop, tar, verify, start. That is 28 of them, including Forgejo, Vaultwarden, the three Immich containers, Jellyfin, Mealie, Memos, Ntfy, Uptime Kuma, Paperless and the four Dawarich containers. The same list sits in an Uptime Kuma maintenance window, so their monitors stay quiet while they are down.
The rule for a new container: if it holds SQLite, Postgres, a Redis dump or LMDB, set its "Skip stopping" to no and verify to yes. Otherwise it is backed up live.
Paperless shows why the rule is about the files, not the app. Its main database lives in a shared Postgres container that gets its own pg_dump, so it started out live. But data/llm_index/llmindex.db is a 279 MB SQLite file that Paperless rewrites after every document save. The first big mail import, about 1650 documents overnight, made the 03:00 tar fail with file changed as we read it. Paperless moved to the stop list on 2026-09-16. The stop costs about 20 s.
Keep "ignore backup errors" off
When live tars start failing on churn, the tempting switch is "Ignore backup errors" = yes. I left it at no on purpose. The plugin treats every tar exit code above 0 as a failure, and exit 2 is fatal: an unreadable file or a full disk. With ignore on, a truncated archive would be filed as a good night.
So the files that churn get excluded instead:
- AdGuard:
work/data/querylog.json*,stats.dbandsessions.db(bbolt, not safe to copy live), andfilters(660 MB, rebuilt on start) - Caddy, Homepage, Recyclarr:
logs/* - Zigbee2MQTT
log, Mosquittolog/mosquitto.log - Jellyfin:
log/*anddata/trickplay, 8.2 GB of preview images it rebuilds on a library scan. With them, the tar took 9 minutes and Jellyfin was down for 11.
One trap: the plugin ignores an exclude that names a whole volume. Name a file or dir/*. And if a night still ends -failed with "file changed as we read it", that file goes on its container's exclude list. The switch stays off.
Home Assistant: a consistent copy, live
Home Assistant is the container I most wanted to stop restarting, and its database never stops writing: the recorder, home-assistant_v2.db, 2.6 GB in WAL mode. Copying that file while the recorder writes gives you a torn database.
So the live home-assistant_v2.db* files and home-assistant.log* are excluded from the HA tar, and the plugin's pre-container hook makes a proper copy first. The plugin calls the hook as <script> pre-container <containerName>; exit 0 means go on. The HA image has Python but no sqlite3 CLI, so the hook runs Python inside the container:
[ "$1" = "pre-container" ] && [ "$2" = "homeassistant" ] || exit 0
timeout 600 docker exec -i homeassistant python3 - <<'EOF'
import os, sqlite3, sys
src = "/config/home-assistant_v2.db"
dst = "/config/db-snapshot/home-assistant_v2.db"
tmp = dst + ".tmp"
try:
s = sqlite3.connect(src)
t = sqlite3.connect(tmp)
s.backup(t) # one step
s.close()
ok = t.execute("pragma quick_check").fetchone()[0]
t.close()
if ok != "ok":
raise RuntimeError("quick_check: " + ok)
os.replace(tmp, dst)
except Exception as e:
for p in (tmp, dst):
if os.path.exists(p):
os.remove(p)
sys.exit("db snapshot FAILED, stale snapshot removed: %r" % e)
EOF(Trimmed: the real one also creates the folder and prints size and time.)
Three choices in there are deliberate:
- One backup step. SQLite's backup API can copy in chunks, but a stepped backup restarts every time another connection writes. The recorder writes every second, so a stepped copy may never finish. One step takes about 150 s,
quick_checkincluded. - Write to
.tmp, thenos.replace. The snapshot is either the new good copy or nothing. - On failure, delete the old snapshot too. A restore should never pick up yesterday's DB by mistake. The hook exits 1, and the plugin logs a warning and tars HA without a DB that night.
Restore is copying db-snapshot/home-assistant_v2.db back to the config root. The plugin only notifies on errors, so a warning means reading the log. The hook lives in appdata, not next to the plugin on the flash drive, because the flash is vfat and has no exec bit.
Gotcha: containerOrder beats skip
On 2026-09-05 the plugin stopped glacier-backup, my rclone container for the offsite pass. It had been set to skip = yes two days earlier.
The cause is in sortContainers in the plugin's ABHelper.php. Containers missing from containerOrder in config.json get appended at the end of the run, and the skip filter does not apply to them. glacier-backup was one of six names that had never made it into the list.
This one had history. Stopping that container mid-upload at 03:35 was the cause of every failed Glacier pass from 08-15 to 09-03, which I only found in the same audit as the cleanup bug. Saving the settings page rebuilds containerOrder. A hand edit added the six missing names that day.
Gotcha: the list that splits on \r\n
The plugin also backs up extra files, outside any container: my rclone.conf, and since 2026-09-03 the folder of nightly Immich database dumps. In config.json that list is one string, and the plugin splits it on \r\n only.
I added the Immich path by hand with a plain \n. The plugin read both paths as one long path, failed the existence check, and on 2026-09-04 skipped every extra file. No error, just no rclone.conf in the snapshot. Edit that list in the settings page, or write \r\n between the paths and after the last one.
The Immich dump overrun
Adding that dump folder had a second cost. It held 14 Immich database dumps of 1.2 GB each, 17 GB. Tarring it took 26 minutes, the run ended at 04:23, and every nightly snapshot carried those 17 GB to S3.
The fix was retention, not the backup. Immich's dump retention is now 3, set through backup.database.keepLastAmount. I wrote it with psql, since there was no Immich API key to use; the UI shows the same setting under Administration > Settings > Backup. The 11 older dumps went by hand the same day, leaving 3.5 GB. Immich reads the value after its nightly restart, and its 06:00 dump job prunes beyond it. If the UI ever shows 14 again, someone saved that page with a stale cache before the restart.
The run used to end anywhere from 03:40 to 04:25. With the dumps trimmed and most containers left running, I expect about 03:40.
Questions
- Does the unRAID Appdata Backup plugin have to stop containers?
- No. It has a "Skip stopping of container" setting, globally and per container. I set the default to skip and turned verify off, then set "no" (stop and verify) only on the 28 containers that hold a database.
- How do I back up the Home Assistant database without stopping Home Assistant?
- Exclude home-assistant_v2.db* from the tar and use a pre-container hook that runs the SQLite online backup API inside the container with docker exec python3, in one step, then pragma quick_check. The plugin then tars that consistent copy.
- Why does the Appdata Backup plugin stop a container I set to skip?
- Containers missing from containerOrder in config.json are appended at the end of the run, and the skip filter does not apply to them. Saving the plugin settings page rebuilds the list.
- Why does the Appdata Backup plugin ignore my extra files?
- The extra-files list is one string that the plugin splits on \r\n only. A hand edit with plain \n makes it read all paths as one path, which fails the existence check, so every extra file is skipped. Edit the list in the settings page.