← Back to Blog
Hibernate on a btrfs Swapfile: The Three Invisible Walls
Linux & Open SourceJul 05, 2026• 6 min read

Hibernate on a btrfs Swapfile: The Three Invisible Walls

Hibernate on my openSUSE Tumbleweed workstation had never actually worked — every "resume" was a cold boot. It turned out to be three bugs stacked on top of each other: dracut silently dropping resume support from the initrd, a chicken-and-egg resume-device problem, and SELinux denying root. Here is the full walkthrough.

I switched my workstation to openSUSE Tumbleweed specifically because hibernate matters to me. So it was humbling to discover — months later — that hibernate had never actually worked on this install. It looked like it worked: the machine wrote something to disk, powered off, and booted up again. But every “resume” was really a cold boot. My session was quietly thrown away every single time.

What made this bug special is that it wasn’t one bug. It was three, stacked on top of each other, each one hiding behind the previous. Fixing any one of them alone changes nothing, which is exactly why searching for the individual error messages gets you nowhere. This post walks through all three walls in the order I hit them, on a setup that’s increasingly common: a swapfile on btrfs, systemd-boot, and SELinux enforcing.

The setup

My hibernate target is a 34 GiB swapfile on a dedicated btrfs subvolume (@swap, NOCOW), not a swap partition. The kernel command line carries the standard incantation for that arrangement:

resume=UUID=<uuid-of-the-btrfs-filesystem> resume_offset=3591789

Two things about this are worth spelling out, because they’re the root of everything that follows:

  • For a swapfile, resume= points at the filesystem that contains the file — not at a swap device. There is no swap-type UUID to point at.
  • resume_offset is the physical offset of the file’s first extent. On btrfs you must get this from btrfs inspect-internal map-swapfile -r /swap/swapfile — the classic filefrag trick lies to you on btrfs.

My values were correct. I verified them character by character. Hibernate still didn’t work — and the system never said so.

Wall 1: dracut silently builds an initrd that can’t resume

The first clue was buried in the noise of a routine zypper dup, easy to scroll straight past:

dracut[E]: Current resume kernel argument points to an invalid disk

dracut 110 validates the resume= argument while building the initrd. It resolves the UUID, finds a btrfs filesystem instead of a swap device, and concludes the resume configuration is invalid. It’s wrong — this is exactly what a swapfile setup is supposed to look like — but the consequence is severe: dracut omits hibernate-resume support from the initrd entirely.

You can prove it to yourself:

lsinitrd /boot/efi/opensuse-tumbleweed/<kver>/initrd-* | grep -c resume
0

Zero files. No systemd-hibernate-resume, no generator, nothing. The kernel writes a perfectly good hibernation image at power-off, and the next boot sails right past it, because nothing in early userspace ever looks for it. Hibernate degrades into a slow shutdown, with no error at either end.

The fix is a one-line drop-in that overrides dracut’s judgement:

# /etc/dracut.conf.d/99-force-resume.conf
force_add_dracutmodules+=" resume "

Then rebuild the initrd. On openSUSE with systemd-boot that’s:

sudo sdbootutil mkinitrd

Verify with lsinitrd | grep resume again — you want to see systemd-hibernate-resume, its generator, and the resume service in the listing. Note the word force_add: the plain add_dracutmodules variant can still be overridden by the same broken validity check.

Wall 2: “resume= is not populated yet resume_offset= is”

With the initrd fixed, I tried to hibernate and hit wall two:

$ systemctl hibernate
Call to Hibernate failed: Invalid resume config: resume= is not populated yet resume_offset= is

This one is a chicken-and-egg problem. systemd refuses to hibernate unless the currently running kernel knows where the resume device is (/sys/power/resume). That value is normally set at boot by systemd-hibernate-resume-generator — which lives in the initrd — which, on my machine, had never contained it. The current boot came up on the broken initrd, so /sys/power/resume was still 0:0.

You could just reboot onto the fixed initrd. Or you can populate it live and hibernate immediately:

# major:minor of the block device holding the swapfile's filesystem
lsblk -no MAJ:MIN /dev/nvme0n1p2        # -> 259:2
echo 259:2    | sudo tee /sys/power/resume
echo 3591789  | sudo tee /sys/power/resume_offset

This is one-time duct tape. From the next boot onwards the generator inside the repaired initrd does it automatically.

Wall 3: “Access denied” — for root

Third attempt:

$ systemctl hibernate
Call to Hibernate failed: Access denied
$ sudo systemctl hibernate
Call to Hibernate failed: Access denied

Access denied for root is a strong hint that discretionary permissions aren’t the problem — something mandatory is saying no. The audit log had the answer:

type=AVC msg=audit(...): avc:  denied  { search } for  pid=1006
  comm="systemd-logind" name="@swap" dev="nvme0n1p2" ino=256
  scontext=system_u:system_r:systemd_logind_t:s0
  tcontext=system_u:object_r:unlabeled_t:s0 tclass=dir permissive=0

My @swap subvolume was created by hand and never labelled, so it sat there as unlabeled_t. SELinux policy doesn’t let systemd-logind even look inside an unlabeled directory — and logind validates the hibernation target before it will sleep. Hence “Access denied”, with no mention of SELinux anywhere in the user-facing error.

The durable fix is to label the swap subvolume and file with the type the policy already knows about:

sudo semanage fcontext -a -t swapfile_t "/swap(/.*)?"
sudo restorecon -RFv /swap

restorecon reported both the directory and the swapfile flipping from unlabeled_t to swapfile_t, and because the rule is registered with semanage, future relabels keep it.

The payoff

After the third wall fell:

$ systemctl hibernate

…a long write to disk, a fully dead machine, and then the part I’d never seen before — the journal of a real resume:

kernel: PM: hibernation: hibernation entry
kernel: ACPI: PM: Preparing to enter system sleep state S4
kernel: ACPI: PM: Waking up from system sleep state S4
kernel: efivarfs: removing variable HibernateLocation-8cf2644b-...
kernel: PM: hibernation: hibernation exit
systemd-logind: Operation 'hibernate' finished.

The HibernateLocation EFI variable being removed is the tell — systemd only deletes it after successfully restoring an image. Every open window, including the terminal session that ran the command, came back exactly as it was. For what it’s worth, this machine runs the NVIDIA open kernel module with NVreg_PreserveVideoMemoryAllocations=1, and the GPU came back clean too.

The checklist

If hibernate on a btrfs swapfile “does nothing” on a modern systemd distro, check all three — in this order:

  1. Is resume support in your initrd at all? lsinitrd <initrd> | grep resume. If empty: force_add_dracutmodules+=" resume " in /etc/dracut.conf.d/, rebuild, re-check. (dracut 110 wrongly rejects filesystem-UUID resume= arguments.)
  2. Does the running kernel know the resume device? cat /sys/power/resume0:0 means no. Either reboot onto the fixed initrd or populate it manually for this session.
  3. Is SELinux blocking logind? ausearch -m avc -ts recent after a failed attempt. An unlabeled_t swap directory needs semanage fcontext -a -t swapfile_t + restorecon.

And one habit for the future: after every kernel or dracut update, that single lsinitrd | grep -c resume is a two-second check that the whole chain is still intact. Silent failure is the theme of this bug — don’t give it a second season.

By Mahmud Farooque4 views