Two kinds of swap: zram for pressure, a 64 GiB btrfs file for hibernation

This laptop had 62 GiB of RAM, a 4 GiB zram device, and no way to hibernate - because zram lives in the memory you are trying to save. Adding a btrfs swapfile meant a dedicated subvolume, a physical offset that can go stale, and three separate ways hourly snapshots could quietly break it.

zram cannot hold a hibernation image. It lives in the memory you are trying to save.

That sentence is the whole reason this took an afternoon rather than ten minutes. zram solves memory pressure; hibernation solves keeping state across a power cut. They both use the word "swap" and they are not the same feature. Making this machine hibernate meant adding real disk swap alongside zram, and then making sure the two never competed for the same job.

What follows is the full setup on one machine: Arch, linux 6.18-lts, KDE Plasma 6.7.4 on Wayland, btrfs with no disk encryption, and hourly Timeshift snapshots — which turn out to be the single most dangerous thing in this story.

The machine

One NVMe, two partitions, five subvolumes:

Device

Size

What

nvme0n1p1

1G

vfat ESP, mounted at /boot

nvme0n1p2

1.8T

btrfs, everything else

@ (subvolid 256)

/ — snapshotted hourly by Timeshift

@home (257)

/home

@log (258)

/var/log

@pkg (259)

/var/cache/pacman/pkg

@swap (312)

/swap — created for this, deliberately outside the snapshot set

Final swap state, which is the design in two lines:

NAME           TYPE      SIZE USED PRIO
/dev/zram0     partition   8G 120K  100     <- everyday paging
/swap/swapfile file       64G   0B   -2     <- hibernation image, and last-resort overflow

The priorities are the point. Everything that pages during normal operation goes to zram, which is fast and doesn't wear the SSD. The 64 GiB file on disk sits at priority -2 and is essentially never touched — it exists to receive a hibernation image.

Part one: making zram worth having

The starting configuration set only compression-algorithm = zstd, which means zram-size fell back to its default of min(ram/2, 4G) — 4 GiB on a 62 GiB machine.

That default is more conservative than it looks, because a zram device's `disksize` is a virtual ceiling, not an allocation. Physical memory is consumed only by data actually stored, after compression. An empty 8 GiB zram device costs essentially nothing; a full one, at zstd's typical 3:1, costs about 2.7 GiB of real memory.

# /etc/systemd/zram-generator.conf
[zram0]
zram-size = 8192
compression-algorithm = zstd

Four sysctl values matter, and they're the standard zram tuning set from the Arch and Fedora documentation rather than anything I invented:

Setting

Default

Set to

Why

vm.swappiness

60

180

Compressed paging is far cheaper than disk I/O, so lean hard toward evicting anonymous pages and keeping page cache. The ceiling is 200 since kernel 5.8

vm.watermark_boost_factor

15000

0

The mechanism exists to fight fragmentation for disk swap; on zram it only triggers surplus aggressive reclaim

vm.watermark_scale_factor

10

125

Wake kswapd earlier — 1.25% of the zone, about 780M of headroom here — instead of hitting direct reclaim and stalling

vm.page-cluster

3

0

Swap-in readahead reads 8 pages at a time. zram is memory: there is no seek to amortise, so readahead just burns decompression CPU

MGLRU is enabled here (/sys/kernel/mm/lru_gen/enabled reads 0x0007), which pairs well with a swappiness that high.

One trap when changing zram-size: restarting the setup service does nothing, because the device is busy — it's currently being used as swap. The order that works:

sudo systemctl daemon-reload            # the generator only re-runs on daemon-reload
sudo systemctl stop dev-zram0.swap      # this is the step that actually does swapoff
sudo systemctl restart systemd-zram-setup@zram0.service
sudo systemctl start dev-zram0.swap

Check swapon --show first and make sure USED is 0, otherwise you'll be waiting on pages being read back into memory.

Part two: somewhere to put the image

Three options, and the reasoning is mostly about risk:

A separate swap partition is the cleanest technically — no resume_offset to maintain, and immune to anything btrfs does to file extents. But p2 occupies all remaining space on this disk, so getting one means shrinking a live btrfs filesystem and re-partitioning. That's more risk than the problem deserves.

Hybrid sleep is not an alternative at all. It writes an image on every suspend *and* keeps the RAM powered, so it solves "the power might cut while suspended" — not "I want to use no power overnight". More on this below, because the distinction matters when picking a lid action.

