Hobby projects → Unraid appdata without downtime
Guide · Unraid 7.3 · Btrfs · Appdata.Backup · September 2026

Unraid appdata backups without stopping containers

The standard Unraid appdata backup stops every container, copies the folder, and starts them again, because a live copy of a running SQLite or Postgres database is a torn copy that will not restore. The way round it is not a newer Unraid release; it is a filesystem snapshot. If your appdata lives on a Btrfs cache pool you already have everything you need: a one-time conversion of the appdata folder into a subvolume, two hook scripts, and a handful of settings in the Appdata.Backup plugin. Everything below is done from the web GUI — User Scripts is the shell — and the result is a daily, compressed, rotating backup on the array, one archive per container, restorable with Zip Manager and the file manager, while every container keeps running.

How it works

A snapshot is instant and atomic. Btrfs freezes a subvolume at a point in time in milliseconds, copy-on-write, so the running containers keep writing to the live tree while the backup reads the frozen one. The backup takes as long as it takes; nothing waits for it.

Only a subvolume can be snapshotted. On most Unraid installs appdata is an ordinary directory on the pool, not a subvolume, and btrfs subvolume snapshot refuses it. Converting it is a one-time job that needs Docker stopped once — the last stop you will do for backups.

The plugin does the scheduling; two hooks do the work. Appdata.Backup (the maintained fork by Commifreak) has pre-run and post-run script hooks and passes each run’s destination folder to them. The pre-run hook takes the snapshot; the post-run hook writes one compressed archive per container folder from the snapshot into that run’s folder and deletes the snapshot. The plugin itself backs up the flash drive and VM metadata, rotates old runs, and updates containers afterwards. Its “skip stopping” option is on, so it never touches a running container.

What a snapshot does and does not promise. It is crash-consistent: databases are captured exactly as they would be after a power cut. SQLite and Postgres replay their write-ahead logs on the next start, so this restores cleanly in practice, but it is not a quiesced dump. For a database you cannot afford to lose the last few seconds of, add a pg_dump or sqlite3 … .backup to the pre-run script before the snapshot line.

What you need

Using User Scripts as the shell. Settings → User Scripts → Add New Script → name it → the gear icon → Edit Script → paste → Save. Run it with Run Script (output in a window) or Run in Background (for anything long; output goes to the script’s log, readable with the Show Log button). Leave every script’s schedule on Scheduled Disabled unless a step says otherwise. If you prefer a terminal, every block below runs unchanged in one.

First, the gotcha that matters most: downloads on the appdata pool

On a great many Unraid boxes the cache pool does two jobs at once: it holds appdata, and it is also where torrents, usenet downloads, transcodes and camera recordings land. That is fine for performance and bad for this scheme, for three reasons.

Fix 1 — a second pool for downloads (recommended)

Unraid supports several pools, and a cheap SATA SSD or a second NVMe makes the problem structural rather than behavioural: appdata on one pool, everything transient on the other. Main → Add Pool, name it scratch (or downloads), assign the drive, format it. Then point the transient shares at it: for each of downloads, incomplete, transcode and camera shares, Shares → share → Primary storage = scratch, Secondary = Array where you want the mover to sweep finished files to the array, or None for pure scratch. The appdata pool then only ever sees appdata, snapshots stay small, and a full scratch pool cannot take a container database down with it.

Sizing: incomplete torrent and usenet data rarely needs more than a few hundred gigabytes if the mover runs nightly; camera recordings and transcodes are whatever your retention says. A 1 TB drive covers most households.

Fix 2 — move the bulk shares to the array

