Hobby projects → Unraid appdata without downtime
Guide · Unraid 7.3 · Btrfs · Appdata.Backup · updated 23 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.

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:

  • A database with journaling off (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.
  • An application that keeps its state in memory and writes it only on a clean exit is captured at its last save, not its current state. Valid, but older than a stop would give.
  • Data split across two places — a database in appdata and its files or WAL in a Docker named volume — cannot be snapshotted atomically. Named volumes are not in this backup at all (they were not in the stop-everything backup either).
  • Every container is paused for the instant of the snapshot, normally well under two seconds. Anything latency-sensitive sees that gap. The scripts release the containers on any error; if you ever find one still paused, docker unpause <name> is the whole fix.
  • Snapshots use pool space while they exist. On a pool that also takes downloads, a backup can run the pool out of space, and a full Btrfs pool can take the container databases down with it. Read the downloads section before anything else.
  • The conversion stops Docker once and needs free pool space equal to the size of appdata for the duration; the “update containers after backup” step still restarts whatever it updates.

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.

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 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.

What a snapshot does and does not promise. It is crash-consistent: databases are captured exactly as they would be after a power cut, which is the state PostgreSQL’s own documentation describes for a file-system snapshot backup of a running server, and the state SQLite is built to roll back on the next open. The pause adds filesystem-level quiescence, the same as a Linux VM with the guest agent installed. Neither is a quiesced dump: a transaction in flight at the snapshot is rolled back on restore. For a database you cannot afford to lose the last few seconds of, add its own hot backup to the pre-run script before the pause — 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.

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 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.
User Script: appdata-snap-pre
#!/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
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
# 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
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.
What the pause does to a container. 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.

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 with 47 containers.
What the plugin’s log shows on a good run
[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)
What the system log shows (Tools → System Log)
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)
Where the time goes, and where the words go. The plugin’s own part — flash and VM meta — takes about three minutes. The post-run hook then compresses every container folder: budget roughly 20–30 minutes per 100 GB of appdata with multicore zstd (23 minutes for this house). The plugin prints DONE! before the post-run hook has even started; the second Script executed! is when the archives exist. The plugin discards whatever the hooks print, which is why both hooks also write their one summary line to the system log with logger — that is where the pause time and the archive count live, and a failure additionally 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 — no snapshot and no paused.list — and the Docker page must show every container running, 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