A btrfs swapfile is what I used. btrfs-progs has had first-class support since 6.1, it's fully reversible, and it doesn't touch the partition table. The price is maintaining a resume_offset, which is the rest of this article.

Size: 64 GiB for 62 GiB of RAM. Not because it's needed — the actual image turned out to be 5.4 GiB — but because at that size hibernation always succeeds and I never have to think about image_size. It costs 3.5% of the SSD. 32 GiB would also work, since the kernel compresses the image, but hibernation starts failing outright once memory use passes roughly 60%.

The subvolume that keeps Timeshift away

This is the part that will silently ruin your day:

TOP=$(mktemp -d)
sudo mount -o subvol=/ /dev/nvme0n1p2 "$TOP"
sudo btrfs subvolume create "$TOP/@swap"       # -> subvolid 312
sudo umount "$TOP" && rmdir "$TOP"
sudo mkdir -p /swap

The swapfile must not live anywhere Timeshift snapshots, and both reasons are hard requirements:

  1. A snapshotted swapfile stops working. Once a snapshot exists, the file is no longer exclusively owned by the live subvolume, and swapon rejects it complaining about holes. Timeshift snapshots @ hourly here. A swapfile under /swap inside @ would break within the hour.
  2. Rolling back must not move it. Restoring a Timeshift snapshot replaces @. Anything inside it goes back in time, including the physical location of a swapfile — which would invalidate the offset the bootloader was told about.

@swap sits at the top level, alongside @ and @home, so Timeshift never sees it.

One cosmetic surprise worth explaining: fstab mounts /swap with only rw,noatime,subvol=/@swap, but findmnt reports compress=zstd:3 on it anyway. Compression in btrfs is a filesystem-wide mount option rather than a per-subvolume one, so /swap inherits whatever the first mount of that filesystem set. It's harmless here — the swapfile is nodatacow, and nodatacow files are never compressed regardless of the mount option.

Creating the file

btrfs swapfiles have hard requirements: nodatacow, no compression, fully preallocated with no holes. One command does all of it, and you should not hand-roll it out of chattr +C and fallocate:

sudo btrfs filesystem mkswapfile -s 64g -U clear /swap/swapfile
sudo chmod 600 /swap/swapfile
$ lsattr /swap/swapfile
---------------C------ /swap/swapfile      # C = nodatacow

$ sudo filefrag /swap/swapfile
/swap/swapfile: 1 extent found              # single extent, no holes

swapon succeeding is itself the kernel's validation — a file with holes, or without nodatacow, gets rejected.

fstab, and the division of labour

# Hibernation: dedicated @swap subvolume, outside Timeshift's snapshot set
UUID=b23710ad-…-b7fc71e7815b  /swap  btrfs  rw,noatime,subvol=/@swap  0 0

# Disk swap at lower priority than zram (100): everyday paging still goes to
# zram; this file is here to hold a hibernation image.
/swap/swapfile  none  swap  defaults,pri=-2  0 0

pri=-2 is where the two halves of this article meet. Both are swap; only one of them is meant to be used.

systemd's fstab generator picks both up at boot with nothing to enable:

swap.mount            loaded active mounted  /swap
swap-swapfile.swap    loaded active active   /swap/swapfile

resume_offset, and why btrfs needs one

Resume happens before any filesystem is mounted. At that moment there is no btrfs driver available to answer "where does the data of /swap/swapfile actually live". So the kernel doesn't get a path. It gets raw coordinates:

  • resume= — which block device
  • resume_offset= — how many page-sized units into that device the image starts

btrfs-progs computes it, and validates the file meets every swapfile requirement while it's in there:

$ sudo btrfs inspect-internal map-swapfile -r /swap/swapfile
21767424

That number describes where the file's data physically sits. It is not a property of the path. Which is why it can go stale — see maintenance, below.

# /etc/default/grub, appended to GRUB_CMDLINE_LINUX so every entry gets it
resume=UUID=b23710ad-9931-41b0-8996-b7fc71e7815b resume_offset=21767424
# /etc/mkinitcpio.conf
HOOKS=(base udev autodetect microcode modconf kms keyboard keymap consolefont block filesystems resume fsck)
                                                                                        ^^^^^^