No second drive: move the transient shares off the pool and onto the array. See what is on the pool first — a User Script, Run Script.
User Script: pool-usage
#!/bin/bash
# what is on the pool, largest last, and how full the pool is
du -sh /mnt/cache/* 2>/dev/null | sort -h | tail -8
btrfs fi df /mnt/cache | grep -i "^Data"

The GUI way to move a share is the mover: Shares → the share → Primary storage = Cache, Secondary storage = Array, mover action Cache → Array, Apply; then Main → Move. Stop whatever writes to the share first (the container, the camera software), and read the two traps below before pressing Apply.

Two traps in the share settings. First: on the Primary/Secondary storage model, setting a share straight to Primary = Array does not move existing data; it only redirects new writes. The mover acts only while the share has Primary = Cache and Secondary = Array. Second: applying a storage-tier change to a share can stop and restart the array. Anything writing to /mnt/user at that moment dies with a permission error. Stop the writers before you press Apply.
The mover has no progress display and can take a while to reach a given share. A direct copy to the array tier of the same share is deterministic and shows a rate; as a User Script, Run in Background, progress in its log. Stop the writer first.
User Script: move-share-to-array (edit the share name; Run in Background)
#!/bin/bash
SHARE=downloads          # the share to move off the pool
# /mnt/user0 is the array without the cache overlay: same share, array tier
rsync -a --info=progress2 --remove-source-files "/mnt/cache/$SHARE/" "/mnt/user0/$SHARE/"
find "/mnt/cache/$SHARE" -type d -empty -delete
du -sh "/mnt/cache/$SHARE" 2>/dev/null || echo "cache copy gone"
du -sh "/mnt/user0/$SHARE"
echo "now set the share to Primary = Array, Secondary = None and Apply (nothing writing when you click)"
Speed. Cache-to-array writes pay for parity. Settings → Disk Settings → Tunable (md_write_method)reconstruct write roughly doubles array write throughput; put it back to auto afterwards so the disks can spin down. Expect 60–100 MB/s on small files, faster on large ones.
If a client mounts the share over NFS, an array restart leaves its mount stale, and Docker on the client then fails to start the container with a misleading mkdir /mnt/<share>: file exists. On the client: umount -l /mnt/<share> && mount /mnt/<share>, then start the container. Same fix after any Unraid array stop/start.

Step 1 — is appdata a subvolume?

A User Script, Run Script. If it prints a UUID and a generation number, appdata is already a subvolume: skip to step 3. If it says Not a Btrfs subvolume, step 2 is the conversion. It also prints the size, which is the free space step 2 needs.
User Script: appdata-check
#!/bin/bash
btrfs subvolume show /mnt/cache/appdata 2>&1 | head -3
echo "--- size of appdata (step 2 needs this much free on the pool):"
du -sh /mnt/cache/appdata
echo "--- pool:"
btrfs fi df /mnt/cache | grep -i "^Data"

Step 2 — convert appdata to a subvolume

The only container stop in the whole scheme. One User Script, Run in Background: it stops Docker, renames the old directory (never deletes it), creates a subvolume under the original name, copies everything in with ownership and permissions preserved, verifies the copy, and starts Docker again. The old directory stays as the rollback until you delete it the next day. NVMe to NVMe on the same pool runs at roughly 10 minutes per 100 GB; read the log when it finishes.
User Script: appdata-convert (Run in Background; watch with Show Log)
#!/bin/bash
set -u
POOL=/mnt/cache
echo "$(date '+%T') stopping Docker"
/etc/rc.d/rc.docker stop
if btrfs subvolume show "$POOL/appdata" >/dev/null 2>&1; then
  echo "appdata is already a subvolume; starting Docker and stopping here"; /etc/rc.d/rc.docker start; exit 0
fi
echo "$(date '+%T') size: $(du -sh "$POOL/appdata" | cut -f1)"
mv "$POOL/appdata" "$POOL/appdata.old" || exit 1
btrfs subvolume create "$POOL/appdata" || exit 1
echo "$(date '+%T') copying (this is the long part)"
cp -a "$POOL/appdata.old/." "$POOL/appdata/" 2> /root/appdata-convert.log
echo "$(date '+%T') copy done; cp errors: $(wc -l < /root/appdata-convert.log)  (expect 0)"
btrfs subvolume show "$POOL/appdata" | head -2
echo "$(date '+%T') comparing old and new (dangling symlinks and sockets are expected noise)"
diff -qr "$POOL/appdata.old" "$POOL/appdata" > /root/appdata-diff.txt 2>&1
echo "diff lines: $(wc -l < /root/appdata-diff.txt)   of which 'No such file' (symlinks): $(grep -c 'No such file or directory' /root/appdata-diff.txt)"
echo "--- lines that are NOT symlink or socket noise (a real difference would show here):"
grep -vE "No such file or directory|is a socket" /root/appdata-diff.txt | head -10
echo "$(date '+%T') starting Docker"
/etc/rc.d/rc.docker start
echo "done. Check the containers; delete $POOL/appdata.old tomorrow (User Script: appdata-cleanup)."
Reading the log. Expect thousands of diff lines, almost all noise: No such file or directory for dangling symlinks (icon themes, Python virtualenvs pointing at an interpreter that only exists inside a container, driver and certificate links) and is a socket for sockets, which cp -a copies as sockets. The block headed “lines that are NOT symlink or socket noise” is what matters: any Only in appdata.old or Files … differ line under a database folder would mean a short copy. That block empty, and cp errors 0, is the green light.
A container that will not come back: stale PID file. Any container that was SIGKILLed on the way down (Plex regularly takes longer than Docker’s 10-second grace) leaves its PID file behind, the copy preserves it, and the new container refuses with “already running”. In the file manager, delete appdata/<plex-folder>/Plex Media Server/plexmediaserver.pid and restart the container. Expect a slow first start from anything that walks its data on boot.
User Script: appdata-cleanup (Run Script, the next day, once everything has run clean)
#!/bin/bash
rm -rf /mnt/cache/appdata.old && echo "appdata.old removed"
btrfs fi df /mnt/cache | grep -i "^Data"

Step 3 — the two hook scripts

Two User Scripts, named exactly appdata-snap-pre and appdata-snap-post, schedule Scheduled Disabled — the backup plugin calls them, nothing else should. Pre-run takes a read-only snapshot beside appdata (never inside it, or every snapshot would contain the previous one). Post-run writes one archive per container folder from the snapshot into the run’s dated folder — containers/<name>.tar.zst, the container folder at the top level, nothing nested — and then deletes the snapshot.
User Script: appdata-snap-pre
#!/bin/bash
#description=Appdata.Backup PRE-run hook: read-only Btrfs snapshot of appdata beside it. Called by the plugin; do not schedule.
mkdir -p /mnt/cache/.snapshots
btrfs subvolume delete /mnt/cache/.snapshots/appdata-backup 2>/dev/null
btrfs subvolume snapshot -r /mnt/cache/appdata /mnt/cache/.snapshots/appdata-backup
User Script: appdata-snap-post
#!/bin/bash
#description=Appdata.Backup POST-run hook: one <container>.tar.zst per top-level appdata folder, from the snapshot, into the run's folder; then delete the snapshot. Called by the plugin with the destination as $2; do not schedule.
SNAP=/mnt/cache/.snapshots/appdata-backup
DEST="${2:-}"                                  # the plugin passes: post-run <destination> <success>
OUT="$DEST/containers"
if [ -z "$DEST" ] || [ ! -d "$DEST" ]; then echo "no destination given; snapshot left in place"; exit 1; fi
if [ ! -d "$SNAP" ]; then echo "no snapshot at $SNAP"; exit 1; fi
mkdir -p "$OUT"
fail=0; n=0
for d in "$SNAP"/*/; do
  name=$(basename "$d")
  if tar -I 'zstd -T0 -3' -cf "$OUT/$name.tar.zst" -C "$SNAP" "$name"; then n=$((n+1)); else fail=1; echo "FAILED: $name"; fi
