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.
Read this before you start — what this scheme does and does not guarantee. A snapshot backup is crash-consistent: every container is captured the way it would be after a power cut, not after a clean shutdown. Databases with journaling on (SQLite in its default or WAL mode, PostgreSQL, MariaDB) are built to recover from exactly that state and do, on the next start. The guarantee has edges, and they are yours to check for your own containers:
journal_mode=OFF or
MEMORY, synchronous=OFF — rare in server software,
but it exists) has nothing to recover from and can come back corrupt. A graceful stop is
the only safe backup for it; set that container to be stopped in the plugin.docker unpause
<name> is the whole fix.Test a restore on a scratch folder before you rely on any of this, and keep your previous backup running until you have restored a container from this one. These are your containers and your data; nothing here has been tested against them.
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 containers are paused for the instant of the snapshot. This is what
Proxmox does when it backs up an LXC container in snapshot mode: suspend the
container, snapshot its storage, resume it, archive the snapshot. Docker has the same
control, docker pause, which freezes every process in the container
between syscalls; so at the moment the snapshot commits nothing is mid-write. The pause
lasts as long as the snapshot takes — normally well under two seconds for every
container together — and the containers come back whether the snapshot succeeded
or not. (A VM-style fsfreeze of the pool is the wrong tool here: a
Btrfs snapshot cannot be created on a frozen Btrfs, and freezing the pool would freeze
docker.img with it.)
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 pauses the running containers, takes the snapshot and unpauses them; 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 stops a running container.
docker exec <name> pg_dumpall -U postgres > /mnt/cache/appdata/<name>/dump.sql
for Postgres, sqlite3 <db> ".backup <db>.bak" for SQLite —
so the dump travels inside the snapshot. That is the Linux equivalent of what VSS gives a
Windows guest, and it is per application.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 pauses every running container, takes a
read-only snapshot beside appdata (never inside it, or every snapshot would contain
the previous one), and unpauses them; it records what it paused in a list file and a
trap releases them however the script exits. Post-run first releases anything that list
still names (belt and braces, for a pre hook killed outright), then 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: pause running containers, read-only Btrfs snapshot of appdata beside it, unpause. Called by the plugin; do not schedule.
SNAP=/mnt/cache/.snapshots/appdata-backup
LIST=/mnt/cache/.snapshots/paused.list
mkdir -p /mnt/cache/.snapshots
btrfs subvolume delete "$SNAP" 2>/dev/null
# only containers that are running now; one you paused on purpose stays as it is
RUNNING=$(docker ps -q --filter status=running)
echo "$RUNNING" > "$LIST"
unpause() { [ -n "$RUNNING" ] && docker unpause $RUNNING >/dev/null 2>&1; rm -f "$LIST"; }
trap unpause EXIT # whatever happens below, they come back
t0=$(date +%s%3N)
[ -n "$RUNNING" ] && docker pause $RUNNING >/dev/null
sync -f /mnt/cache
btrfs subvolume snapshot -r /mnt/cache/appdata "$SNAP"
rc=$?
unpause; trap - EXIT
msg="paused $(echo $RUNNING | wc -w) containers for $(( $(date +%s%3N) - t0 )) ms; snapshot rc=$rc"
echo "$msg"; logger -t appdata-snap-pre "$msg" # the plugin discards hook output: the system log keeps it
exit $rc#!/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
# belt and braces: if the pre hook was ever killed mid-pause, release exactly what it paused
LIST=/mnt/cache/.snapshots/paused.list
[ -s "$LIST" ] && docker unpause $(cat "$LIST") >/dev/null 2>&1; rm -f "$LIST"
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)"
logger -t appdata-snap-post "archived $n folders to $OUT (failures: $fail)" # the plugin discards hook output: the system log keeps it
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."docker pause
is the cgroup freezer: processes stop where they are and continue where they were, with
no signal delivered and nothing exited. Open TCP connections wait; a media stream buffers
for a second; a healthcheck due during the pause runs after it. Only containers that are
running when the hook starts are touched — one you paused yourself is left paused
— and the list file names exactly those, so the release never touches anything
else.| 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:01][Main] Executing script '/mnt/cache/appdata/appdata.backup-scripts/appdata-snap-pre.sh' 'pre-run' ...
[08:30:04][Main] Script executed! (pause + snapshot + unpause: three seconds)
[08:30:04][Main] Backing up from: /mnt/cache/.snapshots/appdata-backup
[08:31:06][Main] Auto-Update for 'jellyfin' is enabled but no update is available.
... one such line per container ...
[08:31:06][jellyfin] Stopping jellyfin... NOT stopping jellyfin because it should be backed up WITHOUT stopping!
... one such line per running container ...
[08:31:06][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:06][Main] Backing up the flash drive.
[08:33:31][Main] VM meta backup enabled! Backing up...
[08:33:31][Main] Checking retention...
[08:33:31][Main] Delete old backup: /mnt/user/backups/ab_20260916_083001
[08:33:31][Main] DONE! Thanks for using this plugin and have a safe day ;)
[08:33:32][Main] Executing script '/mnt/cache/appdata/appdata.backup-scripts/appdata-snap-post.sh' 'post-run' ...
[08:56:15][Main] Script executed! (the archives are finished here, not at DONE)appdata-snap-pre: paused 40 containers for <n> ms; snapshot rc=0 (a few hundred to a couple of thousand ms)
appdata-snap-post: archived 47 folders to /mnt/user/backups/ab_20260924_083001/containers (failures: 0)logger — that is where the pause time and the archive count
live, and a failure additionally raises an Unraid notification.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 — no snapshot and
no paused.list — and the Docker page must show every container
running, 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 unpause
<name> fixes it, and the run’s log says why.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.