The resume hook only needs to come after udev and block, so that device nodes exist. With no LVM and no encryption, after filesystems is fine.

sudo mkinitcpio -P                        # all three installed kernels
sudo grub-mkconfig -o /boot/grub/grub.cfg

There are two resume paths, and only one of them rots

Worth knowing, because it explains why a stale offset often causes no visible symptom:

  1. The EFI variable `HibernateLocation` is the one that actually gets used. With systemd 261 (anything ≥ 255), the resume hook's build stage pulls systemd-hibernate-resume into the initramfs. systemd writes the device and offset it computed *at the moment of hibernating* into that variable, and deletes it after resuming. Because it's computed fresh every time, it automatically follows the swapfile if it moves.
  2. The kernel cmdline resume= / resume_offset= is the backup. It's a hardcoded number. It can go stale, and it will do so quietly.

grub-btrfs snapshot entries: add noresume

This one is a genuine filesystem-corruption hazard, not just an inconvenience.

grub-btrfs generates boot entries for every snapshot, and those entries inherit the kernel cmdline — including resume=. All 36 of them here. Since resume happens before the root filesystem is mounted, booting a snapshot entry while an unresumed hibernation image exists means resume silently wins: the kernel and the rootflags=…subvol=snapshot you picked from the menu are both discarded, and you land back in the pre-hibernation system.

The dangerous sequence is what comes next. Boot something else (a snapshot, a live USB) while an image is pending, write to the disk, then resume that image later — and you get a kernel holding stale in-memory filesystem state, page cache and metadata included, writing onto a disk that has moved underneath it.

# /etc/default/grub-btrfs/config
GRUB_BTRFS_SNAPSHOT_KERNEL_PARAMETERS="noresume"

After grub-mkconfig, all 36 snapshot entries carry noresume and the normal boot entries are untouched (zero occurrences elsewhere in grub.cfg). The semantics are right too: if you're booting a snapshot, something is broken and you're repairing it — restoring a stale memory image is the last thing you want.

The override is reliable because it's the first thing the hook checks, before it ever reads resume=:

noresume="$(getarg noresume)"
if [ -n "$noresume" ]; then
    return 0
fi

Which kind of sleep, and who decides

I wrote a logind config for the lid:

# /etc/systemd/logind.conf.d/10-lid-hibernate.conf
[Login]
HandleLidSwitch=suspend-then-hibernate
HandleLidSwitchExternalPower=suspend

It has no effect inside a Plasma session. PowerDevil takes the lid switch in block mode:

PowerDevil  1000 jin  org_kde_powerde  handle-power-key:handle-suspend-key:
                      handle-hibernate-key:handle-lid-switch   KDE handles power events   block

The file still earns its place as a fallback — it applies on TTYs and at the greeter, outside the Plasma session.

Three modes, and the one most people pick for the wrong reason

KDE's dropdown offers three options and, notably, plain "hibernate" is not one of them. The difference is whether RAM keeps drawing power, and when the image gets written:

Suspend

Hybrid sleep

Suspend, then hibernate

Kernel action

statemem (deep / ACPI S3 here)

write image + disksuspend

mem first, then diskplatform (S4)

RAM powered

yes

yes

yes, then fully off

Image written

never

every time

only at the transition

Resume time

1–2s

1–2s (from RAM; the disk copy is insurance)

1–2s before, ~47s after

Power draw

1–2% per hour

same as suspend

zero after transition

Sudden power loss

state lost

recovered from disk

lost before, safe after

SSD writes

none

a full memory image per sleep

only on a real transition

Hybrid sleep and suspend-then-hibernate solve different problems, and hybrid sleep does not save power. It's insurance: whatever happens, you don't lose state. But S3 keeps draining the battery exactly as it would otherwise, and the disk copy is only ever read if power is actually lost. You also pay for it every single time — a 5.4 GiB image here, written even if you reopen the lid five minutes later. Five lid closes a day is 27 GiB of pointless writes.

Suspend-then-hibernate is the one that actually saves power: suspend first for the fast resume, then transition to a real hibernate once the battery genuinely gets low, and draw nothing after that. The cost is that state lives only in RAM until the transition, so an unexpected power loss in that window — pulled battery, kernel panic — loses it.