done
{ echo "per-container archives $(date '+%F %T')"; ls -la "$OUT"; } > "$OUT/MANIFEST.txt"
btrfs subvolume delete "$SNAP"
echo "archived $n folders to $OUT (failures: $fail)"
if [ "$fail" = 1 ]; then
  /usr/local/emhttp/webGui/scripts/notify -e "Appdata backup" -s "per-container archives: failures" -d "see $OUT/MANIFEST.txt" -i alert
  exit 1
fi
One catch: the plugin executes its hook scripts directly, and nothing on the flash drive (FAT32) can be executed directly — the plugin’s own settings page says so. So the plugin is given two one-line wrappers on the pool that call the User Scripts copies with bash. The User Scripts stay the single source of truth: edit them there, the wrappers never change. A third User Script creates the wrappers; run it once.
User Script: appdata-backup-install (Run Script, once)
#!/bin/bash
S=/boot/config/plugins/user.scripts/scripts
W=/mnt/cache/appdata/appdata.backup-scripts
mkdir -p "$W"
for h in pre post; do
  [ -f "$S/appdata-snap-$h/script" ] || { echo "User Script appdata-snap-$h not found — create it first"; exit 1; }
  printf '#!/bin/bash\nexec bash %s "$@"\n' "$S/appdata-snap-$h/script" > "$W/appdata-snap-$h.sh"
  chmod 755 "$W/appdata-snap-$h.sh"
