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.
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.
pg_dump or
sqlite3 … .backup to the pre-run script before the snapshot
line.cache and appdata is at /mnt/cache/appdata;
if yours is named differently, replace /mnt/cache throughout. ZFS
works the same way with zfs snapshot and the
.zfs/snapshot path. XFS cannot snapshot at all; this guide does not
apply to an XFS pool..tar.zst archives this produces,
which is how a restore is done without a shell. Its development has ended, but the
plugin works on 7.x and 7-Zip is a stable dependency.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.
#!/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.
/mnt/user at that moment dies with a permission error. Stop the writers
before you press Apply.#!/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)"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.#!/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"#!/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)."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.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.#!/bin/bash
rm -rf /mnt/cache/appdata.old && echo "appdata.old removed"
btrfs fi df /mnt/cache | grep -i "^Data"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.#!/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#!/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
fibash. 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.#!/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"#!/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."| Setting | Value | Why |
|---|---|---|
| Backup type | Stop all containers, backup, start all | Irrelevant with skip-stopping on; leave the default. |
| Appdata source(s) | /mnt/cache/.snapshots/appdata-backup | The 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 compression | Yes, multicore | Applies to the plugin’s own archives (flash); harmless. |
| Backup the flash drive | Yes | Lands in the same dated folder, same retention. |
| Backup VM meta | Yes | Cheap. |
| Advanced → Skip stopping of containers? | Yes | The point of the exercise. Global default; overridable per container. |
| Pre-run script | /mnt/cache/appdata/appdata.backup-scripts/appdata-snap-pre.sh | Wrapper → takes the snapshot. |
| Post-run script | /mnt/cache/appdata/appdata.backup-scripts/appdata-snap-post.sh | Wrapper → writes the per-container archives, deletes the snapshot. |
| Include extra files/folders | empty | The post-run hook does the archiving; a path here would add a second full copy. |
| Schedule | Daily, any hour | It stops nothing, so it can run any day at any time. |
| Delete backups older than / keep at least | 5 / 5 | The last five days of appdata and flash. Size it to your array. |
| Update containers after backup? | Yes — with exceptions | See below. |
[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!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.
appdata/<name>
→ appdata/<name>.broken. It stays until the restored container is
proven.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.<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.
#!/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"/mnt/user at that moment dies. Stop the writers, then
click.nohup alone does not survive it — setsid nohup …
< /dev/null & does).bash.docker volume ls shows it; Nextcloud AIO is the common one) is
outside this backup entirely — and was outside the old one too. The log names them
(“does NOT exist! Please check your mappings”); give each its own
backup.