📌 Part of Blueprint: Production Self-Hosted Infrastructure → L1: Compute, Storage & Hybrid Network

Why Unraid Hangs When You Stop the Array With a Swapfile — and the Fix

· 8 min read · DevOps unraidswapbtrfszramdockeruser-scripts

I added an 8 GB swapfile to my Unraid server — my cache pool is a btrfs RAID1, and the kernel refuses a swapfile that lives directly on a multi-device btrfs filesystem, so it goes through a loop device, the standard workaround. Swap worked. Memory pressure dropped. Everything was fine… until the day I stopped the array.

The WebUI sat on “Retry unmounting user shares…” forever. The cache pool would not release. Docker was stopped, VMs were shut down, shares were unmounted — and still, umount /mnt/cache failed with target is busy, every five seconds, indefinitely. The only way out was a reboot.

This is the story of finding out why, the trap I fell into on the way (/boot/config/stop does not run when you click Stop), and the two-script fix that survives every stop/start cycle since.

The problem, phrased the way you’d Google it

If you landed here from a search, you’re probably typing one of these:

Same bug. The short version: a swapfile — or its loop device — is held open by the kernel’s swap subsystem, and Unraid’s array-stop sequence has no step that turns swap off before it unmounts disks. The mount is busy because the kernel itself is using it, and it will keep using it until someone runs swapoff. Nobody does.

What I tried, and why it failed

Attempt 1: reboot, stop array again — hung again

Obviously. The swapfile was still configured in /boot/config/go, so every boot recreated the loop device and enabled swap on it. Every stop hit the same wall. At least this confirmed it was deterministic, not a one-off stuck process.

Attempt 2: put a swapoff in /boot/config/stop

This is the classic trap, and the Unraid docs make it look like the right answer. The stop script exists precisely for “run cleanup before disks go away” — and it does run, but only during a full shutdown or reboot.

I checked on my own box (Unraid 7.1.4): /boot/config/stop is invoked by rc.local_shutdown, which is called from rc.6. Clicking Stop in the WebUI goes through a completely different path — emhttpd fires a series of events — and rc.6 is never involved. No shutdown, no stop script, no swapoff. The array stop still hung.

Attempt 3: read what the GUI Stop actually does

This is where the real answer lives. Unraid’s event dispatcher (emhttp_event) runs hook scripts in a documented sequence when the array stops:

stopping → stopping_libvirt → stopping_docker → stopping_svcs
        → unmounting_disks → stopping_array → stopped

Two facts matter:

  1. The unmount happens at unmounting_disks. Any swapoff must fire at stopping_svcs or earlier — one event before the unmount.
  2. These events are exactly what the User Scripts plugin exposes as schedule options (“At Stopping of Array” = stopping_svcs, “At Startup of the array” / “At First Array Start only” = array_started / disks_mounted). No new code needed — the automation surface already exists.

The subtle part: /proc/swaps lies about loop-backed swap

Here’s the gotcha that makes even a “correct-looking” cleanup script fail. My /boot/config/go set swap up like this (the standard btrfs-RAID1 recipe):

# /boot/config/go — the original (broken-on-stop) setup
mkdir -p /mnt/cache
MOUNTPOINT=/mnt/cache
SWAPFILE=${MOUNTPOINT}/swapfile

# Wait for the cache pool to be mounted
while [ ! -f "${SWAPFILE}" ]; do
  sleep 1
done

# Btrfs RAID1 rejects direct swapfiles: loop device + NOCOW
chattr +C "${SWAPFILE}"
LOOPDEV=$(losetup -f)
losetup "${LOOPDEV}" "${SWAPFILE}"
swapon -p 1 "${LOOPDEV}"

Now look at what the kernel reports while that’s active:

$ cat /proc/swaps
Filename                Type        Size      Used    Priority
/dev/loop0              partition   8388604   0       -2

$ swapon --show
NAME       TYPE SIZE USED PRIO
/dev/loop0 partition  8G   0B   -2

(The -2 is the kernel’s default priority for a swap device created without an explicit one — that transcript predates the swapon -p 1 in the recipe above. losetup’s own -p flag is --pass-fd, a loop-encryption leftover, and has nothing to do with priority: passing -p 100 there is a no-op at best and an error at worst.)

The filename is /dev/loop0, not /mnt/cache/swapfile. So a cleanup script like this one:

# WRONG — silently does nothing for loop-backed swap
grep -q '/mnt/cache/swapfile' /proc/swaps && swapoff /mnt/cache/swapfile