Hybrid sleep is the better trade on a desktop, where you don't care about battery but do care about mains flicker, or on a laptop whose battery has aged into reporting nonsense (jumping from 20% straight to 0 breaks the prediction that suspend-then-hibernate depends on).

None of the three is shutting down. Shutdown deliberately discards memory state.

The configuration, and three traps

# ~/.config/powerdevilrc
[AC][SuspendAndShutdown]
AutoSuspendAction=0           # no SleepMode key -> defaults to suspend; on mains there's no reason to hibernate
[Battery][SuspendAndShutdown]
SleepMode=3                   # 3 = suspend, then hibernate
[LowBattery][SuspendAndShutdown]
SleepMode=3
  1. `SleepMode` is per power profile. The groups are [AC|Battery|LowBattery][SuspendAndShutdown], *not* [General]. Put it in [General] and nothing happens — all three KCM tabs still say "Suspend".
  2. The values are integers, not enum names. 1 = suspend, 2 = hybrid sleep, 3 = suspend then hibernate. Writing the string SuspendThenHibernate is silently ignored — nothing in the log. "PowerDevil didn't complain" proves nothing.
  3. Close and reopen System Settings after editing, or it won't re-read the file.

LidAction, PowerButtonAction and AutoSuspendAction are integer enums too, and their mappings don't appear in strings output because the KCM builds the model at runtime. Don't guess these — a wrong guess can land on "Shut down", which means closing the lid now discards your session. Click the option in the GUI once and read the file back; one round trip gets you the authoritative number.

Note that what changed here is SleepMode, not LidAction. The lid already does "sleep" — redefining what sleep *means* avoids touching the dangerous key at all.

Deliberately not setting HibernateDelaySec

I set HibernateDelaySec=90min initially and then deleted it. /etc/systemd/sleep.conf.d/ is empty on purpose.

man 5 systemd-sleep.conf is explicit: once that key is set, the rule becomes "low battery or the fixed delay, whichever comes first". A fixed 90 minutes almost always comes first, which throws away the battery awareness entirely — and if the battery was already low when you closed the lid, it may not survive to the deadline.

Left unset, the transition is battery-driven, and this machine has the ideal hardware for it:

$ ls /sys/class/power_supply/BAT0/alarm      # exists -> ACPI _BTP supported

systemd sets a hardware trip point on the battery and gets woken when charge actually drops, with no polling at all. Without _BTP it falls back to periodically measuring the discharge rate, and finally to SuspendEstimationSec.

Verifying it works

The non-destructive readiness check, much less tedious than just trying it. Both of these return na before a swapfile exists:

busctl call org.freedesktop.login1 /org/freedesktop/login1 \
  org.freedesktop.login1.Manager CanHibernate               # -> s "yes"
busctl call org.freedesktop.login1 /org/freedesktop/login1 \
  org.freedesktop.login1.Manager CanSuspendThenHibernate    # -> s "yes"

CanHibernate is a D-Bus method, not a property — busctl get-property will tell you the interface or property is unknown.

Measured on this machine: an image of 1412585 pages, about 5.4 GiB, preallocated at 6.4 GiB and written at 3546 MB/s; 46.8 seconds from powered off to a usable desktop, firmware POST included. Swapfile usage back to zero after resume.

Proving hibernation actually happened is less obvious than it sounds, because the logs make it look like it didn't:

python3 -c "import time;print(time.clock_gettime(time.CLOCK_BOOTTIME)-time.clock_gettime(time.CLOCK_MONOTONIC))"
# -> 46.79   <- time spent powered off

BOOTTIME counts time spent hibernating; MONOTONIC doesn't. The difference is exactly how long the machine was off. Note that both /proc/uptime and uptime report BOOTTIME, so neither tells you anything on its own.

Three log entries that look like failures and aren't

Timestamps appear to jump backwards. The same two lines are 50.27s apart in wall clock and 3.48s apart in monotonic time:

hibernation entry   wall 02:57:49.82   mono 185.71
Creating image:     wall 02:58:40.09   mono 189.19

The missing 46.8s is the power-off period. It shows up on the lines *before* hibernation because kernel messages carry monotonic timestamps natively — the wall clock is stamped on by journald when it reads them from /dev/kmsg, and journald was frozen for the whole hibernation. That last batch of pre-snapshot messages wasn't collected until after resume, so they got stamped with the post-resume clock.