done
ls -la "$W"
echo "wrappers ready: $W/appdata-snap-pre.sh and appdata-snap-post.sh"
User Script: appdata-backup-rehearsal (Run in Background; as long as a real backup)
#!/bin/bash
# dress rehearsal into a scratch folder: snapshot, one archive per container, snapshot deleted
W=/mnt/cache/appdata/appdata.backup-scripts
mkdir -p /mnt/user/backups/ab_test
"$W/appdata-snap-pre.sh" && "$W/appdata-snap-post.sh" post-run /mnt/user/backups/ab_test true
ls -la /mnt/user/backups/ab_test/containers | head
echo "now open one small archive in ab_test/containers with Zip Manager: it must extract to a <name>/ folder. Then delete /mnt/user/backups/ab_test in the file manager."
Expect a few “socket ignored” lines from tar. Unix sockets (a torrent client’s IPC socket, a desktop container’s Wayland socket) cannot be archived and do not need to be; the container recreates them on start.
Why delete-then-create in the pre-run. If a backup is interrupted, the previous snapshot is still there; the pre-run script must not fail on that. Deleting first (errors ignored) makes it idempotent.

Step 4 — the Appdata.Backup settings

Settings → Backup/Restore Appdata. The plugin’s model is volume-based: a container’s volume is “internal” if it sits inside an Appdata source. With the source pointed at the snapshot, no container volume is inside it, so the plugin logs does not have any volume to back up for every container — expected. In this design the plugin itself handles the flash and VM-meta backups, the schedule, retention and container updates; the appdata archives come from the post-run hook. Leave Include extra files/folders empty, or you get a second, giant copy of everything.
SettingValueWhy
Backup typeStop all containers, backup, start allIrrelevant with skip-stopping on; leave the default.
Appdata source(s)/mnt/cache/.snapshots/appdata-backupThe frozen tree, not the live one.
Backup destination/mnt/user/backups/The plugin makes a dated ab_… folder per run; the archives go inside it.
Use compressionYes, multicoreApplies to the plugin’s own archives (flash); harmless.
Backup the flash driveYesLands in the same dated folder, same retention.
Backup VM metaYesCheap.
Advanced → Skip stopping of containers?YesThe point of the exercise. Global default; overridable per container.
Pre-run script/mnt/cache/appdata/appdata.backup-scripts/appdata-snap-pre.shWrapper → takes the snapshot.
Post-run script/mnt/cache/appdata/appdata.backup-scripts/appdata-snap-post.shWrapper → writes the per-container archives, deletes the snapshot.
Include extra files/foldersemptyThe post-run hook does the archiving; a path here would add a second full copy.
ScheduleDaily, any hourIt stops nothing, so it can run any day at any time.
Delete backups older than / keep at least5 / 5The last five days of appdata and flash. Size it to your array.
Update containers after backup?Yes — with exceptionsSee below.
Updates are the one restart left in the run. With stopping off, the only thing that can interrupt a container is the “update containers after backup” step, which pulls new images and recreates whatever has one. For any container that runs long jobs — a media server mid-scan, a library import, a build — click its name in the plugin’s container list and set Update container: No; update it by hand.
If Save appears to do nothing. The settings form validates every path: a script that does not exist yet, or a destination directory that does not exist, silently rejects the whole save and the page reloads with the old values. Run the install script and create the destination first, then save.

Verify