…matches nothing, swaps nothing off, and you’re back to the hang. You have to resolve the loop device first — losetup -j <file> does exactly that — then swapoff the loop device, then detach it. The ZRAM Compressed Memory plugin documents this same bug in its source comment, almost word for word: “for loop-backed swap, /proc/swaps lists /dev/loopN, not the image path — a plain grep never matches, so swapoff never ran and the loop device kept the pool busy forever.”

The fix: two User Scripts

User Scripts is already installed on most Unraid boxes. Create two scripts:

Script 1 — “Array Stop Swapoff” — schedule: At Stopping of Array (fires at stopping_svcs, one event before the unmount):

#!/bin/bash
# Release ALL swap, then detach only the loop device backing OUR swapfile.
swapoff -a 2>/dev/null
for l in $(losetup -j /mnt/cache/swapfile 2>/dev/null | awk -F: '{print $1}'); do
  losetup -d "$l" 2>/dev/null
done

losetup -j (“which loop device is attached to this file?”) is the key move — it’s immune to the /proc/swaps naming lie. And detaching only our loop device matters: a box can have other loop devices (mounted ISOs, other images) that must survive an array stop.

Script 2 — “Array Start Swapon” — schedule: At Startup of Array (fires whenever the array starts and disks are mounted):

#!/bin/bash
# Re-create the loop-backed swap after the cache pool mounts (idempotent).
[ -f /mnt/cache/swapfile ] || exit 0
losetup -j /mnt/cache/swapfile | grep -q . && exit 0
LOOP=$(losetup -f)
losetup "$LOOP" /mnt/cache/swapfile && swapon -p 1 "$LOOP"

Why “At Startup of Array” instead of “At First Array Start Only”? Because “First Array Start Only” fires once after a reboot and ignores subsequent GUI stop → start cycles. By removing the old swap block from /boot/config/go entirely and guarding the script with losetup -j idempotency, this User Script becomes the single source of truth — one place that covers both cold boots and GUI stop/start cycles cleanly.

After this: Stop → cache unmounts cleanly on the first try. Start → swap is back. Verified across multiple stop/start cycles and reboots.

What I’d do differently

1. Don’t put swap on a disk that’s already dying. My cache pool is a pair of NVMe drives I’ve since confirmed are silently corrupting data (reads returning all-zero blocks despite clean SMART). An 8 GB swapfile there meant constant write wear on hardware I was about to replace — and it was the very thing pinching my array stops during the migration. Match swap location to disk health, not convenience.

2. Prefer compressed RAM over disk swap when you have the CPU for it. The endgame on this box was the ZRAM Compressed Memory plugin: Tier-1 swap lives in a zstd-compressed RAM-backed block device. Two wins:

One caveat I learned the hard way: the plugin’s auto-size (50% of RAM) gave me a 31.3 GB zram device on a 62 GB host that was already 90% full. That number is an uncompressed ceiling — real RAM cost is capacity ÷ compression ratio, so a full 31.3 GB zram could eat 10–30 GB of the very RAM I was short of. On a nearly-full host, size zram fixed and modest (I went to 8 GB, the old swapfile size, swappiness 150) so it buffers OOM instead of competing with it.

3. Swap delays OOM; it doesn’t cure it. The honest root cause of my memory pressure was VM RAM budgets, not a missing swapfile. Swap bought headroom and kept the host alive during spikes — but the real fix was right-sizing guests.

The result

Array stop went from “hangs forever, forced reboot” to clean unmount on the first attempt, and swap now survives stop/start cycles without manual intervention. Total fix: two shell scripts in a plugin that was already installed. The expensive part was knowing where in the event sequence to put them — and that /proc/swaps won’t tell you the truth about loop-backed swap.

If your Unraid array refuses to stop and you have any swap configured — file, loop, or otherwise — swapoff -a from a root shell will release it immediately, and you’ve confirmed the diagnosis. Then automate it before the next reboot wipes your memory of how.


Run a self-hosted stack that needs this kind of debugging — Unraid, Docker, reverse proxies, NAS migrations? I do this for a living: website design & development is my main work, and self-hosted infrastructure is where I go deep. Reach me on WhatsApp or email.

🛡️ Production Infrastructure & SRE

Dealing with flaky containers, cloud cost bloat, or unexplained outages?

Real production reliability comes from understanding the kernel and the protocol layers, not just restarting containers. I manage 162 containers across 3 hosts running 24/7 client workloads.

Lee Teong Hoe

Lee Teong Hoe · Mr Hoelee

Full-stack developer & DevOps engineer based in Malaysia. I run my own production infrastructure and build web systems for small businesses — this blog is the living record.

Hiring? Profile & stack · Malaysian SME? Website design & development ↗ · All posts