There's no boot log from the resumed kernel; the log just continues. The dmesg buffer restored from the image is the state at snapshot time. Everything the original kernel logged after that — writing the image, powering off — was never in the image, and the resuming kernel's own log is replaced when the image takes over. So you see Creating image followed immediately by Restoring platform NVS memory.

Every normal cold boot prints `PM: Image not found (code -22)`. This is harmless. -22 is -EINVAL: the kernel followed resume=, found the device, read the swap header, and found no image signature. After a normal shutdown there is no image, so this is the only correct outcome — and it's actually evidence the whole chain works. A broken configuration produces resume: hibernation device not found from the initramfs, or nothing at all.

It's printed with pr_err() at KERN_ERR, which is exactly at this machine's loglevel=3 threshold, so it lands on the boot splash. That's an unavoidable side effect of a working hibernation setup: suppressing it means lowering loglevel (hiding real errors too) or dropping resume= from the cmdline (giving up the backup path).

This one is also normal — the service that cleans up a leftover EFI variable, skipped because there isn't one:

Clear Stale Hibernate Storage Info skipped, unmet condition check
ConditionPathExists=…/HibernateLocation-8cf2644b-…

Maintenance: when the offset goes stale

Operations that move the file's physical location: deleting and recreating it; mv-ing it to another subvolume or filesystem (that's a copy); running btrfs filesystem defragment on it (defrag rewrites to new extents); running btrfs balance (which relocates whole block groups).

While the swapfile is active, btrfs refuses to relocate it — balance errors out on a block group containing an active swapfile. The real risk window is when it *isn't* swapped on: after a manual swapoff, or from a live USB or rescue environment.

The consequences are milder than you'd fear. Hibernating still works, because writing the image goes through the mounted filesystem and swapon and doesn't use that number at all. Resume is what breaks: the kernel reads a location with no image signature, prints PM: Image not found (-22), and cold boots normally. You lose the hibernated session; the filesystem is not damaged. And since the EFI variable is computed fresh, a stale offset usually just means the backup path has quietly stopped existing.

sudo btrfs inspect-internal map-swapfile -r /swap/swapfile   # actual
grep -o 'resume_offset=[0-9]*' /etc/default/grub             # configured

# if they disagree:
sudo sed -i "s/resume_offset=[0-9]*/resume_offset=$(sudo btrfs inspect-internal map-swapfile -r /swap/swapfile)/" /etc/default/grub
sudo grub-mkconfig -o /boot/grub/grub.cfg

There's a second failure mode that isn't about the offset at all: a Timeshift rollback restores `/etc/default/grub`, so if the snapshot predates this whole setup, the resume parameters vanish entirely. The swapfile itself is fine — it's in @swap — so you just add the parameters back and regenerate.

Checking it at every boot

Because that failure is silent, a oneshot service checks at boot and warns without changing anything — rewriting GRUB automatically at boot is too aggressive. It catches five cases: the actual offset disagreeing with grub.cfg; resume_offset missing from grub.cfg entirely; entries disagreeing with each other; resume= pointing at a UUID that isn't the swapfile's filesystem; and /etc/default/grub being out of sync with the generated grub.cfg.

Failures go to the journal at err level and put the unit into failed, so both journalctl -b -p err and systemctl --failed surface them, and the message carries a copy-pasteable fix. All four failure paths were tested against a fake grub.cfg. On a healthy boot it logs one info line.

Its limitation: boot time only. If you swapoff, run a balance, and hibernate without rebooting, this won't catch it — though in that case the EFI variable is computed fresh and resume will probably work anyway.

#!/usr/bin/env bash
# /usr/local/bin/check-resume-offset  (755, root:root)
set -uo pipefail

: "${SWAPFILE:=/swap/swapfile}"
: "${GRUB_CFG:=/boot/grub/grub.cfg}"
: "${GRUB_DEFAULT:=/etc/default/grub}"

# journald parses the <N> prefix as a priority (SyslogLevelPrefix is on by default)
err()  { echo "<3>$*"; }
warn() { echo "<4>$*"; }
info() { echo "<6>$*"; }

rc=0

if [[ ! -e $SWAPFILE ]]; then
    err "$SWAPFILE does not exist — hibernation is unavailable."
    exit 1
fi