Press Manual backup once while you are watching, then read the Status / Log tab. Times below are from a 155 GB appdata.
What the log shows on a good run
[08:30:02][Main] Executing script '/mnt/cache/appdata/appdata.backup-scripts/appdata-snap-pre.sh' 'pre-run' ...
[08:30:02][Main] Backing up from: /mnt/cache/.snapshots/appdata-backup
[08:31:02][Main] Method: Stop all container before continuing.
[08:31:02][jellyfin] Stopping jellyfin... NOT stopping jellyfin because it should be backed up WITHOUT stopping!
   ... one such line per running container ...
[08:31:02][jellyfin] jellyfin does not have any volume to back up! Skipping. Please consider ignoring this container.
   ... one such warning per container: expected, the source is the snapshot, not the live folder ...
[08:31:02][Main] Backing up the flash drive.
[08:32:49][Main] VM meta backup enabled! Backing up...
[08:32:49][Main] Backup created without issues
[08:32:49][Main] Checking retention...
[08:32:51][Main] Executing script '/mnt/cache/appdata/appdata.backup-scripts/appdata-snap-post.sh' 'post-run' ...
   tar: .../ipc-socket: socket ignored                     (harmless)
   archived 34 folders to /mnt/user/backups/ab_20260919_083002/containers (failures: 0)
   Delete subvolume ... '/mnt/cache/.snapshots/appdata-backup'
[09:5x:xx][Main] Script executed!
Where the time goes. The plugin’s own part — flash and VM meta — takes about two minutes. The post-run hook then compresses every container folder: budget roughly 30 minutes per 100 GB of appdata with multicore zstd. The plugin’s status shows “done” before the hook finishes; the hook’s own output is in the same log, and a failure raises an Unraid notification.
Two things you will see in that log. The “does not have any volume to back up! Skipping. Please consider ignoring this container” warning appears once per container — ignore the advice: a container marked “skip” is also excluded from the update step. And any line reading “'some_volume' does NOT exist! Please check your mappings!” is the plugin telling you that container uses a Docker named volume, which lives in Docker’s own storage, not in appdata — it is not in this backup, and it was never in the stop-everything backup either. Nextcloud AIO is the usual case (its database and files are named volumes; use its built-in Borg backup from the AIO console); some Postgres and database images behave the same. Back those up with their own tooling.

Then look in the file manager: backups/ab_<date>/ holds containers/ with one <name>.tar.zst per appdata folder and a MANIFEST.txt, next to the flash backup zip, the container XML templates and vm_meta.tgz. /mnt/cache/.snapshots/ must be empty between runs, and the Docker page must show every container with the uptime it had before.

Restore

Each archive opens straight to its container folder, so a restore is a file-manager job. Test it once now on a scratch folder, not the day you need it — the same steps, extracting beside the live folder instead of over it.
  1. Stop the container on the Docker page.
  2. Rename the live folder in the file manager: appdata/<name>appdata/<name>.broken. It stays until the restored container is proven.
  3. Extract the archive with Zip Manager: browse to backups/ab_<date>/containers/<name>.tar.zst, choose Extract, and extract it to a scratch location such as backups/restore. The result is a single <name>/ folder with the container’s files, ownership intact.
  4. Move that folder into appdata with the file manager (Move, not Copy: it is on the array and appdata is on the pool, so this is a copy underneath and takes a moment for a big container).
  5. Start the container. When it is healthy, delete <name>.broken.

The dry run is steps 3 and 4 into the scratch folder only, then a look at both folders side by side: the same file count and size, and only the database files (with their -wal/-shm companions) and logs newer in the live one — everything written since the snapshot.

The same restore as one User Script, if you would rather (edit NAME and DATE; container stopped first)
#!/bin/bash
NAME=sonarr
DATE=20260919_083002
mv "/mnt/cache/appdata/$NAME" "/mnt/cache/appdata/$NAME.broken"
tar --zstd -xf "/mnt/user/backups/ab_$DATE/containers/$NAME.tar.zst" -C /mnt/cache/appdata
ls -la "/mnt/cache/appdata/$NAME" | head
echo "start the container; delete $NAME.broken once it is proven"

Gotchas to watch for