actual=$(btrfs inspect-internal map-swapfile -r "$SWAPFILE" 2>&1)
if [[ ! $actual =~ ^[0-9]+$ ]]; then
    err "Could not read the physical offset of $SWAPFILE; it may no longer qualify as a swapfile: $actual"
    exit 1
fi

mapfile -t cfg_offsets < <(grep -o 'resume_offset=[0-9]*' "$GRUB_CFG" 2>/dev/null | cut -d= -f2 | sort -u)

if (( ${#cfg_offsets[@]} == 0 )); then
    err "No resume_offset in $GRUB_CFG — resume parameters are gone (Timeshift rollback?)."
    err "Fix: add resume=UUID=$(findmnt -no UUID --target "$SWAPFILE") resume_offset=$actual to GRUB_CMDLINE_LINUX in $GRUB_DEFAULT, then grub-mkconfig -o $GRUB_CFG"
    exit 1
fi

if (( ${#cfg_offsets[@]} > 1 )); then
    err "Conflicting resume_offset values in $GRUB_CFG: ${cfg_offsets[*]} — regenerate it."
    rc=1
elif [[ ${cfg_offsets[0]} != "$actual" ]]; then
    err "resume_offset is stale: GRUB says ${cfg_offsets[0]}, $SWAPFILE is actually at $actual."
    err "The next hibernation will not resume (unless the HibernateLocation EFI variable covers it)."
    err "Fix: sudo sed -i 's/resume_offset=[0-9]*/resume_offset=$actual/' $GRUB_DEFAULT && sudo grub-mkconfig -o $GRUB_CFG"
    rc=1
fi

fs_uuid=$(findmnt -no UUID --target "$SWAPFILE" 2>/dev/null)
mapfile -t cfg_uuids < <(grep -o 'resume=UUID=[0-9a-fA-F-]*' "$GRUB_CFG" 2>/dev/null | cut -d= -f3 | sort -u)
if (( ${#cfg_uuids[@]} == 1 )) && [[ -n $fs_uuid && ${cfg_uuids[0]} != "$fs_uuid" ]]; then
    err "resume= points at UUID ${cfg_uuids[0]}, which is not the filesystem holding $SWAPFILE ($fs_uuid)."
    rc=1
fi

def_offset=$(grep -o 'resume_offset=[0-9]*' "$GRUB_DEFAULT" 2>/dev/null | cut -d= -f2 | head -1)
if [[ -n ${def_offset:-} && ${#cfg_offsets[@]} -eq 1 && $def_offset != "${cfg_offsets[0]}" ]]; then
    warn "$GRUB_DEFAULT says $def_offset but $GRUB_CFG says ${cfg_offsets[0]} — edited without regenerating? Run grub-mkconfig -o $GRUB_CFG"
    rc=1
fi

(( rc == 0 )) && info "resume_offset OK: $SWAPFILE is at physical offset $actual, matching GRUB."
exit $rc
# /etc/systemd/system/check-resume-offset.service
[Unit]
Description=Verify resume_offset matches the swapfile's physical location
Documentation=man:btrfs-inspect-internal(8)
After=local-fs.target swap.target
ConditionPathExists=/swap/swapfile
ConditionPathExists=/boot/grub/grub.cfg

[Service]
Type=oneshot
ExecStart=/usr/local/bin/check-resume-offset
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

One thing I broke

systemctl restart systemd-logind tears down the entire Plasma session. I did this while trying to make the logind lid config take effect, and it cost me a login. Worse, plasmalogin survived but its PAM ↔ logind link went stale, so every subsequent login attempt failed:

plasmalogin-helper[…]: [PAM] Asked to close the session but it wasn't previously open
plasmalogin[…]: Auth: plasmalogin-helper exited with 255

To reload logind.conf.d/ safely, use sudo systemctl kill -s SIGHUP systemd-logind, or just wait for the next reboot. To recover after doing it the wrong way, sudo systemctl restart plasmalogin rebuilds the greeter — you're back once the log shows Logind interface found and Greeter session started successfully. Anything that was running in the original session is gone. A TTY login session isn't affected by restarting plasmalogin, which makes it the safe place to stand while fixing this.

One more small one from the same afternoon: /boot is the ESP, mounted with dmask=0077, so a non-root shell can't even list it. ls /boot/initramfs-*.img fails with "no matches found" — a glob expansion failure, not a permission error — which reads exactly like the files don't exist. They do. Use sudo.

No comments yet