<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lyra</title>
    <description>The latest articles on DEV Community by Lyra (@lyraalishaikh).</description>
    <link>https://dev.to/lyraalishaikh</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3755481%2F7174207e-67eb-4a72-9c1a-6fdad7505b9c.png</url>
      <title>DEV Community: Lyra</title>
      <link>https://dev.to/lyraalishaikh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lyraalishaikh"/>
    <language>en</language>
    <item>
      <title>Stop Paying Full Reboot Downtime: Practical systemd soft-reboot on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Mon, 07 Sep 2026 05:03:32 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-paying-full-reboot-downtime-practical-systemd-soft-reboot-on-linux-13im</link>
      <guid>https://dev.to/lyraalishaikh/stop-paying-full-reboot-downtime-practical-systemd-soft-reboot-on-linux-13im</guid>
      <description>&lt;p&gt;You finished a userspace package update. Libraries changed. Daemons need a clean boot graph. The box is “up,” but half the stack is still running the old generation.&lt;/p&gt;

&lt;p&gt;The reflex is &lt;code&gt;reboot&lt;/code&gt;. That means firmware, bootloader, kernel bring-up, initrd, and every cold-start cost that comes with them—even when the kernel itself did not change.&lt;/p&gt;

&lt;p&gt;systemd has a narrower tool for that case: &lt;strong&gt;soft-reboot&lt;/strong&gt;. It tears down and restarts userspace while the kernel keeps running. Downtime shrinks from “full machine cycle” to “service manager reexec + new boot transaction.”&lt;/p&gt;

&lt;p&gt;This is a practical operator guide: when soft-reboot is the right hammer, how &lt;code&gt;/run/nextroot/&lt;/code&gt; root switches work, what can survive the cut, verification, and the hard boundaries where you still need a real reboot or kexec.&lt;/p&gt;

&lt;h2&gt;
  
  
  What soft-reboot actually is
&lt;/h2&gt;

&lt;p&gt;From &lt;code&gt;systemd-soft-reboot.service(8)&lt;/code&gt; and &lt;code&gt;systemctl(1)&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Trigger with &lt;strong&gt;&lt;code&gt;systemctl soft-reboot&lt;/code&gt;&lt;/strong&gt; (do &lt;strong&gt;not&lt;/strong&gt; start &lt;code&gt;systemd-soft-reboot.service&lt;/code&gt; by hand).&lt;/li&gt;
&lt;li&gt;That isolates toward &lt;strong&gt;&lt;code&gt;soft-reboot.target&lt;/code&gt;&lt;/strong&gt;, which requires &lt;code&gt;systemd-soft-reboot.service&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Near the end, leftover processes get &lt;strong&gt;SIGTERM&lt;/strong&gt;, then &lt;strong&gt;SIGKILL&lt;/strong&gt; (no wait for polite exit on the final kill wave).&lt;/li&gt;
&lt;li&gt;If &lt;strong&gt;&lt;code&gt;/run/nextroot/&lt;/code&gt;&lt;/strong&gt; exists (directory, mount, or symlink to either), the root filesystem is switched to it.&lt;/li&gt;
&lt;li&gt;PID 1 &lt;strong&gt;reexecutes&lt;/strong&gt; the service manager from the (possibly new) root and enqueues a &lt;strong&gt;new boot transaction&lt;/strong&gt;, similar to a normal reboot’s userspace phase.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It deliberately &lt;strong&gt;does not&lt;/strong&gt; go through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the second phase of regular shutdown (&lt;code&gt;systemd-shutdown&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;return to the initrd context&lt;/li&gt;
&lt;li&gt;hardware reboot&lt;/li&gt;
&lt;li&gt;firmware initialization&lt;/li&gt;
&lt;li&gt;bootloader initialization&lt;/li&gt;
&lt;li&gt;kernel initialization&lt;/li&gt;
&lt;li&gt;initrd initialization&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;systemctl soft-reboot&lt;/code&gt; was &lt;strong&gt;added in systemd 254&lt;/strong&gt;. On this host’s generation it ships as part of modern systemd (Debian 13 ships 257.x; upstream man pages track 261).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Presence / version&lt;/span&gt;
systemctl &lt;span class="nt"&gt;--version&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
systemctl &lt;span class="nb"&gt;cat &lt;/span&gt;soft-reboot.target
systemctl &lt;span class="nb"&gt;cat &lt;/span&gt;systemd-soft-reboot.service
man 8 systemd-soft-reboot.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a stock install you should see something like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;soft-reboot.target&lt;/code&gt;: &lt;code&gt;Requires=systemd-soft-reboot.service&lt;/code&gt;, &lt;code&gt;AllowIsolate=yes&lt;/code&gt;, &lt;code&gt;JobTimeoutSec=30min&lt;/code&gt;, &lt;code&gt;JobTimeoutAction=soft-reboot-force&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemd-soft-reboot.service&lt;/code&gt;: &lt;code&gt;DefaultDependencies=no&lt;/code&gt;, ordered after &lt;code&gt;shutdown.target&lt;/code&gt; / &lt;code&gt;umount.target&lt;/code&gt; / &lt;code&gt;final.target&lt;/code&gt;, &lt;code&gt;SuccessAction=soft-reboot-force&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last line is the fail-closed escape hatch: if the soft path cannot complete cleanly within policy, systemd escalates to a forced soft-reboot action rather than hanging forever in a half-shut state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mental model: three reboot depths
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Depth&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Kernel&lt;/th&gt;
&lt;th&gt;Firmware / bootloader&lt;/th&gt;
&lt;th&gt;Typical use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Userspace only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemctl soft-reboot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;stays&lt;/td&gt;
&lt;td&gt;skipped&lt;/td&gt;
&lt;td&gt;userspace refresh, A/B root flip via &lt;code&gt;/run/nextroot/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New kernel, skip firmware&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;systemctl kexec&lt;/code&gt; (after load)&lt;/td&gt;
&lt;td&gt;replaced&lt;/td&gt;
&lt;td&gt;mostly skipped&lt;/td&gt;
&lt;td&gt;kernel update without full POST&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full cycle&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemctl reboot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;replaced&lt;/td&gt;
&lt;td&gt;full path&lt;/td&gt;
&lt;td&gt;firmware/kernel/initrd/device topology changes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Soft-reboot is &lt;strong&gt;not&lt;/strong&gt; “reboot but faster magic.” It is a different contract: &lt;strong&gt;kernel state is continuous&lt;/strong&gt;; &lt;strong&gt;userspace state is not&lt;/strong&gt; (unless you deliberately pin survivors).&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;systemd &lt;strong&gt;≥ 254&lt;/strong&gt; with &lt;code&gt;soft-reboot.target&lt;/code&gt; present.&lt;/li&gt;
&lt;li&gt;Root (or polkit rights equivalent to other power commands).&lt;/li&gt;
&lt;li&gt;A reason that is &lt;strong&gt;userspace-shaped&lt;/strong&gt;: package refresh, config generation that needs a full dependency graph, image-based root handoff.&lt;/li&gt;
&lt;li&gt;Acceptance that &lt;strong&gt;sysctl /sys kernel knobs are not reset&lt;/strong&gt; and the &lt;strong&gt;running kernel is unchanged&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ArchWiki’s systemd page adds two operator notes that match the man page:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;unlocked &lt;strong&gt;dm-crypt&lt;/strong&gt; devices can &lt;strong&gt;remain attached&lt;/strong&gt; across soft-reboot (no full teardown through the usual shutdown path)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;do not&lt;/strong&gt; soft-reboot as your only action after updates that changed &lt;strong&gt;kernel + initramfs&lt;/strong&gt;—those need kexec or a full reboot&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lab 0: inspect before you pull the lever
&lt;/h2&gt;

&lt;p&gt;On a spare VM or lab host, record a baseline. Soft-reboot will disconnect your SSH session the same way a reboot does for interactive work—plan console or out-of-band access.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Boot identity&lt;/span&gt;
&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/kernel/random/boot_id
&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/kernel/osrelease

&lt;span class="c"&gt;# Userspace manager&lt;/span&gt;
systemctl show &lt;span class="nt"&gt;-p&lt;/span&gt; Version &lt;span class="nt"&gt;-p&lt;/span&gt; UserspaceTimestamp &lt;span class="nt"&gt;-p&lt;/span&gt; FirmwareTimestamp &lt;span class="nt"&gt;-p&lt;/span&gt; LoaderTimestamp &lt;span class="nt"&gt;-p&lt;/span&gt; KernelTimestamp

&lt;span class="c"&gt;# What soft-reboot will isolate toward&lt;/span&gt;
systemctl list-dependencies soft-reboot.target
systemctl show soft-reboot.target &lt;span class="nt"&gt;-p&lt;/span&gt; Requires &lt;span class="nt"&gt;-p&lt;/span&gt; After &lt;span class="nt"&gt;-p&lt;/span&gt; JobTimeoutUSec &lt;span class="nt"&gt;-p&lt;/span&gt; JobTimeoutAction
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful expectations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;boot_id&lt;/code&gt;&lt;/strong&gt;: changes on full reboot / kexec paths that start a new boot; treat soft-reboot as a userspace cycle—verify with timestamps and service start times rather than assuming every “boot counter” UI agrees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;FirmwareTimestamp&lt;/code&gt; / &lt;code&gt;LoaderTimestamp&lt;/code&gt; / &lt;code&gt;KernelTimestamp&lt;/code&gt;&lt;/strong&gt;: should stay continuous across soft-reboot; &lt;strong&gt;&lt;code&gt;UserspaceTimestamp&lt;/code&gt;&lt;/strong&gt; advances when PID 1 starts a new userspace boot transaction.&lt;/li&gt;
&lt;li&gt;Kernel release from &lt;code&gt;uname -r&lt;/code&gt; &lt;strong&gt;must&lt;/strong&gt; be unchanged after soft-reboot (if it changed, you did not soft-reboot).
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# After soft-reboot, compare:&lt;/span&gt;
systemctl show &lt;span class="nt"&gt;-p&lt;/span&gt; UserspaceTimestamp &lt;span class="nt"&gt;-p&lt;/span&gt; KernelTimestamp &lt;span class="nt"&gt;-p&lt;/span&gt; FirmwareTimestamp
journalctl &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; short-iso | &lt;span class="nb"&gt;head
&lt;/span&gt;systemctl &lt;span class="nt"&gt;--failed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The default operation: plain soft-reboot
&lt;/h2&gt;

&lt;p&gt;For “restart all of userspace on the &lt;strong&gt;same&lt;/strong&gt; root”:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Optional wall / schedule knobs work like other power commands:&lt;/span&gt;
&lt;span class="c"&gt;# systemctl soft-reboot --when=2026-09-07 23:30:00&lt;/span&gt;
&lt;span class="c"&gt;# systemctl soft-reboot --when=cancel&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl soft-reboot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From &lt;code&gt;systemctl(1)&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;equivalent to starting &lt;code&gt;soft-reboot.target&lt;/code&gt; with &lt;code&gt;--job-mode=replace-irreversibly --no-block&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;asynchronous: the command returns after enqueue&lt;/li&gt;
&lt;li&gt;honors &lt;code&gt;--force&lt;/code&gt; and &lt;code&gt;--when=&lt;/code&gt; similarly to &lt;code&gt;halt&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Never&lt;/strong&gt; do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Wrong: do not start the implementation unit directly&lt;/span&gt;
&lt;span class="c"&gt;# sudo systemctl start systemd-soft-reboot.service&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The man page is explicit: trigger via &lt;code&gt;systemctl soft-reboot&lt;/code&gt; (or the target isolation path it implements), not by hand-starting the helper service.&lt;/p&gt;

&lt;h3&gt;
  
  
  What still happens / what does not
&lt;/h3&gt;

&lt;p&gt;Still happens (userspace shutdown graph toward soft-reboot):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;normal units stop via the usual stop jobs as the transaction isolates&lt;/li&gt;
&lt;li&gt;mounts that are part of the shutdown graph can unmount&lt;/li&gt;
&lt;li&gt;journal and unit state for the new userspace cycle start fresh from PID 1 reexec&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Does &lt;strong&gt;not&lt;/strong&gt; happen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;systemd-shutdown&lt;/code&gt; second phase&lt;/li&gt;
&lt;li&gt;executables under &lt;code&gt;/usr/lib/systemd/system-shutdown/&lt;/code&gt; (skipped because &lt;code&gt;systemd-shutdown&lt;/code&gt; is not run)&lt;/li&gt;
&lt;li&gt;firmware / bootloader / kernel / initrd&lt;/li&gt;
&lt;li&gt;automatic reset of &lt;code&gt;/proc/sys&lt;/code&gt; and &lt;code&gt;/sys&lt;/code&gt; policy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you rely on shutdown hooks in &lt;code&gt;/usr/lib/systemd/system-shutdown/&lt;/code&gt; for disk flush gymnastics or LED scripts, soft-reboot will &lt;strong&gt;not&lt;/strong&gt; run them. Put critical work in normal service &lt;code&gt;ExecStop=&lt;/code&gt; / &lt;code&gt;ExecStopPost=&lt;/code&gt; instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lab 1: switch root with &lt;code&gt;/run/nextroot/&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the power feature for image-based and A/B setups: prepare a &lt;strong&gt;complete root filesystem hierarchy&lt;/strong&gt; at &lt;code&gt;/run/nextroot/&lt;/code&gt;, then soft-reboot into it &lt;strong&gt;without&lt;/strong&gt; losing kernel-held state (routes, some device setup, non-reset sysctls, unlocked LUKS mappings as applicable).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/run/nextroot/&lt;/code&gt; may be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a plain directory on the existing &lt;code&gt;/run&lt;/code&gt; tmpfs&lt;/li&gt;
&lt;li&gt;a mount point (bind mount, loop, image)&lt;/li&gt;
&lt;li&gt;a symlink to either&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;systemd will turn a non-mount &lt;code&gt;/run/nextroot/&lt;/code&gt; into a mount point automatically on soft-reboot when needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Minimal directory-based next root (teaching lab)
&lt;/h3&gt;

&lt;p&gt;This is intentionally small and &lt;strong&gt;not&lt;/strong&gt; a full distro root. Use it only to prove the switch mechanism on a disposable VM.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# WARNING: disposable lab only. A real next root needs a bootable userspace:&lt;/span&gt;
&lt;span class="c"&gt;# /usr, /etc, /var skeleton, device nodes or devtmpfs policy, and a systemd binary.&lt;/span&gt;

&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /run/nextroot
&lt;span class="c"&gt;# Example: bind an already-prepared root tree (snapshot, unpacked image, ostree checkout)&lt;/span&gt;
&lt;span class="c"&gt;# sudo mount --bind /var/lib/images/root-b /run/nextroot&lt;/span&gt;

&lt;span class="c"&gt;# Or, for image-based hosts, mount the inactive slot:&lt;/span&gt;
&lt;span class="c"&gt;# sudo mount /dev/disk/by-partlabel/root-b /run/nextroot&lt;/span&gt;

find /run/nextroot &lt;span class="nt"&gt;-maxdepth&lt;/span&gt; 2 &lt;span class="nt"&gt;-type&lt;/span&gt; d | &lt;span class="nb"&gt;head
test&lt;/span&gt; &lt;span class="nt"&gt;-x&lt;/span&gt; /run/nextroot/usr/lib/systemd/systemd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-x&lt;/span&gt; /run/nextroot/lib/systemd/systemd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"systemd binary visible in next root"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl soft-reboot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After reconnecting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;findmnt &lt;span class="nt"&gt;-no&lt;/span&gt; SOURCE,FSTYPE,OPTIONS /
&lt;span class="c"&gt;# Confirm you landed on the intended slot/device&lt;/span&gt;
lsblk &lt;span class="nt"&gt;-o&lt;/span&gt; NAME,LABEL,PARTLABEL,MOUNTPOINTS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Important interaction with plain &lt;code&gt;reboot&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;From both &lt;code&gt;systemctl reboot&lt;/code&gt; and &lt;code&gt;systemctl soft-reboot&lt;/code&gt; docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;if &lt;code&gt;/run/nextroot/&lt;/code&gt; is set up, &lt;strong&gt;&lt;code&gt;systemctl reboot&lt;/code&gt; performs a soft-reboot instead&lt;/strong&gt;, unless you set:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SYSTEMCTL_SKIP_AUTO_SOFT_REBOOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reboot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is easy to miss in automation. Image updaters that stage &lt;code&gt;/run/nextroot/&lt;/code&gt; and then call &lt;code&gt;reboot&lt;/code&gt; are often &lt;strong&gt;depending&lt;/strong&gt; on this auto-soft-reboot behavior. Operators who stage a next root for testing and then expect a full reboot can be surprised—set &lt;code&gt;SYSTEMCTL_SKIP_AUTO_SOFT_REBOOT=1&lt;/code&gt; when you truly need firmware/kernel bring-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resource pass-through (use sparingly)
&lt;/h2&gt;

&lt;p&gt;Soft-reboot can carry selected runtime resources into the next userspace cycle. The man page lists the official mechanisms—and warns to use them &lt;strong&gt;sparingly&lt;/strong&gt;, because mixing old and new generations is how you get “half updated” systems.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) &lt;code&gt;/run&lt;/code&gt; stays mounted
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;/run&lt;/code&gt; is shared across the soft-reboot boundary. It is the natural place for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;next-root staging (&lt;code&gt;/run/nextroot/&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;one-shot flags for the next boot transaction&lt;/li&gt;
&lt;li&gt;ephemeral coordinator state that should not hit disk&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not treat &lt;code&gt;/run&lt;/code&gt; as durable storage; treat it as &lt;strong&gt;kernel-lifetime&lt;/strong&gt; memory backed state that happens to survive userspace recycle.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) File descriptor store
&lt;/h3&gt;

&lt;p&gt;Services can stash file descriptors with the manager (&lt;code&gt;FileDescriptorStoreMax=&lt;/code&gt; / fdstore protocol). Across soft-reboot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;FDs in the store of units that remain active until the end can be handed to the &lt;strong&gt;same unit&lt;/strong&gt; after the next cycle&lt;/li&gt;
&lt;li&gt;alternatively, set &lt;strong&gt;&lt;code&gt;FileDescriptorStorePreserve=&lt;/code&gt;&lt;/strong&gt;:&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Behavior (from &lt;code&gt;systemd.service(5)&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;release store when the service stops&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;restart&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;keep while unit is not inactive/failed, or a job is queued, or restart is expected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;yes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;keep until the unit is removed from memory—useful to pin until the manager exits&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;FileDescriptorStorePreserve=&lt;/code&gt; was &lt;strong&gt;added in version 254&lt;/strong&gt; (same generation as soft-reboot).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Inspect a unit's fdstore (when in use)&lt;/span&gt;
systemd-analyze fdstore some.service
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl clean &lt;span class="nt"&gt;--what&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;fdstore some.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Units that must keep the store without staying “up” the entire shutdown usually combine preserve mode with careful dependency design—or stay alive with the survivor pattern below.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) &lt;code&gt;.socket&lt;/code&gt; units that never stop
&lt;/h3&gt;

&lt;p&gt;Socket FDs remain open and connectible if the &lt;code&gt;.socket&lt;/code&gt; unit is &lt;strong&gt;not&lt;/strong&gt; stopped during the transition. Achieved with &lt;code&gt;DefaultDependencies=no&lt;/code&gt; and by avoiding conflicts that pull the socket into the stop set. Clients can keep connecting while userspace recycles—powerful for load balancers and local brokers, easy to get wrong if activation policy disagrees after the new boot transaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  4) Surviving service processes
&lt;/h3&gt;

&lt;p&gt;A process may keep running across soft-reboot only if &lt;strong&gt;all&lt;/strong&gt; of the following hold (paraphrased from the man page example):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/my-survivor.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;My Surviving Service&lt;/span&gt;
&lt;span class="py"&gt;SurviveFinalKillSignal&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="py"&gt;IgnoreOnIsolate&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="py"&gt;DefaultDependencies&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;basic.target&lt;/span&gt;
&lt;span class="py"&gt;Conflicts&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;reboot.target kexec.target poweroff.target halt.target rescue.target emergency.target&lt;/span&gt;
&lt;span class="py"&gt;Before&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;shutdown.target rescue.target emergency.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/sleep infinity&lt;/span&gt;
&lt;span class="c"&gt;# Real services: your long-running binary; oneshot+sleep is the man-page sketch only
&lt;/span&gt;&lt;span class="py"&gt;RemainAfterExit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;strong&gt;templated&lt;/strong&gt; units (&lt;code&gt;foo@instance.service&lt;/code&gt;), also ship a slice that survives, because instances default into a template slice that would otherwise be stopped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/system-foo.slice
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;SurviveFinalKillSignal&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="py"&gt;IgnoreOnIsolate&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="py"&gt;DefaultDependencies&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Official guidance is blunt: &lt;strong&gt;prefer not to survive&lt;/strong&gt;. Survivors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pin old filesystem mounts and libraries in memory&lt;/li&gt;
&lt;li&gt;skip code updates for that process&lt;/li&gt;
&lt;li&gt;require D-Bus reconnect logic if they talk to the system bus (the broker &lt;strong&gt;does&lt;/strong&gt; restart; connections drop)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need isolation from the host OS tree, the man page points at &lt;strong&gt;Portable Services&lt;/strong&gt;—and warns not to &lt;code&gt;BindPaths=&lt;/code&gt; host OS files, or the old tree stays pinned for the life of the unit.&lt;/p&gt;

&lt;h3&gt;
  
  
  5) Mounts and complex storage
&lt;/h3&gt;

&lt;p&gt;Mounts can remain mounted if configured with &lt;code&gt;DefaultDependencies=no&lt;/code&gt; and without &lt;code&gt;Conflicts=umount.target&lt;/code&gt;. That is how complex storage can stay attached while userspace turns over—aligned with the “unlocked LUKS stays” operational reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Homelab pattern: userspace refresh after apt without a full reboot
&lt;/h2&gt;

&lt;p&gt;A common safe pattern on servers where the kernel did &lt;strong&gt;not&lt;/strong&gt; change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1) Update packages&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get upgrade

&lt;span class="c"&gt;# 2) If a new kernel/initrd landed, do NOT soft-reboot as the only step&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find /boot &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'vmlinuz-*'&lt;/span&gt; &lt;span class="nt"&gt;-newer&lt;/span&gt; /proc/1/root 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Kernel payloads changed under /boot — schedule kexec or full reboot"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# 3) Otherwise recycle userspace cleanly&lt;/span&gt;
&lt;span class="c"&gt;# sudo systemctl soft-reboot&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pair with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;needrestart&lt;/code&gt; or package hooks to see which daemons still need attention&lt;/li&gt;
&lt;li&gt;a scheduled full reboot window for kernel CVEs (livepatch if you use it)&lt;/li&gt;
&lt;li&gt;monitoring that distinguishes “userspace boot age” from “kernel boot age”&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Verification checklist
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Before&lt;/span&gt;
&lt;span class="nv"&gt;KTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;systemctl show &lt;span class="nt"&gt;-p&lt;/span&gt; KernelTimestamp &lt;span class="nt"&gt;--value&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;UTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;systemctl show &lt;span class="nt"&gt;-p&lt;/span&gt; UserspaceTimestamp &lt;span class="nt"&gt;--value&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"kernel=&lt;/span&gt;&lt;span class="nv"&gt;$KTS&lt;/span&gt;&lt;span class="s2"&gt; userspace=&lt;/span&gt;&lt;span class="nv"&gt;$UTS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; | &lt;span class="nb"&gt;tee&lt;/span&gt; /run/soft-reboot-pre-uname

&lt;span class="c"&gt;# Perform soft-reboot, then after login:&lt;/span&gt;

&lt;span class="c"&gt;# Kernel identity unchanged&lt;/span&gt;
diff &lt;span class="nt"&gt;-u&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /run/soft-reboot-pre-uname&lt;span class="o"&gt;)&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Userspace transaction should look like a new boot&lt;/span&gt;
systemctl show &lt;span class="nt"&gt;-p&lt;/span&gt; UserspaceTimestamp &lt;span class="nt"&gt;-p&lt;/span&gt; KernelTimestamp &lt;span class="nt"&gt;-p&lt;/span&gt; FirmwareTimestamp
systemctl is-system-running
systemctl &lt;span class="nt"&gt;--failed&lt;/span&gt;

&lt;span class="c"&gt;# Soft-reboot path evidence in the journal (wording varies by version)&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; systemd-soft-reboot.service &lt;span class="nt"&gt;-u&lt;/span&gt; soft-reboot.target &lt;span class="nt"&gt;--no-pager&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-50&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-b&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-40&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Success signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;uname -r&lt;/code&gt; unchanged&lt;/li&gt;
&lt;li&gt;firmware/loader/kernel timestamps continuous (where exposed)&lt;/li&gt;
&lt;li&gt;userspace services show fresh start times&lt;/li&gt;
&lt;li&gt;failed units list is empty (or only known pre-existing failures)&lt;/li&gt;
&lt;li&gt;if you used &lt;code&gt;/run/nextroot/&lt;/code&gt;, &lt;code&gt;findmnt /&lt;/code&gt; points at the new backing store&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Failure / rollback signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stuck isolating toward &lt;code&gt;soft-reboot.target&lt;/code&gt; past the job timeout (watch for &lt;code&gt;soft-reboot-force&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;services pinned to old trees via accidental survivors / bind mounts&lt;/li&gt;
&lt;li&gt;expectation of reset sysctls that never reset—re-apply &lt;code&gt;/etc/sysctl.d/&lt;/code&gt; explicitly if needed:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Boundaries (what not to expect)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Use instead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New kernel / modules / initramfs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;systemctl kexec&lt;/code&gt; or full &lt;code&gt;systemctl reboot&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firmware / UEFI variable / boot-entry change&lt;/td&gt;
&lt;td&gt;full reboot (&lt;code&gt;--boot-loader-entry=&lt;/code&gt;, &lt;code&gt;--firmware-setup&lt;/code&gt;, etc.)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reset all sysctls and kernel object state&lt;/td&gt;
&lt;td&gt;full reboot (or explicit sysctl + module reload discipline)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run &lt;code&gt;/usr/lib/systemd/system-shutdown/&lt;/code&gt; helpers&lt;/td&gt;
&lt;td&gt;full shutdown path; or move logic into unit stop jobs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crash dump / kdump path testing&lt;/td&gt;
&lt;td&gt;real panic/kdump flow (separate stack)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold device re-probe that depends on boot firmware&lt;/td&gt;
&lt;td&gt;full reboot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;“Survive everything, zero disruption”&lt;/td&gt;
&lt;td&gt;not soft-reboot; use rolling services, FD store carefully, or cluster failover&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Related tools that are &lt;strong&gt;not&lt;/strong&gt; substitutes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;systemctl daemon-reexec&lt;/code&gt;&lt;/strong&gt;: reexec PID 1 only, without a full userspace boot transaction&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;systemctl isolate rescue.target&lt;/code&gt;&lt;/strong&gt;: maintenance mode, not an update cycle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;kexec&lt;/strong&gt;: replaces the kernel; still skips much of firmware, but it is not soft-reboot&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;nspawn / containers&lt;/strong&gt;: recycle a machine without touching the host kernel—different layer&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Suggested roll-out
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Confirm systemd ≥ 254 and read &lt;code&gt;systemctl cat soft-reboot.target&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Try plain &lt;code&gt;systemctl soft-reboot&lt;/code&gt; on a disposable VM; compare kernel vs userspace timestamps.&lt;/li&gt;
&lt;li&gt;Document whether your fleet stages &lt;code&gt;/run/nextroot/&lt;/code&gt; and whether &lt;code&gt;reboot&lt;/code&gt; auto-promotes to soft-reboot.&lt;/li&gt;
&lt;li&gt;Keep survivors and FD-store pass-through off by default; add them per service with an explicit threat/availability model.&lt;/li&gt;
&lt;li&gt;After package updates: soft-reboot for userspace-only changes; kexec/full reboot when &lt;code&gt;/boot&lt;/code&gt; kernel artifacts change.&lt;/li&gt;
&lt;li&gt;Re-apply sysctl policy if you depend on clean defaults after the cycle.&lt;/li&gt;
&lt;li&gt;Monitor &lt;code&gt;systemctl --failed&lt;/code&gt; and boot-age metrics that understand two clocks (kernel uptime vs userspace boot).&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Userspace-only reboot (same root)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl soft-reboot

&lt;span class="c"&gt;# Stage a new root, then soft-reboot into it&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount /dev/disk/by-partlabel/root-b /run/nextroot
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl soft-reboot

&lt;span class="c"&gt;# Force a *full* reboot even if /run/nextroot exists&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;&lt;span class="nv"&gt;SYSTEMCTL_SKIP_AUTO_SOFT_REBOOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 systemctl reboot

&lt;span class="c"&gt;# Inspect implementation units&lt;/span&gt;
systemctl &lt;span class="nb"&gt;cat &lt;/span&gt;soft-reboot.target systemd-soft-reboot.service

&lt;span class="c"&gt;# FD store preserve (unit drop-in sketch)&lt;/span&gt;
&lt;span class="c"&gt;# [Service]&lt;/span&gt;
&lt;span class="c"&gt;# FileDescriptorStoreMax=128&lt;/span&gt;
&lt;span class="c"&gt;# FileDescriptorStorePreserve=yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;systemd-soft-reboot.service(8)&lt;/code&gt; — &lt;a href="https://manpages.debian.org/testing/systemd/systemd-soft-reboot.service.8.en.html" rel="noopener noreferrer"&gt;Debian man page&lt;/a&gt; / &lt;a href="https://man7.org/linux/man-pages/man8/systemd-soft-reboot.service.8.html" rel="noopener noreferrer"&gt;man7&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemctl(1)&lt;/code&gt; — &lt;code&gt;soft-reboot&lt;/code&gt;, auto-soft-reboot on &lt;code&gt;reboot&lt;/code&gt; when &lt;code&gt;/run/nextroot/&lt;/code&gt; exists, &lt;code&gt;SYSTEMCTL_SKIP_AUTO_SOFT_REBOOT&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemd.special(7)&lt;/code&gt; — &lt;code&gt;soft-reboot.target&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemd.service(5)&lt;/code&gt; — &lt;code&gt;FileDescriptorStoreMax=&lt;/code&gt;, &lt;code&gt;FileDescriptorStorePreserve=&lt;/code&gt; (254+)&lt;/li&gt;
&lt;li&gt;ArchWiki — &lt;a href="https://wiki.archlinux.org/title/Systemd#Soft_reboot" rel="noopener noreferrer"&gt;Systemd § Soft reboot&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Portable Services overview — &lt;a href="https://systemd.io/PORTABLE_SERVICES" rel="noopener noreferrer"&gt;systemd.io/PORTABLE_SERVICES&lt;/a&gt; (survivor isolation option cited by the soft-reboot man page)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Full reboots are for kernels, firmware, and cold topology. When only userspace needs a clean graph, soft-reboot is the proportionate tool: same kernel, new service manager transaction, optional root flip through &lt;code&gt;/run/nextroot/&lt;/code&gt;, and a short list of deliberate pass-through knobs you should almost always leave alone until you have a specific reason not to.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>systemd</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Stop Hand-Partitioning Disks: Practical systemd-repart on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Sun, 06 Sep 2026 05:02:50 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-hand-partitioning-disks-practical-systemd-repart-on-linux-58dp</link>
      <guid>https://dev.to/lyraalishaikh/stop-hand-partitioning-disks-practical-systemd-repart-on-linux-58dp</guid>
      <description>&lt;h1&gt;
  
  
  Stop Hand-Partitioning Disks: Practical systemd-repart on Linux
&lt;/h1&gt;

&lt;p&gt;You ship a minimal OS image. The target disk is 64 GB, 256 GB, or 2 TB. Root is still 8 GB. Swap does not exist. &lt;code&gt;/home&lt;/code&gt; is not a partition yet.&lt;/p&gt;

&lt;p&gt;The usual fix is a one-off &lt;code&gt;parted&lt;/code&gt; / &lt;code&gt;gdisk&lt;/code&gt; script, a fragile installer hook, or “remember to resize after first boot.” That does not scale across VMs, bare metal, and image-based fleets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;systemd-repart&lt;/code&gt;&lt;/strong&gt; turns partition layout into declarative config: GPT definitions under &lt;code&gt;repart.d/*.conf&lt;/code&gt;, incremental grow/add on every boot, optional format/encrypt/populate while building disk images (DDIs), and no shrink/move/delete of existing data by default.&lt;/p&gt;

&lt;p&gt;This post is a practical operator guide: lab image build, first-boot grow, swap/home/srv addition, filesystem growth, verification, and rollback boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  What systemd-repart actually does
&lt;/h2&gt;

&lt;p&gt;From &lt;code&gt;systemd-repart(8)&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reads &lt;code&gt;repart.d/*.conf&lt;/code&gt; partition definitions.&lt;/li&gt;
&lt;li&gt;Operates on a block device or image file (or the disk backing &lt;code&gt;/&lt;/code&gt; / &lt;code&gt;/sysroot&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adds missing partitions&lt;/strong&gt; and &lt;strong&gt;grows existing ones&lt;/strong&gt; to satisfy size/weight constraints.&lt;/li&gt;
&lt;li&gt;Is &lt;strong&gt;incremental and idempotent&lt;/strong&gt;: if the table already matches config, it is a no-op.&lt;/li&gt;
&lt;li&gt;Does &lt;strong&gt;not&lt;/strong&gt; shrink, delete, or reorder partitions in normal mode.&lt;/li&gt;
&lt;li&gt;By default only changes the &lt;strong&gt;partition table&lt;/strong&gt;, unless you set &lt;code&gt;Format=&lt;/code&gt;, &lt;code&gt;CopyFiles=&lt;/code&gt;, &lt;code&gt;CopyBlocks=&lt;/code&gt;, &lt;code&gt;Encrypt=&lt;/code&gt;, or &lt;code&gt;Verity=&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Matching is by &lt;strong&gt;GPT type UUID&lt;/strong&gt; (friendly names like &lt;code&gt;root&lt;/code&gt;, &lt;code&gt;home&lt;/code&gt;, &lt;code&gt;swap&lt;/code&gt;, &lt;code&gt;esp&lt;/code&gt;), not by partition number. Filenames sort the definition order. First existing partition of type T binds to the first conf of type T, and so on.&lt;/p&gt;

&lt;p&gt;GPT only. MBR is out of scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mental model: three jobs, one tool
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Typical invocation&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First-boot disk takeover&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;systemd-repart.service&lt;/code&gt; in initrd&lt;/td&gt;
&lt;td&gt;Grow root; create swap/home/srv on free space&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Offline image build&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;systemd-repart --image=...&lt;/code&gt; / &lt;code&gt;--empty=create&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Build a DDI from scratch with Format/CopyFiles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safe preview&lt;/td&gt;
&lt;td&gt;default &lt;code&gt;--dry-run=yes&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Always dry-run before &lt;code&gt;--dry-run=no&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Filesystem growth is a sibling concern: &lt;code&gt;GrowFileSystem=&lt;/code&gt; GPT flag + &lt;code&gt;systemd-growfs&lt;/code&gt; / &lt;code&gt;x-systemd.growfs&lt;/code&gt;, not “repart magically resizes ext4 by default.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GPT disk (or you are creating a new GPT image).&lt;/li&gt;
&lt;li&gt;Package providing the tool (Debian/Ubuntu: &lt;code&gt;systemd-repart&lt;/code&gt;; many images already ship it with systemd).&lt;/li&gt;
&lt;li&gt;Root for real devices; unprivileged builds often use loop files + userns.&lt;/li&gt;
&lt;li&gt;Enough free space &lt;strong&gt;after&lt;/strong&gt; the last partition you care about (repart appends; it does not defragment the table).
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Presence check (paths vary by distro)&lt;/span&gt;
&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; systemd-repart
man 8 systemd-repart
man 5 repart.d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Lab 1: Build a minimal GPT image without touching real disks
&lt;/h2&gt;

&lt;p&gt;This is the safest way to learn. Create a sparse file, declare ESP + root + swap, format them, and inspect the result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /tmp/repart-lab/&lt;span class="o"&gt;{&lt;/span&gt;defs,out,rootfs&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="c"&gt;# Tiny fake root tree to copy into the root partition&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /tmp/repart-lab/rootfs/&lt;span class="o"&gt;{&lt;/span&gt;etc,usr/bin,var&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"repart-lab"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/repart-lab/rootfs/etc/hostname
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'#!/bin/sh\necho ok\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/repart-lab/rootfs/usr/bin/hello
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /tmp/repart-lab/rootfs/usr/bin/hello
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Partition definitions
&lt;/h3&gt;

&lt;p&gt;Drop files under a dedicated definitions directory (do not write these to a production host’s &lt;code&gt;/etc/repart.d&lt;/code&gt; yet):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/tmp/repart-lab/defs/00-esp.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[Partition]
Type=esp
Format=vfat
SizeMinBytes=512M
SizeMaxBytes=512M
Label=ESP
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/tmp/repart-lab/defs/10-root.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[Partition]
Type=root
Format=ext4
# Architecture-aware: "root" means root-x86-64 on amd64, root-arm64 on aarch64, etc.
CopyFiles=/tmp/repart-lab/rootfs:/
SizeMinBytes=2G
Weight=1000
Label=root-a
GrowFileSystem=on
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/tmp/repart-lab/defs/20-swap.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[Partition]
Type=swap
Format=swap
SizeMinBytes=1G
SizeMaxBytes=1G
Label=swap
Priority=100
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes from &lt;code&gt;repart.d(5)&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Type=root&lt;/code&gt; is an alias for the &lt;strong&gt;local architecture&lt;/strong&gt; root type (DPS).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Weight=&lt;/code&gt; shares leftover free space elastically (default 1000).&lt;/li&gt;
&lt;li&gt;Equal &lt;code&gt;SizeMinBytes=&lt;/code&gt; and &lt;code&gt;SizeMaxBytes=&lt;/code&gt; fix the size (weight ignored).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Priority=&lt;/code&gt; drops &lt;strong&gt;optional&lt;/strong&gt; new partitions when the disk is too small (0 or lower never dropped; higher number = lower priority).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Format=&lt;/code&gt; runs before the partition is registered, so you never see a half-initialized slot.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CopyFiles=&lt;/code&gt; implies a suitable &lt;code&gt;Format=&lt;/code&gt; if omitted (ext4 by default for non-ESP).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Dry-run, then create
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;IMG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/repart-lab/out/lab.raw

&lt;span class="c"&gt;# Compute minimum size and create the image file&lt;/span&gt;
systemd-repart &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--definitions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/repart-lab/defs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--empty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4G &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Apply for real&lt;/span&gt;
systemd-repart &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--definitions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/repart-lab/defs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--empty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4G &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inspect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sfdisk &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# or&lt;/span&gt;
parted &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; unit MiB print free

&lt;span class="c"&gt;# Loop-attach and probe filesystems&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;losetup &lt;span class="nt"&gt;-fP&lt;/span&gt; &lt;span class="nt"&gt;--show&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# Suppose it printed /dev/loop0&lt;/span&gt;
lsblk &lt;span class="nt"&gt;-o&lt;/span&gt; NAME,SIZE,FSTYPE,LABEL,PARTTYPE,PARTLABEL /dev/loop0
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/repart-root
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount /dev/loop0p2 /mnt/repart-root   &lt;span class="c"&gt;# partition numbers depend on layout&lt;/span&gt;
find /mnt/repart-root &lt;span class="nt"&gt;-maxdepth&lt;/span&gt; 3 &lt;span class="nt"&gt;-type&lt;/span&gt; f
&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /mnt/repart-root
&lt;span class="nb"&gt;sudo &lt;/span&gt;losetup &lt;span class="nt"&gt;-d&lt;/span&gt; /dev/loop0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--empty=&lt;/code&gt; modes matter:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;refuse&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;Require an existing partition table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;allow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Extend existing or create if missing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;require&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Create only if empty; refuse if a table exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;force&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wipe and create a fresh table (&lt;strong&gt;data loss&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;create&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Create a new regular file image at the path&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Default CLI mode is &lt;strong&gt;&lt;code&gt;--dry-run=yes&lt;/code&gt;&lt;/strong&gt;. Nothing is written until you pass &lt;code&gt;--dry-run=no&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lab 2: Grow into a larger disk (the VM resize story)
&lt;/h2&gt;

&lt;p&gt;You deploy the same 4 G image onto a 16 G virtual disk. Goal: root grows; swap stays fixed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Simulate "hypervisor gave us a bigger disk"&lt;/span&gt;
&lt;span class="nb"&gt;truncate&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; 16G &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

systemd-repart &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--definitions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/repart-lab/defs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

systemd-repart &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--definitions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/repart-lab/defs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What should happen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Existing partitions keep their identities (type UUID match).&lt;/li&gt;
&lt;li&gt;Root grows according to &lt;code&gt;Weight=&lt;/code&gt; / max constraints into free space.&lt;/li&gt;
&lt;li&gt;Swap stays 1 G because min=max.&lt;/li&gt;
&lt;li&gt;No partition is moved or renumbered.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Partition table growth ≠ filesystem growth.&lt;/strong&gt; After the partition is larger, grow the filesystem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;losetup &lt;span class="nt"&gt;-fP&lt;/span&gt; &lt;span class="nt"&gt;--show&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# mount the root partition, then:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount /dev/loop0p2 /mnt/repart-root
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemd-growfs /mnt/repart-root
&lt;span class="c"&gt;# equivalent idea: resize2fs on ext4 while mounted (ext4 supports online grow)&lt;/span&gt;
&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /mnt/repart-root
&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /mnt/repart-root
&lt;span class="nb"&gt;sudo &lt;/span&gt;losetup &lt;span class="nt"&gt;-d&lt;/span&gt; /dev/loop0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On real systems, prefer the GPT &lt;strong&gt;Grow-File-System&lt;/strong&gt; flag (&lt;code&gt;GrowFileSystem=on&lt;/code&gt; in repart.d) plus automatic &lt;code&gt;systemd-growfs@.service&lt;/code&gt; via &lt;code&gt;x-systemd.growfs&lt;/code&gt; in fstab, or discovery helpers that honor the DPS flags. &lt;code&gt;systemd-growfs&lt;/code&gt; supports ext4, btrfs, xfs, and dm-crypt mappings of those.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production pattern: first-boot additions on a live host
&lt;/h2&gt;

&lt;p&gt;Common homelab/server goal:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Keep vendor root as-is (or grow it).&lt;/li&gt;
&lt;li&gt;Add encrypted-capable state partitions on free space: &lt;code&gt;swap&lt;/code&gt;, &lt;code&gt;home&lt;/code&gt;, &lt;code&gt;srv&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Let &lt;code&gt;systemd-gpt-auto-generator&lt;/code&gt; mount them by DPS type without hand-written fstab.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Example definitions for &lt;code&gt;/etc/repart.d/&lt;/code&gt; (review carefully; test on a spare disk first):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/repart.d/50-root.conf&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;Partition]
&lt;span class="nv"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;root
&lt;span class="c"&gt;# Grow existing root partition into free space; do not format existing data&lt;/span&gt;
&lt;span class="nv"&gt;Weight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2000
&lt;span class="nv"&gt;GrowFileSystem&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;on

&lt;span class="c"&gt;# /etc/repart.d/60-swap.conf&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;Partition]
&lt;span class="nv"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;swap
&lt;span class="nv"&gt;Format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;swap
&lt;span class="nv"&gt;SizeMinBytes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4G
&lt;span class="nv"&gt;SizeMaxBytes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8G
&lt;span class="nv"&gt;Weight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100
&lt;span class="nv"&gt;Priority&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;50
&lt;span class="nv"&gt;Label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;swap

&lt;span class="c"&gt;# /etc/repart.d/70-home.conf&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;Partition]
&lt;span class="nv"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;home
&lt;span class="nv"&gt;Format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ext4
&lt;span class="nv"&gt;SizeMinBytes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20G
&lt;span class="nv"&gt;Weight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000
&lt;span class="nv"&gt;Label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;home
&lt;span class="nv"&gt;GrowFileSystem&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;on

&lt;span class="c"&gt;# /etc/repart.d/80-srv.conf&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;Partition]
&lt;span class="nv"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;srv
&lt;span class="nv"&gt;Format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ext4
&lt;span class="nv"&gt;SizeMinBytes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10G
&lt;span class="nv"&gt;Weight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000
&lt;span class="nv"&gt;Label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;srv
&lt;span class="nv"&gt;GrowFileSystem&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;on
&lt;span class="nv"&gt;FactoryReset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Preview against the disk that backs root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemd-repart &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt;
&lt;span class="c"&gt;# When correct:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemd-repart &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no device argument, repart targets the block device backing the root filesystem (or &lt;code&gt;/sysroot&lt;/code&gt; in the initrd).&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Type=home / Type=srv matter
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://uapi-group.org/specifications/specs/discoverable_partitions_specification/" rel="noopener noreferrer"&gt;UAPI.2 Discoverable Partitions Specification&lt;/a&gt; assigns stable GPT type UUIDs for root, &lt;code&gt;/usr&lt;/code&gt;, home, srv, var, tmp, swap, ESP, XBOOTLDR, and verity siblings.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;systemd-gpt-auto-generator(8)&lt;/code&gt; can then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Discover and mount home/srv/var/tmp and enable swap from GPT types.&lt;/li&gt;
&lt;li&gt;Skip mount points that already have fstab entries or non-empty directories (important operational caveat).&lt;/li&gt;
&lt;li&gt;Pair with image tools (&lt;code&gt;systemd-nspawn --image=&lt;/code&gt;, dissect helpers) so the &lt;strong&gt;same&lt;/strong&gt; GPT image boots on bare metal and as a container root.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you format a “data” partition as generic Linux without the right type UUID, auto-discovery will not place it on &lt;code&gt;/home&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Image factory: CopyFiles, Encrypt, Verity
&lt;/h2&gt;

&lt;p&gt;For offline DDI builds, repart becomes an image factory—not only a grower.&lt;/p&gt;

&lt;h3&gt;
  
  
  Populate root from a directory tree
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Partition]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;root&lt;/span&gt;
&lt;span class="py"&gt;Format&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ext4&lt;/span&gt;
&lt;span class="py"&gt;CopyFiles&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/var/tmp/my-os-tree:/&lt;/span&gt;
&lt;span class="py"&gt;Minimize&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;guess&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Minimize=guess&lt;/code&gt; sizes the partition from content (may populate twice on writable FS).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Minimize=best&lt;/code&gt; is for read-only filesystems.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CopyFiles=&lt;/code&gt; cannot modify an &lt;strong&gt;existing&lt;/strong&gt; partition; it only populates newly created+formatted ones.&lt;/li&gt;
&lt;li&gt;Symlinks/devices may be skipped on vfat ESP copies (logged).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  LUKS2 at creation time
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Partition]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;var&lt;/span&gt;
&lt;span class="py"&gt;Format&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ext4&lt;/span&gt;
&lt;span class="py"&gt;Encrypt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;key-file&lt;/span&gt;
&lt;span class="py"&gt;SizeMinBytes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5G&lt;/span&gt;
&lt;span class="py"&gt;Label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;var&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-repart &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--definitions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/path/to/key &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--empty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;create &lt;span class="nt"&gt;--size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;auto disk.raw
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Encrypt=&lt;/code&gt; values from the man page: &lt;code&gt;off&lt;/code&gt;, &lt;code&gt;key-file&lt;/code&gt;, &lt;code&gt;tpm2&lt;/code&gt;, &lt;code&gt;key-file+tpm2&lt;/code&gt;. Encryption is applied when the partition is &lt;strong&gt;created&lt;/strong&gt;; existing partitions are left alone.&lt;/p&gt;

&lt;h3&gt;
  
  
  dm-verity siblings (image build)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# 10-root.conf
&lt;/span&gt;&lt;span class="nn"&gt;[Partition]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;root&lt;/span&gt;
&lt;span class="py"&gt;CopyFiles&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/path/to/root:/&lt;/span&gt;
&lt;span class="py"&gt;Verity&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;
&lt;span class="py"&gt;VerityMatchKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;root&lt;/span&gt;

&lt;span class="c"&gt;# 11-root-verity.conf
&lt;/span&gt;&lt;span class="nn"&gt;[Partition]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;root-verity&lt;/span&gt;
&lt;span class="py"&gt;Verity&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;hash&lt;/span&gt;
&lt;span class="py"&gt;VerityMatchKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;root&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Verity=signature&lt;/code&gt; can add a signature partition when you pass &lt;code&gt;--private-key=&lt;/code&gt; / &lt;code&gt;--certificate=&lt;/code&gt;. This is the image-build side of the verity story; runtime open still needs your verity/UKI/cmdline story (separate from day-2 &lt;code&gt;veritysetup&lt;/code&gt; on arbitrary devices).&lt;/p&gt;

&lt;h2&gt;
  
  
  Factory reset (explicit, destructive, opt-in)
&lt;/h2&gt;

&lt;p&gt;Normal mode never deletes partitions. Factory reset is the exception:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mark partitions with &lt;code&gt;FactoryReset=yes&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Trigger via &lt;code&gt;systemd-repart --factory-reset=yes&lt;/code&gt;, kernel cmdline &lt;code&gt;systemd.factory_reset=yes&lt;/code&gt;, or the EFI &lt;code&gt;FactoryResetRequest&lt;/code&gt; variable documented in &lt;code&gt;systemd-repart(8)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Marked partitions are deleted and recreated empty per definitions.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-repart &lt;span class="nt"&gt;--can-factory-reset&lt;/span&gt;   &lt;span class="c"&gt;# exit 0 if any partition is marked&lt;/span&gt;
&lt;span class="c"&gt;# Only when you truly mean it:&lt;/span&gt;
&lt;span class="c"&gt;# systemd-repart --factory-reset=yes --dry-run=no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use for lab appliances and kiosk images—not as a casual “cleanup” tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency, seeds, and reproducibility
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Re-running with the same defs on an already-compliant disk does nothing.&lt;/li&gt;
&lt;li&gt;New partition UUIDs (and the disk UUID when zero) are hashed from a &lt;strong&gt;seed&lt;/strong&gt; (machine-id by default, or &lt;code&gt;--seed=UUID&lt;/code&gt; / &lt;code&gt;--seed=random&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Fixed seeds make image builds reproducible; random seeds make unique instances.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-repart &lt;span class="nt"&gt;--seed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;00000000-0000-0000-0000-000000000001 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--definitions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/repart-lab/defs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--empty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;create &lt;span class="nt"&gt;--size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4G &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no /tmp/repart-lab/out/repro.raw
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verification checklist
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Layout + free space&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;fdisk &lt;span class="nt"&gt;-l&lt;/span&gt; /dev/DISK
&lt;span class="nb"&gt;sudo &lt;/span&gt;parted &lt;span class="nt"&gt;-s&lt;/span&gt; /dev/DISK unit MiB print free

&lt;span class="c"&gt;# DPS types and labels&lt;/span&gt;
lsblk &lt;span class="nt"&gt;-o&lt;/span&gt; NAME,SIZE,FSTYPE,LABEL,PARTLABEL,PARTTYPE,UUID /dev/DISK

&lt;span class="c"&gt;# What gpt-auto would consider (after reboot / generator run)&lt;/span&gt;
systemctl &lt;span class="nb"&gt;cat &lt;/span&gt;systemd-gpt-auto-generator 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;find /run/systemd/generator&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*home*'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*srv*'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*swap*'&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt;

&lt;span class="c"&gt;# Filesystem actually filled the partition&lt;/span&gt;
findmnt &lt;span class="nt"&gt;-no&lt;/span&gt; SOURCE,FSTYPE,SIZE,AVAIL /
&lt;span class="nb"&gt;sudo &lt;/span&gt;blockdev &lt;span class="nt"&gt;--getsize64&lt;/span&gt; /dev/disk/by-partlabel/root-a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected signals of success:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;parted print free&lt;/code&gt; shows little or no unexpected trailing free space you intended to claim.&lt;/li&gt;
&lt;li&gt;New partitions have correct PARTLABEL / type GUIDs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;df&lt;/code&gt; size tracks the grown partition after growfs.&lt;/li&gt;
&lt;li&gt;Second &lt;code&gt;systemd-repart --dry-run=yes&lt;/code&gt; reports no changes.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Boundaries (what not to expect)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Use instead / note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shrink or delete partitions in place&lt;/td&gt;
&lt;td&gt;Manual partitioning; factory-reset only for marked partitions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reorder partition numbers&lt;/td&gt;
&lt;td&gt;Not supported; numbers stay stable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MBR disks&lt;/td&gt;
&lt;td&gt;GPT only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grow FS without partition grow&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;resize2fs&lt;/code&gt; / &lt;code&gt;xfs_growfs&lt;/code&gt; / &lt;code&gt;btrfs filesystem resize&lt;/code&gt; alone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writable block integrity&lt;/td&gt;
&lt;td&gt;dm-integrity + integritysetup (different layer)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read-only image authentication at runtime&lt;/td&gt;
&lt;td&gt;dm-verity + veritysetup / UKI measurements&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-file authenticity on mutable FS&lt;/td&gt;
&lt;td&gt;fs-verity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LVM thin provisioning / snapshots&lt;/td&gt;
&lt;td&gt;LVM thin pools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Btrfs send/receive backups&lt;/td&gt;
&lt;td&gt;btrfs tooling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multipath LUNs&lt;/td&gt;
&lt;td&gt;multipathd&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;systemd-repart&lt;/code&gt; owns &lt;strong&gt;GPT shape and optional first-time population&lt;/strong&gt;. Integrity, confidentiality, and backup remain separate layers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Suggested roll-out
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Lab file image&lt;/strong&gt; with &lt;code&gt;--empty=create&lt;/code&gt; until defs look right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spare USB/VM disk&lt;/strong&gt; with &lt;code&gt;--dry-run=yes&lt;/code&gt; then &lt;code&gt;--dry-run=no&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Install defs into the image under &lt;code&gt;/usr/lib/repart.d/&lt;/code&gt; (vendor) or &lt;code&gt;/etc/repart.d/&lt;/code&gt; (local policy).&lt;/li&gt;
&lt;li&gt;Ensure initrd runs &lt;code&gt;systemd-repart.service&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; filesystems that depend on new partitions are mounted (image-based distros usually wire this; classic installer roots may need explicit enablement).&lt;/li&gt;
&lt;li&gt;Pair &lt;code&gt;GrowFileSystem=on&lt;/code&gt; with growfs on first mount.&lt;/li&gt;
&lt;li&gt;Prefer DPS types so &lt;code&gt;systemd-gpt-auto-generator&lt;/code&gt; and nspawn image mounts stay consistent.&lt;/li&gt;
&lt;li&gt;Document that operators must &lt;strong&gt;not&lt;/strong&gt; create conflicting fstab lines for the same mount points if they want auto-discovery.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Preview host root disk&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemd-repart &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt;

&lt;span class="c"&gt;# Apply host root disk&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemd-repart &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt;

&lt;span class="c"&gt;# Build new image&lt;/span&gt;
systemd-repart &lt;span class="nt"&gt;--definitions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./defs &lt;span class="nt"&gt;--empty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;create &lt;span class="nt"&gt;--size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;auto &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no ./disk.raw

&lt;span class="c"&gt;# Force fresh partition table on a file/device (DESTRUCTIVE)&lt;/span&gt;
&lt;span class="c"&gt;# systemd-repart --empty=force --dry-run=no /dev/DISK&lt;/span&gt;

&lt;span class="c"&gt;# Definitions search path (highest precedence first among drop-ins; see man)&lt;/span&gt;
&lt;span class="c"&gt;# /etc/repart.d/*.conf&lt;/span&gt;
&lt;span class="c"&gt;# /run/repart.d/*.conf&lt;/span&gt;
&lt;span class="c"&gt;# /usr/local/lib/repart.d/*.conf&lt;/span&gt;
&lt;span class="c"&gt;# /usr/lib/repart.d/*.conf&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;systemd-repart(8)&lt;/code&gt; — Debian manpages: &lt;a href="https://manpages.debian.org/testing/systemd-repart/systemd-repart.8.en.html" rel="noopener noreferrer"&gt;systemd-repart.8&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;repart.d(5)&lt;/code&gt; — &lt;a href="https://manpages.debian.org/testing/systemd-repart/repart.d.5.en.html" rel="noopener noreferrer"&gt;repart.d.5&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemd-growfs(8)&lt;/code&gt; / &lt;code&gt;systemd-makefs@.service&lt;/code&gt; — &lt;a href="https://manpages.debian.org/testing/systemd/systemd-growfs.8.en.html" rel="noopener noreferrer"&gt;systemd-makefs@.service.8&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemd-gpt-auto-generator(8)&lt;/code&gt; — &lt;a href="https://manpages.debian.org/testing/systemd/systemd-gpt-auto-generator.8.en.html" rel="noopener noreferrer"&gt;generator man page&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;UAPI.2 Discoverable Partitions Specification — &lt;a href="https://uapi-group.org/specifications/specs/discoverable_partitions_specification/" rel="noopener noreferrer"&gt;uapi-group.org&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Related reading on this blog: dm-verity with veritysetup, dm-integrity with integritysetup, fs-verity with fsverity, LVM thin pools, systemd-nspawn images&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Ship a small GPT image. Let the target disk tell you how big it is. Declare the partitions you want in &lt;code&gt;repart.d&lt;/code&gt;, dry-run until the pretty table looks boring, then apply once. That is the whole habit: &lt;strong&gt;layout as config&lt;/strong&gt;, not as a remembered &lt;code&gt;parted&lt;/code&gt; session.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>systemd</category>
      <category>devops</category>
      <category>storage</category>
    </item>
    <item>
      <title>Stop Trusting Mutable Files Blindly: Practical fs-verity with fsverity on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Sat, 05 Sep 2026 05:02:59 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-trusting-mutable-files-blindly-practical-fs-verity-with-fsverity-on-linux-4ddn</link>
      <guid>https://dev.to/lyraalishaikh/stop-trusting-mutable-files-blindly-practical-fs-verity-with-fsverity-on-linux-4ddn</guid>
      <description>&lt;h1&gt;
  
  
  Stop Trusting Mutable Files Blindly: Practical fs-verity with fsverity on Linux
&lt;/h1&gt;

&lt;p&gt;dm-verity is the right hammer when an entire block device is frozen: publish a root hash, open read-only, and fail closed on any flipped sector. That model falls apart the moment you need a &lt;em&gt;writable&lt;/em&gt; filesystem that still hosts a handful of immutable artifacts—model weights, container layers staged on disk, signed policy blobs, package payloads, firmware images, or large archives that get updated independently.&lt;/p&gt;

&lt;p&gt;You could &lt;code&gt;sha256sum&lt;/code&gt; the file before every use. That works, and it is slow for multi-gigabyte objects when you only touch a few pages. You could put the file on a dm-verity volume. That works, and it freezes the whole device every time one file changes.&lt;/p&gt;

&lt;p&gt;fs-verity sits between those extremes. It is a kernel filesystem feature that builds a per-file Merkle tree, marks the file permanently read-only for content, and re-verifies data on every page-in. Userspace drives it with the &lt;code&gt;fsverity&lt;/code&gt; utility from fsverity-utils. Supported filesystems today are &lt;strong&gt;ext4&lt;/strong&gt;, &lt;strong&gt;f2fs&lt;/strong&gt;, and &lt;strong&gt;btrfs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This guide is an operator walkthrough: enable the filesystem feature, protect a file, measure its digest in constant time, prove corruption fails closed, understand authentication options (including why built-in signatures are optional and constrained), and place fs-verity next to dm-verity, dm-integrity, AIDE/debsums, and plain hashes. It is &lt;strong&gt;not&lt;/strong&gt; block-device Merkle verification (dm-verity), writable sector tags (dm-integrity), full-disk encryption (LUKS), or package inventory tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you are actually building
&lt;/h2&gt;

&lt;p&gt;From the kernel fs-verity documentation and &lt;code&gt;fsverity(1)&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Filesystem with verity support&lt;/td&gt;
&lt;td&gt;ext4 (&lt;code&gt;-O verity&lt;/code&gt; / &lt;code&gt;tune2fs -O verity&lt;/code&gt;), f2fs (&lt;code&gt;-O verity&lt;/code&gt;), or btrfs (kernel 5.15+)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Merkle tree + descriptor&lt;/td&gt;
&lt;td&gt;Built at enable time and stored with the file (past &lt;code&gt;i_size&lt;/code&gt; on ext4/f2fs; btree items on btrfs)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fs-verity file digest&lt;/td&gt;
&lt;td&gt;Hash of a descriptor that includes the Merkle root, file size, algorithm, salt—not a naive full-file hash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fsverity enable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Userspace wrapper for &lt;code&gt;FS_IOC_ENABLE_VERITY&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fsverity measure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Constant-time &lt;code&gt;FS_IOC_MEASURE_VERITY&lt;/code&gt; of an already-protected file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optional authentication&lt;/td&gt;
&lt;td&gt;Userspace signature of the digest, IMA appraisal, IPE policy, or (carefully) built-in PKCS#7 signatures&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Theory of operation, condensed from the kernel docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enabling verity builds a Merkle tree over the file’s data blocks (default SHA-256, 4K blocks), persists it, and marks the inode as a verity file.&lt;/li&gt;
&lt;li&gt;After enable, content is immutable: open-for-write and truncate fail with &lt;code&gt;EPERM&lt;/code&gt;. Metadata (owner, mode, timestamps, xattrs), rename, link, and delete still work.&lt;/li&gt;
&lt;li&gt;Reads—including &lt;code&gt;mmap&lt;/code&gt;—are verified against the tree as pages enter the page cache. Bad data fails with &lt;code&gt;EIO&lt;/code&gt; (&lt;code&gt;read&lt;/code&gt;) or &lt;code&gt;SIGBUS&lt;/code&gt; (&lt;code&gt;mmap&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FS_IOC_MEASURE_VERITY&lt;/code&gt; returns the digest in &lt;strong&gt;constant time&lt;/strong&gt;, regardless of file size. That is the operational win over &lt;code&gt;sha256sum&lt;/code&gt; on large sparse-access files.&lt;/li&gt;
&lt;li&gt;Copying or restoring a verity file with ordinary tools &lt;strong&gt;drops&lt;/strong&gt; verity-ness. The feature is meant for files managed in place (package managers, deploy pipelines), not for naive &lt;code&gt;cp&lt;/code&gt;/&lt;code&gt;rsync&lt;/code&gt; of the protected state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Important mental model: &lt;strong&gt;fs-verity alone is integrity, not a complete authentication policy.&lt;/strong&gt; Anyone who can replace a verity file with a non-verity twin can bypass it unless trusted code, IMA, IPE, or another policy requires a verified verity digest before use. The kernel docs are explicit about this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Linux kernel with &lt;code&gt;CONFIG_FS_VERITY&lt;/code&gt; (stock on modern distros). ext4/f2fs support since v5.4; btrfs since v5.15. Flexible Merkle block sizes since v6.3.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fsverity-utils&lt;/code&gt; package providing the &lt;code&gt;fsverity&lt;/code&gt; CLI.&lt;/li&gt;
&lt;li&gt;For ext4: e2fsprogs new enough to set the &lt;code&gt;verity&lt;/code&gt; RO_COMPAT feature (&lt;code&gt;tune2fs -O verity&lt;/code&gt;; feature present since e2fsprogs ~1.45.2).&lt;/li&gt;
&lt;li&gt;Root (or CAP_SYS_ADMIN where needed for filesystem feature enable) and a scratch directory. Prefer a loop-backed ext4 so you never touch production disks.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Debian/Ubuntu&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; fsverity e2fsprogs

&lt;span class="c"&gt;# Fedora&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; fsverity-utils e2fsprogs

fsverity &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="c"&gt;# Expect fsverity-utils (v1.5+ / v1.6 common on current distros)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  1) Build a tiny fs-verity lab on loop-backed ext4
&lt;/h2&gt;

&lt;p&gt;Create a disposable ext4 image, enable the &lt;code&gt;verity&lt;/code&gt; filesystem feature, mount it, and drop a payload file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;LAB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/tmp/fsverity-lab
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 512 MiB scratch image&lt;/span&gt;
&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/zero &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;disk.img &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1M &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;512 &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none
&lt;span class="nb"&gt;sudo &lt;/span&gt;losetup &lt;span class="nt"&gt;-fP&lt;/span&gt; &lt;span class="nt"&gt;--show&lt;/span&gt; disk.img
&lt;span class="nv"&gt;LOOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;losetup &lt;span class="nt"&gt;-j&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/disk.img"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;: &lt;span class="s1"&gt;'NR==1{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Format with the verity RO_COMPAT feature&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mkfs.ext4 &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-O&lt;/span&gt; verity &lt;span class="nt"&gt;-L&lt;/span&gt; fsveritylab &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Confirm the feature bit&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;tune2fs &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'Filesystem features'&lt;/span&gt;
&lt;span class="c"&gt;# Expect "verity" among the features&lt;/span&gt;

&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/fsv
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /mnt/fsv

&lt;span class="c"&gt;# Sample payload — pretend this is a model weight, policy blob, or layer tarball&lt;/span&gt;
python3 - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;'
from pathlib import Path
p = Path('/mnt/fsv/payload.bin')
# ~32 MiB of structured data so Merkle overhead is visible but lab stays fast
chunk = (b'FSVERITY-LAB-PAYLOAD-v1&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;' * 64)
data = chunk * (32 * 1024 * 1024 // len(chunk))
p.write_bytes(data)
print(p, 'bytes', p.stat().st_size)
&lt;/span&gt;&lt;span class="no"&gt;PY

&lt;/span&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;0644 /mnt/fsv/payload.bin
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /mnt/fsv/payload.bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the filesystem already exists without &lt;code&gt;verity&lt;/code&gt;, enable it offline (or carefully online per your e2fsprogs/kernel combo) with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Filesystem should be clean; RO_COMPAT: old kernels will only mount read-only afterward&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /mnt/fsv 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;tune2fs &lt;span class="nt"&gt;-O&lt;/span&gt; verity &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /mnt/fsv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On f2fs, format with &lt;code&gt;-O verity&lt;/code&gt;. On btrfs, no special mkfs flag is required once the kernel is ≥ 5.15—verity metadata lives in separate btree items.&lt;/p&gt;

&lt;h2&gt;
  
  
  2) Enable fs-verity and measure the digest
&lt;/h2&gt;

&lt;p&gt;Nothing is protected until you run enable. The file must not be open for writing anywhere (&lt;code&gt;ETXTBSY&lt;/code&gt; otherwise). Enable builds the Merkle tree and is interruptible by fatal signals; on failure, the file is left unchanged.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Optional: compute the digest userspace-side before enable (same algorithm/params)&lt;/span&gt;
fsverity digest &lt;span class="nt"&gt;--hash-alg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sha256 &lt;span class="nt"&gt;--block-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4096 /mnt/fsv/payload.bin

&lt;span class="c"&gt;# Enable Merkle protection (SHA-256 + 4K blocks are the usual defaults)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;fsverity &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--hash-alg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sha256 &lt;span class="nt"&gt;--block-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4096 /mnt/fsv/payload.bin

&lt;span class="c"&gt;# Constant-time measurement from the kernel&lt;/span&gt;
fsverity measure /mnt/fsv/payload.bin
&lt;span class="c"&gt;# sha256:................................  /mnt/fsv/payload.bin&lt;/span&gt;

&lt;span class="c"&gt;# Cheap presence check without opening the file (statx ATTR_VERITY since Linux 5.5)&lt;/span&gt;
statx &lt;span class="nt"&gt;--help&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;python3 - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;'
import os, ctypes, ctypes.util
# Portable check via FS_IOC_GETFLAGS when available
import fcntl, struct
FS_IOC_GETFLAGS = 0x80086601
FS_VERITY_FL = 0x00100000
fd = os.open('/mnt/fsv/payload.bin', os.O_RDONLY)
try:
    buf = array = bytearray(4)
    # fallback simple: just report measure succeeded above
finally:
    os.close(fd)
print('verity enable completed; measure is the authoritative digest API')
&lt;/span&gt;&lt;span class="no"&gt;PY
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What changed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Content is now read-only at the filesystem layer. Mode bits may still look writable; open-for-write still fails.&lt;/li&gt;
&lt;li&gt;Direct I/O falls back to buffered I/O. DAX is unsupported (would skip verification).&lt;/li&gt;
&lt;li&gt;Metadata changes still succeed. That is intentional—fs-verity measures file &lt;em&gt;contents&lt;/em&gt;, not inode owner/mode.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Try a write to prove the gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Expect: Operation not permitted&lt;/span&gt;
&lt;span class="nb"&gt;echo &lt;/span&gt;mutate | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /mnt/fsv/payload.bin &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"write blocked as expected"&lt;/span&gt;

&lt;span class="c"&gt;# Truncate also fails with EPERM on verity files&lt;/span&gt;
&lt;span class="nb"&gt;sudo truncate&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; 0 /mnt/fsv/payload.bin &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"truncate blocked as expected"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3) Prove silent corruption fails closed
&lt;/h2&gt;

&lt;p&gt;With the file still verity-protected, corrupt raw blocks on the loop device and read through the mounted filesystem. On systems where page cache can be dropped, force a device re-read so verification runs again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Note the file's apparent size and a mid-file byte offset for a lab poke.&lt;/span&gt;
&lt;span class="c"&gt;# Corruption must hit data blocks the next read will fault in.&lt;/span&gt;
&lt;span class="nv"&gt;OFFSET_MIB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8
&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /mnt/fsv
&lt;span class="c"&gt;# Poke the filesystem image past typical superblock/group descriptors.&lt;/span&gt;
&lt;span class="c"&gt;# This is a lab demonstration, not a surgical block editor.&lt;/span&gt;
&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/urandom &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1M &lt;span class="nv"&gt;seek&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OFFSET_MIB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;conv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;notrunc &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none

&lt;span class="nb"&gt;sudo &lt;/span&gt;mount &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /mnt/fsv
&lt;span class="nb"&gt;sync
sudo &lt;/span&gt;sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo 3 &amp;gt; /proc/sys/vm/drop_caches'&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# Read the whole file — expect I/O error when a corrupted page is verified&lt;/span&gt;
&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/mnt/fsv/payload.bin &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/null &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1M &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"unexpected success"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"read failed closed as expected"&lt;/span&gt;

&lt;span class="c"&gt;# mmap consumers should see SIGBUS on bad pages (kernel docs)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What should happen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Untouched pages still verify and read cleanly.&lt;/li&gt;
&lt;li&gt;Pages whose data no longer match the Merkle path fail closed—no silent garbage.&lt;/li&gt;
&lt;li&gt;The digest from &lt;code&gt;fsverity measure&lt;/code&gt; is unchanged; the tree still describes the &lt;em&gt;original&lt;/em&gt; content. Bad sectors simply will not serve.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Restore the lab image before continuing if you want a clean authentication section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /mnt/fsv 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;losetup &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/zero &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;disk.img &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1M &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;512 &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none
&lt;span class="nv"&gt;LOOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;losetup &lt;span class="nt"&gt;-fP&lt;/span&gt; &lt;span class="nt"&gt;--show&lt;/span&gt; disk.img&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mkfs.ext4 &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-O&lt;/span&gt; verity &lt;span class="nt"&gt;-L&lt;/span&gt; fsveritylab &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /mnt/fsv
&lt;span class="c"&gt;# recreate payload + enable as in sections 1–2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4) Digest vs traditional hash: why the format matters
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;fsverity measure&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; return &lt;code&gt;SHA-256(file bytes)&lt;/code&gt;. The kernel hashes an &lt;code&gt;fsverity_descriptor&lt;/code&gt; that binds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Merkle root hash&lt;/li&gt;
&lt;li&gt;file size (&lt;code&gt;data_size&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;hash algorithm and log2(block size)&lt;/li&gt;
&lt;li&gt;optional salt&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That removes ambiguities the plain root hash would have (padding, length confusion). If you sign or pin anything, sign the &lt;strong&gt;fs-verity file digest&lt;/strong&gt; (or the &lt;code&gt;fsverity_formatted_digest&lt;/code&gt; blob when using built-in signatures), not a hand-rolled &lt;code&gt;sha256sum&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Userspace can recompute the same digest without enable (for signing pipelines)&lt;/span&gt;
fsverity digest &lt;span class="nt"&gt;--hash-alg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sha256 &lt;span class="nt"&gt;--block-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4096 /mnt/fsv/payload.bin

&lt;span class="c"&gt;# After enable, measure must match digest for the same parameters&lt;/span&gt;
fsverity measure /mnt/fsv/payload.bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Optional salt personalizes the tree (max 32 bytes). Pass the same salt to &lt;code&gt;digest&lt;/code&gt;, &lt;code&gt;sign&lt;/code&gt;, and &lt;code&gt;enable&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SALT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 16&lt;span class="si"&gt;)&lt;/span&gt;
fsverity digest &lt;span class="nt"&gt;--salt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SALT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /mnt/fsv/payload.bin
&lt;span class="nb"&gt;sudo &lt;/span&gt;fsverity &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--salt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SALT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /mnt/fsv/payload.bin
fsverity measure /mnt/fsv/payload.bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5) Authentication options (pick deliberately)
&lt;/h2&gt;

&lt;p&gt;Integrity without authentication only detects accidental corruption (and some classes of malicious disk firmware behavior on already-measured files). To stop &lt;em&gt;substitution&lt;/em&gt; attacks you need a policy around the digest. The kernel documents four common patterns:&lt;/p&gt;

&lt;h3&gt;
  
  
  A) Trusted userspace (most flexible default)
&lt;/h3&gt;

&lt;p&gt;Measure the digest and compare it to a value you already trust—an allow-list shipped with your orchestrator, a signature you verify with OpenSSL, or a digest embedded in a dm-verity-protected rootfs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;EXPECTED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'sha256:YOUR_PINNED_DIGEST_HEX_HERE'&lt;/span&gt;
&lt;span class="nv"&gt;GOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;fsverity measure /mnt/fsv/payload.bin&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GOT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXPECTED&lt;/span&gt;&lt;span class="s2"&gt; /mnt/fsv/payload.bin"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GOT&lt;/span&gt;&lt;span class="p"&gt;%% *&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;EXPECTED&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"digest mismatch"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A minimal deploy gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;:?file&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;PIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;:?expected-sha256-hex&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;GOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;fsverity measure &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# GOT looks like sha256:&amp;lt;hex&amp;gt;&lt;/span&gt;
&lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GOT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"sha256:&lt;/span&gt;&lt;span class="nv"&gt;$PIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"reject: &lt;/span&gt;&lt;span class="nv"&gt;$GOT&lt;/span&gt;&lt;span class="s2"&gt; != sha256:&lt;/span&gt;&lt;span class="nv"&gt;$PIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ok: &lt;/span&gt;&lt;span class="nv"&gt;$FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  B) IMA appraisal
&lt;/h3&gt;

&lt;p&gt;IMA can use fs-verity digests instead of full-file hashes and enforce signatures in &lt;code&gt;security.ima&lt;/code&gt; under an IMA policy. Prefer this when you already run IMA appraisal fleet-wide. Setup is distribution-specific (policy loading, keyrings, appraisal modes) and larger than this article—see the kernel IMA docs once the digest workflow above is solid.&lt;/p&gt;

&lt;h3&gt;
  
  
  C) IPE (Integrity Policy Enforcement)
&lt;/h3&gt;

&lt;p&gt;IPE can authorize access based on &lt;code&gt;fsverity_digest&lt;/code&gt; or a verified built-in &lt;code&gt;fsverity_signature&lt;/code&gt;. Useful when you want kernel-enforced execution/access policy tied to verity properties. Requires an IPE-enabled kernel and loaded policy.&lt;/p&gt;

&lt;h3&gt;
  
  
  D) Built-in PKCS#7 signatures (use with care)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;CONFIG_FS_VERITY_BUILTIN_SIGNATURES&lt;/code&gt; adds a &lt;code&gt;.fs-verity&lt;/code&gt; keyring and optional in-kernel signature check at open. The kernel docs warn hard: this is &lt;strong&gt;not&lt;/strong&gt; a complete authentication policy by itself, keys are global, PKCS#7/X.509 parsing expands attack surface, Ed25519 is not available in-kernel for this path, and certificate validity times are not checked. Prefer userspace signatures unless you specifically need IPE integration with built-in signatures.&lt;/p&gt;

&lt;p&gt;If you still need the built-in path for lab or IPE work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Generate a disposable key + cert (lab only)&lt;/span&gt;
openssl req &lt;span class="nt"&gt;-newkey&lt;/span&gt; rsa:4096 &lt;span class="nt"&gt;-nodes&lt;/span&gt; &lt;span class="nt"&gt;-keyout&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/key.pem"&lt;/span&gt; &lt;span class="nt"&gt;-x509&lt;/span&gt; &lt;span class="nt"&gt;-out&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/cert.pem"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-days&lt;/span&gt; 365 &lt;span class="nt"&gt;-subj&lt;/span&gt; &lt;span class="s2"&gt;"/CN=fsverity-lab/"&lt;/span&gt;

&lt;span class="c"&gt;# Sign → PKCS#7 DER detached signature of the formatted digest&lt;/span&gt;
fsverity sign &lt;span class="nt"&gt;--key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/key.pem"&lt;/span&gt; &lt;span class="nt"&gt;--cert&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/cert.pem"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  /mnt/fsv/payload.bin &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/payload.sig"&lt;/span&gt;

&lt;span class="c"&gt;# Enable with embedded signature (must happen in the same enable step)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;fsverity &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--signature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/payload.sig"&lt;/span&gt; /mnt/fsv/payload.bin

&lt;span class="c"&gt;# Load certificate into the .fs-verity keyring (root). Exact keyctl plumbing&lt;/span&gt;
&lt;span class="c"&gt;# varies; many operators use a small helper or keyctl padd asymmetric ...&lt;/span&gt;
&lt;span class="c"&gt;# Consult current kernel docs for add_key() to ".fs-verity".&lt;/span&gt;

&lt;span class="c"&gt;# Optional system-wide gate: refuse unsigned verity files on open&lt;/span&gt;
&lt;span class="c"&gt;# sudo sysctl -w fs.verity.require_signatures=1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Built-in signatures cannot be rotated in place: changing the signature means recreating the file and re-enabling verity.&lt;/p&gt;

&lt;h2&gt;
  
  
  6) Day-2 operations operators forget
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Copying kills verity.&lt;/strong&gt; &lt;code&gt;cp&lt;/code&gt;, &lt;code&gt;rsync&lt;/code&gt;, tar, and most backup agents restore bytes without Merkle metadata. After restore you must &lt;code&gt;fsverity enable&lt;/code&gt; again (and re-apply any signature policy). Design deploy pipelines to enable on the destination, not to ship “already verity” files as ordinary blobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ext4/f2fs storage layout.&lt;/strong&gt; Metadata lives past &lt;code&gt;i_size&lt;/code&gt; starting at the next 64K boundary. Userspace does not see it via normal reads; &lt;code&gt;ls -l&lt;/code&gt; size is still the logical file size. On encrypted ext4 files, plaintext is what gets verified (digest stays meaningful across per-file keys).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;btrfs.&lt;/strong&gt; Supported since Linux 5.15 with metadata in btree items and a RO_COMPAT inode flag. Same userspace &lt;code&gt;fsverity&lt;/code&gt; CLI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance.&lt;/strong&gt; Sequential reads are cheap: hash blocks cache well (with SHA-256/4K, ~127/128 data blocks reuse a cached lower hash block). Random reads pay more tree walks. Enabling on multi-gigabyte files can take noticeable CPU/I/O time once; measure afterward is O(1).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What fs-verity does not stop:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deleting the file and dropping a lookalike without verity (unless policy forbids it)&lt;/li&gt;
&lt;li&gt;Renaming paths out from under a careless consumer&lt;/li&gt;
&lt;li&gt;Compromised trusted userspace that “forgets” to call &lt;code&gt;measure&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Writable corruption on non-verity files sitting next to protected ones&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7) systemd oneshot pattern for a digest gate
&lt;/h2&gt;

&lt;p&gt;Pin critical artifacts after deploy and fail the unit if digests drift:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/fsverity/pins.list  (format: absolute-path  sha256-hex)&lt;/span&gt;
&lt;span class="c"&gt;# /var/lib/models/weights.bin  0123abc...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/fsverity-gate.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Verify fs-verity digests for pinned files&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;local-fs.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/sbin/fsverity-gate.sh&lt;/span&gt;
&lt;span class="c"&gt;# Nice + IOSchedulingClass=best-effort are optional on large fleets
&lt;/span&gt;
&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /usr/local/sbin/fsverity-gate.sh&lt;/span&gt;
&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; path pin&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;~ ^# &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue
  &lt;/span&gt;&lt;span class="nv"&gt;got&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;fsverity measure &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$got&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"sha256:&lt;/span&gt;&lt;span class="nv"&gt;$pin&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"fsverity-gate: FAIL &lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="nv"&gt;$got&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
  &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"fsverity-gate: ok &lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &amp;lt; /etc/fsverity/pins.list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;0755 /usr/local/sbin/fsverity-gate.sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; fsverity-gate.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pair this with a deploy hook that runs &lt;code&gt;fsverity enable&lt;/code&gt; immediately after writing the artifact and updates &lt;code&gt;pins.list&lt;/code&gt; atomically.&lt;/p&gt;

&lt;h2&gt;
  
  
  8) Clean rollback for the lab
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /mnt/fsv 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nv"&gt;LOOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;losetup &lt;span class="nt"&gt;-j&lt;/span&gt; /var/tmp/fsverity-lab/disk.img | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;: &lt;span class="s1"&gt;'NR==1{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;losetup &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/tmp/fsverity-lab
&lt;span class="c"&gt;# Remove any lab keys/sigs you created under $LAB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Production rollback is “replace the file from a trusted pipeline and re-enable verity,” not “clear a flag.” The verity inode flag is not toggleable via &lt;code&gt;chattr&lt;/code&gt;; only &lt;code&gt;FS_IOC_ENABLE_VERITY&lt;/code&gt; sets it, and clearing it is not supported—you replace the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision guide: fs-verity vs the neighbors
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Whole block device / image matches a published root hash&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;dm-verity&lt;/strong&gt; + &lt;code&gt;veritysetup&lt;/code&gt; / &lt;code&gt;veritytab&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Individual immutable files on a &lt;strong&gt;writable&lt;/strong&gt; filesystem&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;fs-verity&lt;/strong&gt; + &lt;code&gt;fsverity&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writable volume detects silent sector corruption&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;dm-integrity&lt;/strong&gt; / &lt;code&gt;integritysetup&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Confidentiality (who can read)&lt;/td&gt;
&lt;td&gt;LUKS / dm-crypt (stacks with ext4 verity: plaintext verified)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Package/config drift inventories&lt;/td&gt;
&lt;td&gt;debsums, AIDE, audit watches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filesystem scrub of checksum trees&lt;/td&gt;
&lt;td&gt;Btrfs scrub, ZFS scrub&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One-shot full-file hash in a script&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sha256sum&lt;/code&gt; (simple; no per-read re-verify; costly on huge sparse-access files)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;fs-verity does not replace backups, Secure Boot, or transport signatures. It replaces the failure mode where a multi-gigabyte artifact is either fully re-hashed on every start or left unchecked between rare inventory scans.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical recommendations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Lab on loop-backed ext4 first. Confirm enable / measure / write-block / corruption behavior before production paths.&lt;/li&gt;
&lt;li&gt;Pin &lt;strong&gt;fs-verity digests&lt;/strong&gt;, not ad-hoc &lt;code&gt;sha256sum&lt;/code&gt; outputs, when you integrate signing or allow-lists.&lt;/li&gt;
&lt;li&gt;Prefer &lt;strong&gt;userspace&lt;/strong&gt; signature verification or IMA/IPE over built-in PKCS#7 unless you have a concrete IPE requirement and accept the limitations the kernel documents.&lt;/li&gt;
&lt;li&gt;Enable verity &lt;strong&gt;on the destination host&lt;/strong&gt; as the last deploy step; do not assume copies preserve protection.&lt;/li&gt;
&lt;li&gt;Keep a clear policy for non-verity substitutes—measure alone is not a mandatory access control.&lt;/li&gt;
&lt;li&gt;For whole root filesystems and golden disk images, keep using &lt;strong&gt;dm-verity&lt;/strong&gt;. For independently updated files on RW data volumes, use &lt;strong&gt;fs-verity&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources and references
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Linux kernel documentation: &lt;em&gt;fs-verity: read-only file-based authenticity protection&lt;/em&gt; — &lt;a href="https://www.kernel.org/doc/html/latest/filesystems/fsverity.html" rel="noopener noreferrer"&gt;https://www.kernel.org/doc/html/latest/filesystems/fsverity.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fsverity(1)&lt;/code&gt; (fsverity-utils) — Debian man page: &lt;a href="https://manpages.debian.org/testing/fsverity/fsverity.1.en.html" rel="noopener noreferrer"&gt;https://manpages.debian.org/testing/fsverity/fsverity.1.en.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;fsverity-utils project / README (examples and signing notes) — &lt;a href="https://git.kernel.org/pub/scm/fs/fsverity/fsverity-utils.git" rel="noopener noreferrer"&gt;https://git.kernel.org/pub/scm/fs/fsverity/fsverity-utils.git&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;ext4 feature enablement: &lt;code&gt;tune2fs(8)&lt;/code&gt; / &lt;code&gt;mkfs.ext4 -O verity&lt;/code&gt; (e2fsprogs)&lt;/li&gt;
&lt;li&gt;Related operator guides on this blog: dm-verity with veritysetup; dm-integrity with integritysetup (writable sector integrity)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Protect the files that must not drift—without freezing the entire disk to do it.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>storage</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stop Silent Bit Rot on Writable Disks: Practical dm-integrity with integritysetup on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Fri, 04 Sep 2026 05:03:14 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-silent-bit-rot-on-writable-disks-practical-dm-integrity-with-integritysetup-on-linux-2pif</link>
      <guid>https://dev.to/lyraalishaikh/stop-silent-bit-rot-on-writable-disks-practical-dm-integrity-with-integritysetup-on-linux-2pif</guid>
      <description>&lt;h1&gt;
  
  
  Stop Silent Bit Rot on Writable Disks: Practical dm-integrity with integritysetup on Linux
&lt;/h1&gt;

&lt;p&gt;dm-verity is perfect when the payload is frozen: publish a root hash, mount read-only, fail closed on any flipped sector. Most day-to-day volumes are &lt;strong&gt;not&lt;/strong&gt; frozen. Databases, home directories, backup landing zones, and container layers keep changing. For those, you need a different property: &lt;strong&gt;writable block devices that still notice silent corruption&lt;/strong&gt;—and optionally cryptographically authenticate every sector without pretending checksums are encryption.&lt;/p&gt;

&lt;p&gt;That is what &lt;strong&gt;dm-integrity&lt;/strong&gt; does. The kernel device-mapper &lt;code&gt;integrity&lt;/code&gt; target stores a per-sector tag (CRC, hash, or HMAC) and verifies it on every read. Userspace formats and activates it with &lt;strong&gt;&lt;code&gt;integritysetup&lt;/code&gt;&lt;/strong&gt; from the cryptsetup project. systemd can bring devices up at boot via &lt;strong&gt;&lt;code&gt;/etc/integritytab&lt;/code&gt;&lt;/strong&gt; and &lt;code&gt;systemd-integritysetup-generator&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This guide is an operator walkthrough: format a standalone integrity device, choose journal vs bitmap vs direct mode, prove corruption is blocked, optionally use HMAC tags, wire &lt;code&gt;integritytab&lt;/code&gt;, and understand how this sits next to LUKS authenticated encryption and dm-verity. It is &lt;strong&gt;not&lt;/strong&gt; read-only Merkle verification (dm-verity), full-disk confidentiality alone (plain LUKS without integrity), filesystem scrub (Btrfs/ZFS), or package inventories (AIDE/debsums). Here the unit of work is a &lt;strong&gt;writable authenticated block device&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you are actually building
&lt;/h2&gt;

&lt;p&gt;From the kernel dm-integrity docs and &lt;code&gt;integritysetup(8)&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Backing device&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Partition, LV, or loop file that holds data &lt;strong&gt;and&lt;/strong&gt; integrity metadata (or metadata alone when using &lt;code&gt;--data-device&lt;/code&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Integrity tags&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Per-sector CRC/hash/HMAC stored by the target. Standalone mode computes them internally; stacked mode can accept tags from dm-crypt.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Journal / bitmap / direct / inline&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Crash and performance policy for keeping data and tags consistent.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;dm-integrity mapping&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Kernel target under &lt;code&gt;/dev/mapper/&amp;lt;name&amp;gt;&lt;/code&gt;. Applications mount &lt;strong&gt;this&lt;/strong&gt;, never the raw backing device, if they want checks.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Optional key&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Required for HMAC (and for LUKS2 authenticated encryption). CRC-only mode detects accidental corruption, not a keyed attacker.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Theory of operation (kernel docs, condensed):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Writing a sector and its tag must be atomic. The default &lt;strong&gt;journal&lt;/strong&gt; mode writes data+tag to a journal, commits, then copies them to their final locations so a crash cannot leave a sector with a stale tag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bitmap&lt;/strong&gt; mode (kernel 5.2+) skips the double-write: dirty regions are marked and recalculated after a crash. Faster, less reliable if corruption lands exactly during the crash window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct&lt;/strong&gt; mode (&lt;code&gt;-D&lt;/code&gt; / no journal) writes data and tags separately. After a crash, mismatches are possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inline&lt;/strong&gt; mode (kernel 6.11+) stores tags in hardware DIF/PI fields when the device exposes a usable integrity profile—no journal/bitmap overhead when the hardware path is real.&lt;/li&gt;
&lt;li&gt;Standalone mode with &lt;code&gt;internal_hash&lt;/code&gt; detects silent disk/I/O path corruption. HMAC mode authenticates data without encrypting it. Stacked with dm-crypt, tags can provide &lt;strong&gt;authenticated encryption&lt;/strong&gt; so modified ciphertext fails instead of decrypting to garbage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Important mental model: &lt;strong&gt;dm-integrity is not a backup, not a filesystem, and not a substitute for LUKS confidentiality.&lt;/strong&gt; CRC tags catch bit flips. HMAC tags catch unauthenticated modification if the key stays secret. Confidentiality still needs dm-crypt/LUKS. Immutable image trust still needs dm-verity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Kernel with &lt;code&gt;dm-integrity&lt;/code&gt; (stock on modern distros; bitmap needs 5.2+, discards 5.7+, resize-up 5.7+, recalculate-reset 5.13+, inline 6.11+).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cryptsetup&lt;/code&gt; package that ships &lt;code&gt;integritysetup&lt;/code&gt; (Debian/Ubuntu: &lt;code&gt;cryptsetup&lt;/code&gt; / &lt;code&gt;cryptsetup-bin&lt;/code&gt;; Fedora/RHEL: &lt;code&gt;cryptsetup&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;systemd with &lt;code&gt;integritytab&lt;/code&gt; support (options expanded significantly around v250+; &lt;code&gt;mode=&lt;/code&gt; in v254; &lt;code&gt;_netdev&lt;/code&gt;/&lt;code&gt;noauto&lt;/code&gt;/&lt;code&gt;nofail&lt;/code&gt; in newer releases).&lt;/li&gt;
&lt;li&gt;Root shell and a scratch directory. Prefer loop files for the lab so you never touch production disks.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Debian/Ubuntu&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; cryptsetup

&lt;span class="c"&gt;# Fedora&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; cryptsetup

integritysetup &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="c"&gt;# Expect integritysetup from cryptsetup 2.x+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On systems where module introspection is available:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lsmod | &lt;span class="nb"&gt;grep &lt;/span&gt;dm_integrity &lt;span class="o"&gt;||&lt;/span&gt; modprobe dm-integrity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  1) Build a tiny writable integrity lab
&lt;/h2&gt;

&lt;p&gt;Create a loop-backed device, format it with default standalone CRC32C tags, open a mapper, and put a filesystem on the &lt;strong&gt;mapper&lt;/strong&gt;—not the raw file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;LAB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/tmp/integrity-lab
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 1 GiB scratch image (metadata + journal eat some capacity)&lt;/span&gt;
&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/zero &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;disk.img &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1M &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1024 &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none
&lt;span class="nb"&gt;sudo &lt;/span&gt;losetup &lt;span class="nt"&gt;-fP&lt;/span&gt; &lt;span class="nt"&gt;--show&lt;/span&gt; disk.img
&lt;span class="nv"&gt;LOOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;losetup &lt;span class="nt"&gt;-j&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/disk.img"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;: &lt;span class="s1"&gt;'NR==1{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Format: calculates superblock/journal layout and wipes tags&lt;/span&gt;
&lt;span class="c"&gt;# Default standalone algorithm is crc32c&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup format &lt;span class="nt"&gt;--batch-mode&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Inspect on-disk superblock parameters&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup dump &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;format&lt;/code&gt; is destructive for the target layout: it writes the dm-integrity superblock, reserves journal/tag space, and (unless &lt;code&gt;--no-wipe&lt;/code&gt;) initializes tags so the device starts consistent.&lt;/p&gt;

&lt;p&gt;Open the mapping and build a normal filesystem on top:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup open &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; integ-lab
&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup status integ-lab
&lt;span class="c"&gt;# Expect /dev/mapper/integ-lab, journaled mode by default&lt;/span&gt;

&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /dev/mapper/integ-lab
&lt;span class="nb"&gt;sudo &lt;/span&gt;mkfs.ext4 &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; integlab /dev/mapper/integ-lab

&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/integ
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount /dev/mapper/integ-lab /mnt/integ
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'integrity payload v1'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /mnt/integ/README &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;span class="nb"&gt;sync
cat&lt;/span&gt; /mnt/integ/README
&lt;span class="c"&gt;# integrity payload v1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Capacity note: the mapper’s usable size is &lt;strong&gt;smaller&lt;/strong&gt; than the backing image. That is intentional—journal, superblock, and tag areas are carved out. &lt;code&gt;integritysetup status&lt;/code&gt; and &lt;code&gt;dump&lt;/code&gt; report the provided data sectors the filesystem actually sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  2) Prove silent corruption fails closed
&lt;/h2&gt;

&lt;p&gt;Keep the filesystem mounted (or at least leave the mapper open). Corrupt a data sector on the &lt;strong&gt;raw&lt;/strong&gt; loop device, then read through the integrity mapping.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Find a data offset past the superblock/journal region.&lt;/span&gt;
&lt;span class="c"&gt;# Using a mid-device poke is enough for a lab demonstration.&lt;/span&gt;
&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/urandom &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;512 &lt;span class="nv"&gt;seek&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;200000 &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;conv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;notrunc &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none

&lt;span class="c"&gt;# Drop page cache so the read hits the device (lab host)&lt;/span&gt;
&lt;span class="nb"&gt;sync
sudo &lt;/span&gt;sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo 3 &amp;gt; /proc/sys/vm/drop_caches'&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# Read via the integrity device — expect I/O error on affected ranges&lt;/span&gt;
&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/mapper/integ-lab &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/null &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1M &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"read failed as expected"&lt;/span&gt;

&lt;span class="c"&gt;# dmesg / journal often records integrity failure lines on systems where&lt;/span&gt;
&lt;span class="c"&gt;# kernel logging for dm-integrity is enabled&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 50 &lt;span class="nt"&gt;--no-pager&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; integrity &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What should happen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reads of untouched regions still succeed.&lt;/li&gt;
&lt;li&gt;Reads that touch the corrupted sector return &lt;strong&gt;I/O error&lt;/strong&gt; instead of silent garbage.&lt;/li&gt;
&lt;li&gt;The filesystem may remount read-only or show errors depending on which blocks were hit—that is the point of fail-closed integrity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Recovery mode exists when you need to salvage data without tag checks (read-only, no journal replay):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /mnt/integ 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;integritysetup close integ-lab

&lt;span class="c"&gt;# Recovery open: no tag checking, writes disallowed&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup open &lt;span class="nt"&gt;--integrity-recovery-mode&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; integ-recovery
&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup status integ-recovery
&lt;span class="c"&gt;# Copy what you can, then close and restore from backup&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup close integ-recovery
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do &lt;strong&gt;not&lt;/strong&gt; treat recovery mode as normal operations. It is a last-resort read path when the device will not activate cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  3) Journal vs bitmap vs direct: pick the failure model
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;How it works&lt;/th&gt;
&lt;th&gt;Tradeoff&lt;/th&gt;
&lt;th&gt;When to use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Journal (default)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Data+tags journaled, then committed to final location&lt;/td&gt;
&lt;td&gt;~2× write amplification; crash-safe atomicity&lt;/td&gt;
&lt;td&gt;Default for most integrity volumes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bitmap (&lt;code&gt;-B&lt;/code&gt;)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dirty-region bitmap; post-crash recalculation&lt;/td&gt;
&lt;td&gt;Faster writes; crash-window corruption may be missed&lt;/td&gt;
&lt;td&gt;Performance-sensitive internal disks you still want checksums on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Direct (&lt;code&gt;-D&lt;/code&gt;)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Independent data/tag writes&lt;/td&gt;
&lt;td&gt;Fastest; crash can desync tags&lt;/td&gt;
&lt;td&gt;Special cases only; understand the risk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Inline (6.11+)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tags in hardware PI/DIF fields&lt;/td&gt;
&lt;td&gt;Native speed when hardware cooperates&lt;/td&gt;
&lt;td&gt;NVMe/SCSI with usable integrity profile&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Re-open examples (re-format if you change layout-defining options; mode can often be selected at open for journal/bitmap/direct depending on how the volume was prepared):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Bitmap mode open (after a volume formatted for internal hash use)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup open &lt;span class="nt"&gt;--integrity-bitmap-mode&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; integ-lab

&lt;span class="c"&gt;# Direct / no-journal open&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup open &lt;span class="nt"&gt;--integrity-no-journal&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; integ-lab

&lt;span class="c"&gt;# Tune journal behavior on journaled opens&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup open &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--journal-watermark&lt;/span&gt; 50 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--journal-commit-time&lt;/span&gt; 10000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; integ-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From &lt;code&gt;integritytab(5)&lt;/code&gt; and the kernel docs: journal watermark is a percent that triggers flush; commit time is milliseconds before a background journal write when no explicit flush arrived. Bitmap mode is explicitly documented as &lt;strong&gt;less reliable&lt;/strong&gt; if corruption coincides with a crash—because unsynchronized regions are recalculated, not proven against a prior commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  4) HMAC tags: accidental vs adversarial modification
&lt;/h2&gt;

&lt;p&gt;Default CRC32C is a &lt;strong&gt;checksum&lt;/strong&gt;. It catches bit rot and many I/O path glitches. It does &lt;strong&gt;not&lt;/strong&gt; stop someone who can write the raw device and recompute CRCs.&lt;/p&gt;

&lt;p&gt;For keyed authentication without encryption:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 32-byte key material (store in a real secret path / TPM / sealed file in production)&lt;/span&gt;
&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/urandom &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/hmac.key"&lt;/span&gt; &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;32 &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;0400 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/hmac.key"&lt;/span&gt;

&lt;span class="c"&gt;# Separate lab image for HMAC demo&lt;/span&gt;
&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/zero &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;hmac.img &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1M &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;512 &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none
&lt;span class="nb"&gt;sudo &lt;/span&gt;losetup &lt;span class="nt"&gt;-fP&lt;/span&gt; &lt;span class="nt"&gt;--show&lt;/span&gt; hmac.img
&lt;span class="nv"&gt;HLOOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;losetup &lt;span class="nt"&gt;-j&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/hmac.img"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;: &lt;span class="s1"&gt;'NR==1{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup format &lt;span class="nt"&gt;--batch-mode&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--integrity&lt;/span&gt; hmac-sha256 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--integrity-key-file&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/hmac.key"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--integrity-key-size&lt;/span&gt; 32 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tag-size&lt;/span&gt; 32 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HLOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup open &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--integrity&lt;/span&gt; hmac-sha256 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--integrity-key-file&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/hmac.key"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--integrity-key-size&lt;/span&gt; 32 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HLOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; integ-hmac

&lt;span class="nb"&gt;sudo &lt;/span&gt;mkfs.ext4 &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; integhmac /dev/mapper/integ-hmac
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes that matter operationally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Algorithm must be passed again on &lt;strong&gt;open&lt;/strong&gt; for non-default integrity functions—&lt;code&gt;integritysetup&lt;/code&gt; does not always auto-detect standalone algorithm choice from the superblock the way you might expect.&lt;/li&gt;
&lt;li&gt;Maximum integrity key size is &lt;strong&gt;4096 bytes&lt;/strong&gt; per &lt;code&gt;integritysetup(8)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Kernel docs warn that &lt;strong&gt;recalculating HMAC volumes&lt;/strong&gt; is disabled by default (&lt;code&gt;legacy_recalculate&lt;/code&gt;) because an attacker could reset the recalculation offset and force the kernel to bless modified data. Do not enable legacy recalculate paths casually.&lt;/li&gt;
&lt;li&gt;Journal encryption/MAC options exist for testing and layered threat models; the man page is blunt that journal encryption alone without data encryption is usually not a meaningful production design.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5) Separate data device and background recalculate
&lt;/h2&gt;

&lt;p&gt;If you already have a data disk and want tags/journal on a second device:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Conceptual pattern from integritysetup(8)&lt;/span&gt;
&lt;span class="c"&gt;# DATA = existing payload device (will not be wiped if you pass --no-wipe)&lt;/span&gt;
&lt;span class="c"&gt;# META = device that will hold superblock, journal, and tags&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup format &lt;span class="nt"&gt;--batch-mode&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-device&lt;/span&gt; /dev/disk/by-id/data-disk &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-wipe&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  /dev/disk/by-id/meta-disk

&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup open &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-device&lt;/span&gt; /dev/disk/by-id/data-disk &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--integrity-recalculate&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  /dev/disk/by-id/meta-disk integ-data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--integrity-recalculate&lt;/code&gt; lets the kernel fill tags in the background while the device is usable; protection is complete only when recalculation finishes. &lt;code&gt;integritysetup dump&lt;/code&gt; exposes the recalculation offset so you can watch progress. &lt;code&gt;--integrity-recalculate-reset&lt;/code&gt; (kernel 5.13+) restarts from the beginning—useful when changing checksum function &lt;strong&gt;without&lt;/strong&gt; changing tag length.&lt;/p&gt;

&lt;h2&gt;
  
  
  6) Boot activation with &lt;code&gt;/etc/integritytab&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;systemd-integritysetup-generator&lt;/code&gt; turns &lt;code&gt;/etc/integritytab&lt;/code&gt; into &lt;code&gt;systemd-integritysetup@.service&lt;/code&gt; units early at boot—parallel to &lt;code&gt;crypttab&lt;/code&gt; / &lt;code&gt;veritytab&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Line format (&lt;code&gt;integritytab(5)&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;volume-name  block-device  [keyfile|-]  [options|-]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/integritytab&lt;/span&gt;
&lt;span class="c"&gt;# CRC journaled volume by PARTUUID, allow TRIM (kernel 5.7+)&lt;/span&gt;
scratch &lt;span class="nv"&gt;PARTUUID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4973d0b8-1b15-c449-96ec-94bab7f6a7b8 - allow-discards,journal-watermark&lt;span class="o"&gt;=&lt;/span&gt;55%,journal-commit-time&lt;span class="o"&gt;=&lt;/span&gt;10

&lt;span class="c"&gt;# Defaults only&lt;/span&gt;
data &lt;span class="nv"&gt;PARTUUID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5d4b1808-be76-774d-88af-03c4c3a41761

&lt;span class="c"&gt;# HMAC key file (absolute path). Algorithm defaults toward hmac-sha256 when a key file is present&lt;/span&gt;
secure &lt;span class="nv"&gt;PARTUUID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;11111111-2222-3333-4444-555555555555 /etc/integrity/hmac.key integrity-algorithm&lt;span class="o"&gt;=&lt;/span&gt;hmac-sha256

&lt;span class="c"&gt;# Tags on one device, payload on another&lt;/span&gt;
home &lt;span class="nv"&gt;PARTUUID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee - data-device&lt;span class="o"&gt;=&lt;/span&gt;/dev/disk/by-uuid/9276d9c0-d4e3-4297-b4ff-3307cd0d092f
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful options from the man page:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mode=journal|bitmap|direct&lt;/code&gt; (systemd v254+)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;allow-discards&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;journal-watermark=&lt;/code&gt; / &lt;code&gt;journal-commit-time=&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;data-device=&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;integrity-algorithm=&lt;/code&gt; (&lt;code&gt;crc32c&lt;/code&gt;, &lt;code&gt;crc32&lt;/code&gt;, &lt;code&gt;xxhash64&lt;/code&gt;, &lt;code&gt;sha1&lt;/code&gt;, &lt;code&gt;sha256&lt;/code&gt;, &lt;code&gt;hmac-sha256&lt;/code&gt;, &lt;code&gt;hmac-sha512&lt;/code&gt;, &lt;code&gt;phmac-sha256&lt;/code&gt;, &lt;code&gt;phmac-sha512&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;_netdev&lt;/code&gt;, &lt;code&gt;noauto&lt;/code&gt;, &lt;code&gt;nofail&lt;/code&gt; on newer systemd&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After editing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="c"&gt;# Generator creates integritysetup units; exact unit name follows volume-name&lt;/span&gt;
systemctl status systemd-integritysetup@scratch.service 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /dev/mapper/scratch 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pair fstab mounts with matching &lt;code&gt;_netdev&lt;/code&gt; / &lt;code&gt;nofail&lt;/code&gt; semantics when the integrity device itself uses those flags, or you can create dependency loops and local-fs boot stalls—the integritytab man page calls this out explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  7) LUKS2 authenticated encryption (stacked path)
&lt;/h2&gt;

&lt;p&gt;Standalone dm-integrity authenticates (or checksums) &lt;strong&gt;plaintext&lt;/strong&gt; on a writable device. If you also need confidentiality, the stacked pattern is &lt;strong&gt;LUKS2 + integrity&lt;/strong&gt;, where dm-crypt generates integrity tags and dm-integrity stores/verifies them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cryptsetup-luksFormat(8)&lt;/code&gt; documents &lt;code&gt;--integrity&lt;/code&gt; as an &lt;strong&gt;experimental&lt;/strong&gt; LUKS2 extension that requires the dm-integrity target. Native AEAD modes additionally need userspace AEAD support in the kernel crypto API (&lt;code&gt;CONFIG_CRYPTO_USER_API_AEAD&lt;/code&gt;). Read the AUTHENTICATED DISK ENCRYPTION section in &lt;code&gt;cryptsetup(8)&lt;/code&gt; for the pairing your build supports before converting anything important.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Illustrative LUKS2 format with integrity tags&lt;/span&gt;
&lt;span class="c"&gt;# WARNING: experimental; verify cipher/integrity pairing for your cryptsetup + kernel&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;cryptsetup luksFormat &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt; luks2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--integrity&lt;/span&gt; hmac-sha256 &lt;span class="se"&gt;\&lt;/span&gt;
  /dev/disk/by-id/your-disk

&lt;span class="c"&gt;# Optional: skip initial tag wipe only if you accept unread sectors failing until first write&lt;/span&gt;
&lt;span class="c"&gt;# sudo cryptsetup luksFormat --type luks2 --integrity hmac-sha256 --integrity-no-wipe ...&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;cryptsetup open /dev/disk/by-id/your-disk crypt-auth
&lt;span class="nb"&gt;sudo &lt;/span&gt;mkfs.ext4 &lt;span class="nt"&gt;-L&lt;/span&gt; cryptauth /dev/mapper/crypt-auth
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this buys you (kernel dm-integrity + dm-crypt docs):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Modified ciphertext should produce &lt;strong&gt;I/O errors&lt;/strong&gt;, not silently decrypt to attacker-controlled plaintext.&lt;/li&gt;
&lt;li&gt;You still manage LUKS keys/slots as usual (&lt;code&gt;cryptenroll&lt;/code&gt;, passphrases, TPM2, FIDO2—covered in other operational guides).&lt;/li&gt;
&lt;li&gt;Journaled integrity under LUKS can guarantee write atomicity for the encryption sector, at the cost of writing data twice.&lt;/li&gt;
&lt;li&gt;Space overhead and write cost rise; benchmark on your storage before converting large fleets.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you only need “don’t silently serve bit-flipped blocks” on an already-trusted host, standalone CRC integrity is simpler and has no key hierarchy. If the threat includes offline disk tampering &lt;strong&gt;and&lt;/strong&gt; confidentiality, evaluate LUKS2 &lt;code&gt;--integrity&lt;/code&gt; (experimental) or an AEAD setup your cryptsetup build documents—not CRC alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  8) Discards, resize, and day-2 ops
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# TRIM through integrity (internal-hash volumes; kernel 5.7+)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup open &lt;span class="nt"&gt;--allow-discards&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; integ-lab
&lt;span class="c"&gt;# Prefer keyed discard semantics on new keyed volumes when your cryptsetup/kernel&lt;/span&gt;
&lt;span class="c"&gt;# documents allow_discards_keyed — constant filler tags are forgeable without the key.&lt;/span&gt;

&lt;span class="c"&gt;# Status / mismatches / provided size&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup status integ-lab

&lt;span class="c"&gt;# Grow (kernel 5.7+ for size increases). Recalculating flag is set after resize.&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup resize integ-lab
&lt;span class="c"&gt;# Optional: wipe newly allocated area so tags start clean&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;integritysetup resize &lt;span class="nt"&gt;--wipe&lt;/span&gt; integ-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kernel guidance worth internalizing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;allow_discards&lt;/code&gt; marks discarded blocks with a &lt;strong&gt;constant&lt;/strong&gt; filler tag that anyone with raw write access can forge without a key. Prefer keyed discard marking on new keyed volumes when available.&lt;/li&gt;
&lt;li&gt;Layout-defining parameters (interleave, tag size, algorithm, separate meta device) are not casual reload knobs—the on-disk layout depends on them.&lt;/li&gt;
&lt;li&gt;Journal mode, buffer size, watermark, commit time, and discards can often be adjusted on reload; still test before production cutover.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  9) Clean rollback for the lab
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /mnt/integ 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;integritysetup close integ-lab 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;integritysetup close integ-hmac 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;integritysetup close integ-recovery 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# Detach loops&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;img &lt;span class="k"&gt;in &lt;/span&gt;disk.img hmac.img&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;dev&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;losetup &lt;span class="nt"&gt;-j&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$img&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;: &lt;span class="s1"&gt;'NR==1{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dev&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;losetup &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dev&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# Remove scratch (optional)&lt;/span&gt;
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For production, reverse through fstab → integritytab → wipe only after backups are verified. Closing the mapper without a backup does not un-corrupt a damaged backing store.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision guide: integrity vs the neighbors
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Immutable image / rootfs matches a published digest&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;dm-verity&lt;/strong&gt; + root hash (&lt;code&gt;veritysetup&lt;/code&gt;, &lt;code&gt;veritytab&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writable volume detects silent bit flips&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;dm-integrity&lt;/strong&gt; standalone CRC/hash (&lt;code&gt;integritysetup&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writable volume authenticates sectors with a key&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;dm-integrity&lt;/strong&gt; HMAC or &lt;strong&gt;LUKS2 --integrity&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Confidentiality (who can read)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;LUKS/dm-crypt&lt;/strong&gt; (optionally stacked with integrity)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File-level package/config drift&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;debsums&lt;/strong&gt;, &lt;strong&gt;AIDE&lt;/strong&gt;, audit watches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filesystem checksum scrub + repair&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Btrfs scrub&lt;/strong&gt;, &lt;strong&gt;ZFS scrub&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAID parity consistency&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;mdadm check/repair&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;dm-integrity does not replace backups. A wiped or re-formatted integrity device is still gone. It replaces the failure mode where a flaky cable, bad SSD sector, or sneaky offline edit serves wrong bytes without complaint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical recommendations
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Lab on loop files first.&lt;/strong&gt; Confirm format/open/status/corruption behavior before touching real disks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default to journal mode&lt;/strong&gt; unless you have measured write cost and accept bitmap/direct crash semantics.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use CRC for accidental corruption; use HMAC or LUKS2 integrity for adversarial offline writes.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mount only &lt;code&gt;/dev/mapper/...&lt;/code&gt;.&lt;/strong&gt; Raw access bypasses checks by definition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wire &lt;code&gt;integritytab&lt;/code&gt; with stable PARTUUID/UUID identifiers&lt;/strong&gt;, and keep HMAC key files out of world-readable paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch capacity.&lt;/strong&gt; Tag+journal overhead is real; size filesystems on the mapper device after open.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep recovery mode and legacy recalculate off the happy path.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stack deliberately:&lt;/strong&gt; verity for immutable publish/subscribe images; integrity for mutable disks; LUKS for secrecy.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Sources and references
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Linux kernel docs: &lt;a href="https://docs.kernel.org/admin-guide/device-mapper/dm-integrity.html" rel="noopener noreferrer"&gt;dm-integrity&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;integritysetup(8)&lt;/code&gt; — cryptsetup project man page&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;integritytab(5)&lt;/code&gt; — systemd integrity device table&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemd-integritysetup-generator(8)&lt;/code&gt; / &lt;code&gt;systemd-integritysetup@.service(8)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;cryptsetup project: &lt;a href="https://gitlab.com/cryptsetup/cryptsetup" rel="noopener noreferrer"&gt;gitlab.com/cryptsetup/cryptsetup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Related boundary tools: &lt;code&gt;veritysetup(8)&lt;/code&gt;, &lt;code&gt;cryptsetup(8)&lt;/code&gt;, &lt;code&gt;cryptsetup-luksFormat(8)&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Silent corruption is boring until it is not. dm-integrity will not make disks immortal, but it stops your stack from cheerfully serving wrong sectors as if nothing happened—and that is a much better default for every writable volume you actually care about.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>storage</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stop Blindly Mounting Untrusted Images: Practical dm-verity with veritysetup on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Thu, 03 Sep 2026 05:02:31 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-blindly-mounting-untrusted-images-practical-dm-verity-with-veritysetup-on-linux-2iga</link>
      <guid>https://dev.to/lyraalishaikh/stop-blindly-mounting-untrusted-images-practical-dm-verity-with-veritysetup-on-linux-2iga</guid>
      <description>&lt;h1&gt;
  
  
  Stop Blindly Mounting Untrusted Images: Practical dm-verity with veritysetup on Linux
&lt;/h1&gt;

&lt;p&gt;Encryption answers &lt;em&gt;who can read this&lt;/em&gt;. It does &lt;strong&gt;not&lt;/strong&gt; answer &lt;em&gt;did anyone change a block since I published it&lt;/em&gt;. A LUKS volume with a weak or leaked key still mounts whatever is on disk. A golden appliance image, an immutable &lt;code&gt;/usr&lt;/code&gt;, a signed container rootfs export, or a lab “known-good” loop image needs a different property: &lt;strong&gt;read-only, block-level integrity that fails closed when a single sector is flipped&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is what &lt;strong&gt;dm-verity&lt;/strong&gt; does. The kernel device-mapper &lt;code&gt;verity&lt;/code&gt; target verifies every data block against a Merkle hash tree. Userspace builds and activates that tree with &lt;strong&gt;&lt;code&gt;veritysetup&lt;/code&gt;&lt;/strong&gt; (from the cryptsetup project). systemd can bring devices up at boot via &lt;strong&gt;&lt;code&gt;/etc/veritytab&lt;/code&gt;&lt;/strong&gt; and &lt;code&gt;systemd-veritysetup-generator&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This guide is an operator walkthrough: format a data/hash pair, activate a verified mapper device, prove corruption is blocked, optionally add FEC, wire &lt;code&gt;veritytab&lt;/code&gt;, and understand root-hash trust—including boot-time &lt;code&gt;roothash=&lt;/code&gt; for OS images. It is &lt;strong&gt;not&lt;/strong&gt; LUKS confidentiality, AIDE/debsums file inventories, Btrfs scrub, Secure Boot key enrollment, or dm-integrity authenticated encryption. Those solve adjacent problems. Here the unit of work is a &lt;strong&gt;read-only verified block device&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you are actually building
&lt;/h2&gt;

&lt;p&gt;From the kernel dm-verity docs and &lt;code&gt;veritysetup(8)&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Data device&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Read-only payload (partition, loop file, or image). Applications never mount this raw if you want verification.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hash device&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stores the Merkle tree (and optional on-disk superblock). Can be a second partition/file, or the same device after a hash offset.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Root hash&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cryptographic digest of the tree root (+ salt). &lt;strong&gt;This is the trust anchor.&lt;/strong&gt; Anyone who can change the root hash can fake the tree.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;dm-verity mapping&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Kernel target that serves verified blocks under &lt;code&gt;/dev/mapper/&amp;lt;name&amp;gt;&lt;/code&gt;. I/O fails (or restarts/panics, if configured) on mismatch.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Optional FEC&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reed-Solomon parity so some corrupt/unreadable blocks can be recovered, then re-checked against the hash.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Theory of operation (kernel docs, condensed):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On read, the kernel hashes the data block and walks the tree up to the trusted root hash.&lt;/li&gt;
&lt;li&gt;Mismatch → default behavior is &lt;strong&gt;I/O error&lt;/strong&gt; for that read (fail closed).&lt;/li&gt;
&lt;li&gt;The target is &lt;strong&gt;read-only&lt;/strong&gt;. You do not “write through verity” and rehash live; you rebuild offline and publish a new root hash.&lt;/li&gt;
&lt;li&gt;Format version &lt;strong&gt;1&lt;/strong&gt; is the modern on-disk layout (salt prepended; use it for new devices). Version &lt;strong&gt;0&lt;/strong&gt; is the old Chrome OS layout.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Important mental model: &lt;strong&gt;dm-verity is not a backup, not a filesystem, and not encryption.&lt;/strong&gt; It proves the bytes match a published root hash. Confidentiality still needs LUKS/dm-crypt (often stacked under or beside verity in image pipelines). Writable integrity needs different tools (&lt;code&gt;integritysetup&lt;/code&gt; / dm-integrity, filesystem checksums, etc.).&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Kernel with &lt;code&gt;dm-verity&lt;/code&gt; (stock on modern distros).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cryptsetup&lt;/code&gt; package that ships &lt;code&gt;veritysetup&lt;/code&gt; (Debian/Ubuntu: &lt;code&gt;cryptsetup&lt;/code&gt; / &lt;code&gt;cryptsetup-bin&lt;/code&gt;; Fedora/RHEL: &lt;code&gt;cryptsetup&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Optional: systemd ≥ 248 for full &lt;code&gt;veritytab&lt;/code&gt; option set; generator support for root/&lt;code&gt;usr&lt;/code&gt; verity goes back further (&lt;code&gt;roothash=&lt;/code&gt; since v233 era).&lt;/li&gt;
&lt;li&gt;Root shell and a scratch directory. Prefer loop files for the lab so you never touch real disks.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Debian/Ubuntu&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; cryptsetup

&lt;span class="c"&gt;# Fedora&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; cryptsetup

veritysetup &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="c"&gt;# Expect veritysetup from cryptsetup 2.x+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm the module path exists on systems where it is not built-in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On systems where pstore-style live introspection is available:&lt;/span&gt;
lsmod | &lt;span class="nb"&gt;grep &lt;/span&gt;verity &lt;span class="o"&gt;||&lt;/span&gt; modprobe dm-verity
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/module/dm_verity/parameters/use_bh_bytes 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  1) Build a tiny verified image lab
&lt;/h2&gt;

&lt;p&gt;Create a data image, put a filesystem on it, freeze content, then format hashes into a separate hash image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;LAB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/tmp/verity-lab
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 64 MiB data image + empty hash image (veritysetup can create the hash file)&lt;/span&gt;
&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/zero &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;data.img &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1M &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;64 &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none
&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/zero &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;hash.img &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1M &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8 &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none

&lt;span class="c"&gt;# Filesystem + sample payload (unmounted before format)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;losetup &lt;span class="nt"&gt;-fP&lt;/span&gt; &lt;span class="nt"&gt;--show&lt;/span&gt; data.img   &lt;span class="c"&gt;# note the loop device, e.g. /dev/loop0&lt;/span&gt;
&lt;span class="nv"&gt;DATA_LOOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;losetup &lt;span class="nt"&gt;-j&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/data.img"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;: &lt;span class="s1"&gt;'NR==1{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mkfs.ext4 &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; veritydata &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATA_LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/verity-src
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATA_LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /mnt/verity-src
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'golden payload v1'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /mnt/verity-src/README &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /mnt/verity-src
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Format&lt;/strong&gt; the Merkle tree. Capture the printed &lt;strong&gt;Root hash&lt;/strong&gt;—that string is your trust anchor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;veritysetup format &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--hash&lt;/span&gt; sha256 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-block-size&lt;/span&gt; 4096 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--hash-block-size&lt;/span&gt; 4096 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--root-hash-file&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/root.hash"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/data.img"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/hash.img"&lt;/span&gt;

&lt;span class="nb"&gt;sudo cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/root.hash"&lt;/span&gt;
&lt;span class="c"&gt;# Example shape only (yours will differ):&lt;/span&gt;
&lt;span class="c"&gt;# e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;format&lt;/code&gt; computes hashes for the data device and permanently stores the tree on the hash device. With &lt;code&gt;--root-hash-file&lt;/code&gt;, the root hash is written as hex text (no trailing newline expected by later reads).&lt;/p&gt;

&lt;p&gt;Inspect what was written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;veritysetup dump &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/hash.img"&lt;/span&gt;
&lt;span class="c"&gt;# Shows UUID, hash algorithm, block sizes, data blocks, salt, etc.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2) Activate and mount the verified device
&lt;/h2&gt;

&lt;p&gt;Open a mapping. Applications use &lt;strong&gt;&lt;code&gt;/dev/mapper/...&lt;/code&gt;&lt;/strong&gt;, not the raw data image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ROOT_HASH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;sudo cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/root.hash"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;veritysetup open &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/data.img"&lt;/span&gt; veritylab &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/hash.img"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ROOT_HASH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Status should report verification mode and devices&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;veritysetup status veritylab
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /dev/mapper/veritylab

&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/verity
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount &lt;span class="nt"&gt;-o&lt;/span&gt; ro /dev/mapper/veritylab /mnt/verity
&lt;span class="nb"&gt;cat&lt;/span&gt; /mnt/verity/README
&lt;span class="c"&gt;# golden payload v1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Userspace-only check (no mapper device) is available when you want CI-style validation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;veritysetup verify &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/data.img"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/hash.img"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--root-hash-file&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/root.hash"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;   &lt;span class="c"&gt;# 0 on success&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3) Prove fail-closed behavior
&lt;/h2&gt;

&lt;p&gt;With the mapping open, corrupt a data block &lt;strong&gt;behind&lt;/strong&gt; the mapper’s back and read again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# DANGEROUS on real disks — lab images only&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /mnt/verity

&lt;span class="c"&gt;# Flip bytes in the data image while verity is still open or after close+reopen&lt;/span&gt;
&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/urandom &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/data.img"&lt;/span&gt; &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4096 &lt;span class="nv"&gt;seek&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10 &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;conv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;notrunc &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none

&lt;span class="c"&gt;# Recreate mapping if you closed it&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;veritysetup close veritylab 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;veritysetup open &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/data.img"&lt;/span&gt; veritylab &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/hash.img"&lt;/span&gt; &lt;span class="nt"&gt;--root-hash-file&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/root.hash"&lt;/span&gt;

&lt;span class="c"&gt;# Mount may succeed; the failure is on verified read of the bad block&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount &lt;span class="nt"&gt;-o&lt;/span&gt; ro /dev/mapper/veritylab /mnt/verity
&lt;span class="c"&gt;# Force a read that hits the corrupted region (offset depends on FS layout).&lt;/span&gt;
&lt;span class="c"&gt;# dd through the mapper is a blunt instrument:&lt;/span&gt;
&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/mapper/veritylab &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/null &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4096 &lt;span class="nv"&gt;skip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10 &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none
&lt;span class="c"&gt;# Expect I/O error once the bad block is fetched and checked&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Default policy (no extra flags): &lt;strong&gt;failed verification → I/O error&lt;/strong&gt;. That is what you want for golden images.&lt;/p&gt;

&lt;p&gt;Corruption knobs from &lt;code&gt;veritysetup open&lt;/code&gt; / kernel optional params (use sparingly):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;(default)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;I/O error on bad block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--ignore-corruption&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Log and continue (not for production trust boundaries)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--restart-on-corruption&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Trigger reboot path (need anti-loop strategy)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--panic-on-corruption&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Kernel panic on bad block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--check-at-most-once&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Verify each data block only first read — weaker (offline tamper only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--ignore-zero-blocks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Skip expected-zero blocks — special-case only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Kernel status line uses &lt;strong&gt;V&lt;/strong&gt; (valid so far) vs &lt;strong&gt;C&lt;/strong&gt; (corruption seen). FEC-corrected block counts appear when FEC is enabled.&lt;/p&gt;

&lt;p&gt;Restore the lab data image from backup, or re-&lt;code&gt;format&lt;/code&gt; after restoring known-good bytes, before continuing.&lt;/p&gt;

&lt;h2&gt;
  
  
  4) Same-device hash offset (single image layout)
&lt;/h2&gt;

&lt;p&gt;You do not always want two files. Hashes can live on the &lt;strong&gt;same&lt;/strong&gt; device after the data area:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Example pattern from veritysetup(8):&lt;/span&gt;
&lt;span class="c"&gt;# --data-blocks limits verified data; --hash-offset places the tree later.&lt;/span&gt;
&lt;span class="c"&gt;# hash-offset is in bytes and must sit past the data region.&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;veritysetup format &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-blocks&lt;/span&gt; 8192 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--hash-offset&lt;/span&gt; 33554432 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--root-hash-file&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/combined.root"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/combined.img"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/combined.img"&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;veritysetup open &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/combined.img"&lt;/span&gt; veritycombo &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/combined.img"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-blocks&lt;/span&gt; 8192 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--hash-offset&lt;/span&gt; 33554432 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--root-hash-file&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/combined.root"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--data-blocks&lt;/code&gt; × data-block-size must end &lt;strong&gt;before&lt;/strong&gt; the hash offset.&lt;/li&gt;
&lt;li&gt;Activation must pass the &lt;strong&gt;same&lt;/strong&gt; geometry flags you used at format when superblock mode requires it (&lt;code&gt;--no-superblock&lt;/code&gt; workflows especially).&lt;/li&gt;
&lt;li&gt;Prefer separate hash devices in labs; combined layouts are common in appliance partition schemes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5) Optional FEC: recover then re-verify
&lt;/h2&gt;

&lt;p&gt;dm-verity can attach &lt;strong&gt;forward error correction&lt;/strong&gt;. FEC does not weaken security: recovered blocks are still checked against the Merkle tree before use (kernel docs). FEC runs only when a hash mismatch or read error occurs, so the happy path stays cheap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/zero &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/fec.img"&lt;/span&gt; &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1M &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none

&lt;span class="nb"&gt;sudo &lt;/span&gt;veritysetup format &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--hash&lt;/span&gt; sha256 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--fec-device&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/fec.img"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--fec-roots&lt;/span&gt; 2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--root-hash-file&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/root-fec.hash"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/data-good.img"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/hash-fec.img"&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;veritysetup open &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/data-good.img"&lt;/span&gt; verityfec &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/hash-fec.img"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--root-hash-file&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/root-fec.hash"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--fec-device&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;/fec.img"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--fec-roots&lt;/span&gt; 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes from man pages / kernel docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Data and hash &lt;strong&gt;block sizes must match&lt;/strong&gt; when FEC is used.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fec-roots&lt;/code&gt; is M−N in RS(255, N); supported range &lt;strong&gt;2–24&lt;/strong&gt;. &lt;strong&gt;2&lt;/strong&gt; is the usual recommendation (~0.8% parity overhead class of cost, with interleaving).&lt;/li&gt;
&lt;li&gt;If the data device is encrypted, encrypt the FEC device too so parity does not leak plaintext structure.&lt;/li&gt;
&lt;li&gt;FEC helps with &lt;strong&gt;storage faults&lt;/strong&gt; and some corruption bursts; it is not a substitute for a trusted root hash distribution channel.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6) Persist with &lt;code&gt;/etc/veritytab&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;For non-root volumes you want every boot, use &lt;strong&gt;&lt;code&gt;veritytab(5)&lt;/code&gt;&lt;/strong&gt;. Field layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;volume-name  data-device  hash-device  roothash  [options]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example (paths or UUID/PARTUUID forms):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/veritytab&lt;/span&gt;
&lt;span class="c"&gt;# volume  data                         hash                          roothash   options&lt;/span&gt;
usrverity &lt;span class="nv"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;11111111-2222-3333-4444-555555555555 &lt;span class="nv"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee 36e3f740ad502e2c25e2a23d9c7c17bf0fdad2300b7580842d4b7ec1fb0fa263 auto
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful options (systemd veritytab, many added in v248/v254):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;hash=sha256&lt;/code&gt;, &lt;code&gt;data-block-size=4096&lt;/code&gt;, &lt;code&gt;hash-block-size=4096&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hash-offset=…&lt;/code&gt;, &lt;code&gt;data-blocks=…&lt;/code&gt;, &lt;code&gt;salt=…&lt;/code&gt;, &lt;code&gt;superblock=…&lt;/code&gt;, &lt;code&gt;format=1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ignore-corruption&lt;/code&gt; / &lt;code&gt;restart-on-corruption&lt;/code&gt; / &lt;code&gt;panic-on-corruption&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;check-at-most-once&lt;/code&gt;, &lt;code&gt;ignore-zero-blocks&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fec-device=&lt;/code&gt;, &lt;code&gt;fec-offset=&lt;/code&gt;, &lt;code&gt;fec-roots=&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;root-hash-signature=PATH|base64:…|auto&lt;/code&gt; (kernel 5.4+ root-hash sig verify)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;noauto&lt;/code&gt;, &lt;code&gt;nofail&lt;/code&gt;, &lt;code&gt;_netdev&lt;/code&gt;, &lt;code&gt;x-initrd.attach&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Generator: &lt;code&gt;systemd-veritysetup-generator&lt;/code&gt; turns veritytab (and kernel cmdline) into &lt;code&gt;systemd-veritysetup@.service&lt;/code&gt; instances. After editing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="c"&gt;# Units are generated; enable path depends on whether something pulls the volume in&lt;/span&gt;
&lt;span class="c"&gt;# (a mount unit or veritysetup.target membership without noauto).&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start systemd-veritysetup@usrverity.service
systemctl status systemd-veritysetup@usrverity.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mount the mapper read-only from fstab as usual, pointing at &lt;code&gt;/dev/mapper/usrverity&lt;/code&gt;. If the verity device uses &lt;code&gt;_netdev&lt;/code&gt;, mark the mount &lt;code&gt;_netdev&lt;/code&gt; too or you can create a dependency loop with &lt;code&gt;local-fs.target&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Initrd / root filesystem case
&lt;/h3&gt;

&lt;p&gt;For &lt;strong&gt;OS root&lt;/strong&gt; (and &lt;code&gt;/usr&lt;/code&gt;) verity, prefer the &lt;strong&gt;kernel cmdline&lt;/strong&gt; interface documented in &lt;code&gt;systemd-veritysetup-generator(8)&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;roothash=&amp;lt;hex&amp;gt;&lt;/code&gt; — often enough by itself when GPT partition UUIDs are derived from the root hash (first 128 bits → data PARTUUID scheme, last 128 bits → hash PARTUUID scheme).&lt;/li&gt;
&lt;li&gt;Or explicit: &lt;code&gt;systemd.verity_root_data=&lt;/code&gt;, &lt;code&gt;systemd.verity_root_hash=&lt;/code&gt;, &lt;code&gt;systemd.verity_root_options=&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Parallel set for &lt;code&gt;/usr&lt;/code&gt;: &lt;code&gt;usrhash=&lt;/code&gt;, &lt;code&gt;systemd.verity_usr_data=&lt;/code&gt;, &lt;code&gt;systemd.verity_usr_hash=&lt;/code&gt;, &lt;code&gt;systemd.verity_usr_options=&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Toggles: &lt;code&gt;systemd.verity=&lt;/code&gt;, &lt;code&gt;rd.systemd.verity=&lt;/code&gt;, &lt;code&gt;veritytab=&lt;/code&gt;, &lt;code&gt;rd.veritytab=&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use &lt;code&gt;x-initrd.attach&lt;/code&gt; in veritytab for devices needed in the initrd so detach ordering during shutdown stays sane.&lt;/p&gt;

&lt;p&gt;Root-hash &lt;strong&gt;signatures&lt;/strong&gt; (&lt;code&gt;root-hash-signature=&lt;/code&gt;, kernel &lt;code&gt;root_hash_sig_key_desc&lt;/code&gt;) bind the root hash to a key in the trusted keyring—important when the cmdline or veritytab itself could be altered. Pair with Secure Boot/UKI policies when you build a real verified-boot chain; verity alone does not protect the bootloader config that supplies the hash.&lt;/p&gt;

&lt;h2&gt;
  
  
  7) Operational checklist
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Publish path (image builder):&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Freeze content on the data device (filesystem unmounted, image finalized).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;veritysetup format&lt;/code&gt; → store hash device + root hash (+ optional FEC + signature).&lt;/li&gt;
&lt;li&gt;Distribute &lt;strong&gt;root hash&lt;/strong&gt; (and signature) over a trusted channel: signed image manifest, Secure Boot-measured cmdline, internal PKI, etc.&lt;/li&gt;
&lt;li&gt;Never treat “hash device present” as trust. Only the &lt;strong&gt;root hash&lt;/strong&gt; (and its signature) is the anchor.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Consume path (host):&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;veritysetup open&lt;/code&gt; or veritytab activation with the &lt;strong&gt;expected&lt;/strong&gt; root hash.&lt;/li&gt;
&lt;li&gt;Mount &lt;strong&gt;&lt;code&gt;/dev/mapper/...&lt;/code&gt; read-only&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Monitor: &lt;code&gt;veritysetup status&lt;/code&gt;, dmesg/journal for verity errors, mapper device health.&lt;/li&gt;
&lt;li&gt;On verification failure: treat as incident. Replace data from trusted media; do not “just ignore-corruption” to get the box up unless you are in a deliberate degraded debug mode.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Stacking notes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;dm-verity + LUKS:&lt;/strong&gt; common pattern is encrypt for confidentiality, verity for authenticity of a published image—order depends on design (factory-signed plaintext verity over encrypted at rest, or verity of ciphertext). Be explicit; FEC device encryption should match data encryption guidance in the man page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dm-verity vs dm-integrity:&lt;/strong&gt; integritysetup/dm-integrity targets &lt;strong&gt;writable&lt;/strong&gt; authenticated disks with per-sector tags and typically a key. Verity is the &lt;strong&gt;immutable publish/verify&lt;/strong&gt; tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dm-verity vs fs-verity:&lt;/strong&gt; fs-verity authenticates &lt;strong&gt;files&lt;/strong&gt; inside a filesystem; dm-verity authenticates a &lt;strong&gt;block device&lt;/strong&gt;. Different layers, complementary in image systems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dm-verity vs AIDE/debsums:&lt;/strong&gt; userspace file inventories drift and race; verity is online in the block I/O path for a frozen image.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  8) Rollback and cleanup (lab)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /mnt/verity 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;veritysetup close veritylab 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;veritysetup close veritycombo 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;veritysetup close verityfec 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;losetup &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATA_LOOP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/tmp/verity-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Production rollback is “boot previous signed image + previous root hash,” not “disable verification.” If you must break glass, remove or &lt;code&gt;noauto&lt;/code&gt; the veritytab line and reboot from a known recovery path—then fix the trust chain before returning to service.&lt;/p&gt;

&lt;h2&gt;
  
  
  What not to do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Do not mount the &lt;strong&gt;raw&lt;/strong&gt; data device read-write and expect the hash tree to stay valid.&lt;/li&gt;
&lt;li&gt;Do not pass the root hash from an untrusted HTTP download without signature or out-of-band pin.&lt;/li&gt;
&lt;li&gt;Do not enable &lt;code&gt;--ignore-corruption&lt;/code&gt; or &lt;code&gt;--check-at-most-once&lt;/code&gt; on security boundaries “for performance” without accepting the weakened model.&lt;/li&gt;
&lt;li&gt;Do not confuse a green &lt;code&gt;mount&lt;/code&gt; with a verified read of every block—verification is &lt;strong&gt;on I/O&lt;/strong&gt;; spot-check critical paths and use &lt;code&gt;veritysetup verify&lt;/code&gt; in pipelines.&lt;/li&gt;
&lt;li&gt;Do not skip Secure Boot / signed cmdline concerns if the attacker can edit &lt;code&gt;roothash=&lt;/code&gt; as easily as the disk.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Linux kernel docs: &lt;a href="https://www.kernel.org/doc/html/latest/admin-guide/device-mapper/verity.html" rel="noopener noreferrer"&gt;dm-verity&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;veritysetup(8)&lt;/code&gt; — format/open/verify/close/status/dump, FEC and corruption options (man7 / cryptsetup)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;veritytab(5)&lt;/code&gt; — &lt;code&gt;/etc/veritytab&lt;/code&gt; field format and options&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemd-veritysetup-generator(8)&lt;/code&gt; — &lt;code&gt;roothash=&lt;/code&gt;, &lt;code&gt;usrhash=&lt;/code&gt;, cmdline device selection&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemd-veritysetup@.service(8)&lt;/code&gt; — per-volume activation units&lt;/li&gt;
&lt;li&gt;cryptsetup project: &lt;a href="https://gitlab.com/cryptsetup/cryptsetup" rel="noopener noreferrer"&gt;https://gitlab.com/cryptsetup/cryptsetup&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;dm-verity turns “I hope nobody touched this image” into “the kernel will not hand me a block that does not match the root hash I trust.” Format offline, distribute the root hash like a secret of equal importance to a signing key, activate through &lt;code&gt;veritysetup&lt;/code&gt; or &lt;code&gt;veritytab&lt;/code&gt;, and mount only the mapper. That is the whole game—simple, strict, and fail-closed.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>storage</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stop Overprovisioning Blindly: Practical LVM Thin Pools and Thin Snapshots on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Wed, 02 Sep 2026 05:03:19 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-overprovisioning-blindly-practical-lvm-thin-pools-and-thin-snapshots-on-linux-7h2</link>
      <guid>https://dev.to/lyraalishaikh/stop-overprovisioning-blindly-practical-lvm-thin-pools-and-thin-snapshots-on-linux-7h2</guid>
      <description>&lt;h1&gt;
  
  
  Stop Overprovisioning Blindly: Practical LVM Thin Pools and Thin Snapshots on Linux
&lt;/h1&gt;

&lt;p&gt;Thick LVs are honest: if you ask for 500 GiB, LVM carves 500 GiB of physical extents &lt;em&gt;now&lt;/em&gt;. Thin LVs are different. You declare a &lt;strong&gt;virtual size&lt;/strong&gt;, and blocks only get allocated when something actually writes. That is powerful for VM images, container volumes, homelab scratch space, and dense snapshot trees—and dangerous if you treat “virtual free space” like real free space.&lt;/p&gt;

&lt;p&gt;This guide is an operator walkthrough of LVM thin provisioning on modern Linux: create a thin pool, cut thin LVs, take space-efficient thin snapshots, wire &lt;code&gt;dmeventd&lt;/code&gt; autoextend, choose full-pool behavior, monitor &lt;code&gt;Data%&lt;/code&gt; / &lt;code&gt;Meta%&lt;/code&gt;, and recover metadata with &lt;code&gt;thin_check&lt;/code&gt; / &lt;code&gt;thin_repair&lt;/code&gt; when the kernel sets &lt;code&gt;needs_check&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It is &lt;strong&gt;not&lt;/strong&gt; about Btrfs send/receive, mdadm scrubbing, multipath path failover, or filesystem-level snapshot tools. Those solve adjacent problems. Here the unit of work is the &lt;strong&gt;device-mapper thin-pool&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you are actually building
&lt;/h2&gt;

&lt;p&gt;From &lt;code&gt;lvmthin(7)&lt;/code&gt; and the kernel thin-provisioning docs, the stack looks like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Thin pool LV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Special LV you &lt;em&gt;name&lt;/em&gt; but do not mount. Holds physical data + metadata.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;_tdata&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hidden data LV — physical storage for all thin volumes in the pool.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;_tmeta&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hidden metadata LV — mapping tables for &lt;code&gt;dm-thin-pool&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Thin LV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Virtual block device (&lt;code&gt;-V&lt;/code&gt; / &lt;code&gt;--virtualsize&lt;/code&gt;). Space comes from the pool on write.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Thin snapshot&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Another thin LV that initially shares blocks with its origin. No classic COW chunk LV.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Key operational facts from the kernel docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Data and metadata live on &lt;strong&gt;separate&lt;/strong&gt; devices under the hood (LVM builds that for you).&lt;/li&gt;
&lt;li&gt;Newly provisioned blocks are tracked in pool metadata; commits happen on FLUSH/FUA (or about once a second).&lt;/li&gt;
&lt;li&gt;If &lt;strong&gt;data&lt;/strong&gt; space runs out, the pool either &lt;strong&gt;queues&lt;/strong&gt; or &lt;strong&gt;errors&lt;/strong&gt; I/O (configurable).&lt;/li&gt;
&lt;li&gt;If &lt;strong&gt;metadata&lt;/strong&gt; space is exhausted or a metadata op fails, the pool can enter a bad state requiring offline check/repair (&lt;code&gt;needs_check&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Recursive thin snapshots do &lt;strong&gt;not&lt;/strong&gt; degrade like stacked classic COW snapshots; depth is fine, but fragmentation and pool fullness still matter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A volume group with free physical extents (lab example uses &lt;code&gt;vg0&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Packages: &lt;code&gt;lvm2&lt;/code&gt; (provides &lt;code&gt;lvcreate&lt;/code&gt;, &lt;code&gt;dmeventd&lt;/code&gt; / &lt;code&gt;lvm2-monitor&lt;/code&gt;), and thin provisioning tools:

&lt;ul&gt;
&lt;li&gt;Debian/Ubuntu: &lt;code&gt;thin-provisioning-tools&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Fedora/RHEL family: often &lt;code&gt;device-mapper-persistent-data&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Root shell, and a non-production VG if you are learning.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Debian/Ubuntu&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; lvm2 thin-provisioning-tools

&lt;span class="c"&gt;# Fedora&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; lvm2 device-mapper-persistent-data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm the monitor path that drives autoextend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl status lvm2-monitor.service
&lt;span class="c"&gt;# or, depending on distro packaging:&lt;/span&gt;
systemctl status dm-event.socket dm-event.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  1) Create a thin pool
&lt;/h2&gt;

&lt;p&gt;Simplest path — let LVM size metadata automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 100 GiB physical data capacity in pool "tp0" on volume group vg0&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvcreate &lt;span class="nt"&gt;--type&lt;/span&gt; thin-pool &lt;span class="nt"&gt;-n&lt;/span&gt; tp0 &lt;span class="nt"&gt;-L&lt;/span&gt; 100G vg0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inspect the hidden components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;lvs &lt;span class="nt"&gt;-a&lt;/span&gt; vg0
&lt;span class="c"&gt;# Expect something like:&lt;/span&gt;
&lt;span class="c"&gt;#   tp0            twi-a-tz--  100.00g&lt;/span&gt;
&lt;span class="c"&gt;#   [tp0_tdata]    Twi-ao----  100.00g&lt;/span&gt;
&lt;span class="c"&gt;#   [tp0_tmeta]    ewi-ao----    N.NNm&lt;/span&gt;
&lt;span class="c"&gt;#   [lvol0_pmspare] ...   # spare metadata copy for repair workflows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Custom metadata size (recommended for dense snapshot hosts)
&lt;/h3&gt;

&lt;p&gt;If you expect &lt;strong&gt;many&lt;/strong&gt; thin volumes or heavy snapshot churn, do not rely on the tiny default metadata LV forever. Create data + metadata yourself, then convert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;lvcreate &lt;span class="nt"&gt;-n&lt;/span&gt; tp0_data &lt;span class="nt"&gt;-L&lt;/span&gt; 100G vg0
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvcreate &lt;span class="nt"&gt;-n&lt;/span&gt; tp0_meta &lt;span class="nt"&gt;-L&lt;/span&gt; 1G vg0   &lt;span class="c"&gt;# size to taste; larger = more headroom&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvconvert &lt;span class="nt"&gt;--type&lt;/span&gt; thin-pool &lt;span class="nt"&gt;--poolmetadata&lt;/span&gt; tp0_meta vg0/tp0_data
&lt;span class="c"&gt;# Resulting pool keeps the data LV's name: vg0/tp0_data → rename if you want&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvrename vg0/tp0_data tp0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kernel guidance for raw dm-thin (useful mental model): metadata size scales roughly with&lt;br&gt;&lt;br&gt;
&lt;code&gt;48 * data_size / data_block_size&lt;/code&gt;, with a practical upper bound around &lt;strong&gt;16 GiB&lt;/strong&gt; of metadata. LVM’s &lt;code&gt;--poolmetadatasize&lt;/code&gt; is the knob you want instead of hand-rolling dmsetup tables.&lt;/p&gt;
&lt;h3&gt;
  
  
  Chunk size and discards (set at pool creation)
&lt;/h3&gt;

&lt;p&gt;From &lt;code&gt;lvcreate(8)&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--chunksize&lt;/code&gt; for thin pools: &lt;strong&gt;64 KiB–1 GiB&lt;/strong&gt;, multiple of 64 KiB. Default starts at 64 KiB and may scale with pool/metadata sizing.&lt;/li&gt;
&lt;li&gt;Smaller chunks → better snapshot granularity, more metadata pressure.&lt;/li&gt;
&lt;li&gt;Larger chunks → less metadata, coarser allocation (often fine for bulk thin provisioning).
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;lvcreate &lt;span class="nt"&gt;--type&lt;/span&gt; thin-pool &lt;span class="nt"&gt;-n&lt;/span&gt; tp0 &lt;span class="nt"&gt;-L&lt;/span&gt; 100G &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--chunksize&lt;/span&gt; 256K &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--discards&lt;/span&gt; passdown &lt;span class="se"&gt;\&lt;/span&gt;
  vg0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Discard modes (&lt;code&gt;--discards&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;passdown&lt;/code&gt; — reclaim in-pool &lt;strong&gt;and&lt;/strong&gt; pass TRIM/UNMAP to the underlying device (good on SSDs that support it).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nopassdown&lt;/code&gt; — reclaim in-pool only.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ignore&lt;/code&gt; — ignore discards (pool never frees via TRIM).&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Full-pool I/O policy
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;lvcreate &lt;span class="nt"&gt;--type&lt;/span&gt; thin-pool &lt;span class="nt"&gt;-n&lt;/span&gt; tp0 &lt;span class="nt"&gt;-L&lt;/span&gt; 100G &lt;span class="nt"&gt;--errorwhenfull&lt;/span&gt; y vg0
&lt;span class="c"&gt;# or change later:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvchange &lt;span class="nt"&gt;--errorwhenfull&lt;/span&gt; y vg0/tp0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--errorwhenfull y&lt;/code&gt; → fail I/O immediately when the pool cannot allocate (fail fast; apps see ENOSPC-style failures).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--errorwhenfull n&lt;/code&gt; (common default behavior path) → &lt;strong&gt;queue&lt;/strong&gt; I/O for a while so autoextend can catch up. The kernel module parameter &lt;code&gt;no_space_timeout&lt;/code&gt; (default &lt;strong&gt;60s&lt;/strong&gt; in upstream docs) bounds how long queuing lasts before errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For automated homelabs with autoextend and spare VG free space, queuing can be convenient. For multi-tenant or database hosts, &lt;strong&gt;error-when-full&lt;/strong&gt; is often safer than silent stalls.&lt;/p&gt;
&lt;h2&gt;
  
  
  2) Create thin logical volumes
&lt;/h2&gt;

&lt;p&gt;Virtual size is independent of pool size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 500 GiB *virtual* thin LV living in a 100 GiB physical pool&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvcreate &lt;span class="nt"&gt;--type&lt;/span&gt; thin &lt;span class="nt"&gt;-n&lt;/span&gt; vm-disk0 &lt;span class="nt"&gt;-V&lt;/span&gt; 500G &lt;span class="nt"&gt;--thinpool&lt;/span&gt; tp0 vg0

&lt;span class="c"&gt;# equivalent short form used a lot in docs:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvcreate &lt;span class="nt"&gt;-T&lt;/span&gt; vg0/tp0 &lt;span class="nt"&gt;-V&lt;/span&gt; 500G &lt;span class="nt"&gt;-n&lt;/span&gt; vm-disk0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Format and mount like any block device:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;mkfs.ext4 &lt;span class="nt"&gt;-L&lt;/span&gt; vm-disk0 /dev/vg0/vm-disk0
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/vm-disk0
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount /dev/vg0/vm-disk0 /mnt/vm-disk0
&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /mnt/vm-disk0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;df&lt;/code&gt; shows the &lt;strong&gt;virtual&lt;/strong&gt; size. Pool consumption is a different question — always check the pool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;lvs &lt;span class="nt"&gt;-o&lt;/span&gt; name,lv_size,data_percent,metadata_percent,pool_lv,lv_attr vg0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Combined create (pool + first thin LV in one shot):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;lvcreate &lt;span class="nt"&gt;--type&lt;/span&gt; thin &lt;span class="nt"&gt;-n&lt;/span&gt; app-data &lt;span class="nt"&gt;-V&lt;/span&gt; 200G &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--thinpool&lt;/span&gt; tp0 &lt;span class="nt"&gt;-L&lt;/span&gt; 50G vg0
&lt;span class="c"&gt;# Creates pool tp0 at 50G physical, then thin LV app-data at 200G virtual&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3) Thin snapshots (the feature people actually want)
&lt;/h2&gt;

&lt;p&gt;Classic LVM COW snapshots need a fixed chunk store and get painful as they fill. Thin snapshots are thin LVs that &lt;strong&gt;share&lt;/strong&gt; unchanged blocks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# IMPORTANT: do not pass -L/--size here or you get a classic COW snapshot&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvcreate &lt;span class="nt"&gt;--snapshot&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; vm-disk0-snap1 vg0/vm-disk0

&lt;span class="c"&gt;# Snapshot of a snapshot is fine&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvcreate &lt;span class="nt"&gt;--snapshot&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; vm-disk0-snap2 vg0/vm-disk0-snap1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Activation skip on new thin snapshots
&lt;/h3&gt;

&lt;p&gt;New thin snapshots often get the &lt;strong&gt;skip activation&lt;/strong&gt; property (&lt;code&gt;k&lt;/code&gt; in &lt;code&gt;lvs&lt;/code&gt; attr, or &lt;code&gt;lvs -o skip_activation&lt;/code&gt;). That means plain &lt;code&gt;vgchange -ay&lt;/code&gt; / &lt;code&gt;lvchange -ay&lt;/code&gt; will &lt;strong&gt;not&lt;/strong&gt; activate them unless you force it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;lvchange &lt;span class="nt"&gt;-ay&lt;/span&gt; &lt;span class="nt"&gt;-K&lt;/span&gt; vg0/vm-disk0-snap1
&lt;span class="c"&gt;# clear the skip flag if this snapshot should activate normally:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvchange &lt;span class="nt"&gt;--setactivationskip&lt;/span&gt; n vg0/vm-disk0-snap1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;lvm.conf&lt;/code&gt; knob: &lt;code&gt;activation/auto_set_activation_skip&lt;/code&gt; controls whether freshly created snapshots get the skip flag by default.&lt;/p&gt;

&lt;h3&gt;
  
  
  External origin (golden image pattern)
&lt;/h3&gt;

&lt;p&gt;You can snapshot a &lt;strong&gt;read-only external&lt;/strong&gt; LV (thick or thin-in-another-pool) into a thin pool: unwritten regions read through to the origin; writes land in the pool. Useful for many VMs sharing one golden disk image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;lvchange &lt;span class="nt"&gt;--permission&lt;/span&gt; r vg0/golden-image
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvcreate &lt;span class="nt"&gt;--snapshot&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; guest1-disk &lt;span class="nt"&gt;--thinpool&lt;/span&gt; tp0 vg0/golden-image
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do &lt;strong&gt;not&lt;/strong&gt; write to the external origin while it is serving as an origin.&lt;/p&gt;

&lt;h3&gt;
  
  
  Merge a thin snapshot back
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Must not be open/mounted if you want immediate merge; otherwise merge defers&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvconvert &lt;span class="nt"&gt;--merge&lt;/span&gt; vg0/vm-disk0-snap1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After merge, the origin takes the snapshot’s content and the snapshot LV is removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  4) Monitor pool health like a hawk
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;lvs &lt;span class="nt"&gt;-o&lt;/span&gt; name,lv_size,data_percent,metadata_percent,seg_monitor vg0/tp0
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvs &lt;span class="nt"&gt;-a&lt;/span&gt; vg0   &lt;span class="c"&gt;# see _tdata / _tmeta&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Extend &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;Data%&lt;/code&gt; or &lt;code&gt;Meta%&lt;/code&gt; approaches 100%.&lt;/li&gt;
&lt;li&gt;Metadata full is worse than data full: metadata exhaustion can force repair before the pool is fully usable again.&lt;/li&gt;
&lt;li&gt;Removing thin LVs does &lt;strong&gt;not&lt;/strong&gt; always free as much as you hope (shared snapshot blocks + fragmentation), per &lt;code&gt;lvmthin(7)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Optional live dm status (advanced):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;dmsetup status vg0-tp0-tpool 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;dmsetup status | &lt;span class="nb"&gt;grep &lt;/span&gt;thin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kernel status fields include used/total data and metadata blocks, &lt;code&gt;ro|rw|out_of_data_space&lt;/code&gt;, &lt;code&gt;error_if_no_space|queue_if_no_space&lt;/code&gt;, and &lt;code&gt;needs_check|-&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5) Extend the pool (manual and automatic)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Manual
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# grow data (metadata may grow automatically relative to new size)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvextend &lt;span class="nt"&gt;-L&lt;/span&gt; +50G vg0/tp0

&lt;span class="c"&gt;# grow metadata explicitly&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvextend &lt;span class="nt"&gt;--poolmetadatasize&lt;/span&gt; +512M vg0/tp0

&lt;span class="c"&gt;# or target hidden LVs directly&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvextend &lt;span class="nt"&gt;-L&lt;/span&gt; +50G vg0/tp0_tdata
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvextend &lt;span class="nt"&gt;-L&lt;/span&gt; +512M vg0/tp0_tmeta
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You need &lt;strong&gt;free extents in the VG&lt;/strong&gt;. Thin does not invent disks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Automatic via dmeventd
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;lvmthin(7)&lt;/code&gt; + &lt;code&gt;dmeventd(8)&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ensure the thin pool is monitored:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="nb"&gt;sudo &lt;/span&gt;lvs &lt;span class="nt"&gt;-o&lt;/span&gt;+seg_monitor vg0/tp0
   &lt;span class="nb"&gt;sudo &lt;/span&gt;lvchange &lt;span class="nt"&gt;--monitor&lt;/span&gt; y vg0/tp0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Configure thresholds in &lt;code&gt;/etc/lvm/lvm.conf&lt;/code&gt; (or a drop-in profile):
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;activation&lt;/span&gt; {
    &lt;span class="c"&gt;# Extend when usage reaches this percent (minimum 50; 100 disables)
&lt;/span&gt;    &lt;span class="n"&gt;thin_pool_autoextend_threshold&lt;/span&gt; = &lt;span class="m"&gt;70&lt;/span&gt;
    &lt;span class="c"&gt;# Grow by this percent of current size
&lt;/span&gt;    &lt;span class="n"&gt;thin_pool_autoextend_percent&lt;/span&gt; = &lt;span class="m"&gt;20&lt;/span&gt;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Keep free space in the VG for growth, and keep &lt;code&gt;lvm2-monitor&lt;/code&gt; / &lt;code&gt;dmeventd&lt;/code&gt; running.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What dmeventd does (thin plugin):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Warns in syslog as the pool crosses ~80/85/90/95% fullness.&lt;/li&gt;
&lt;li&gt;Above ~50%, periodically runs the configured thin command (default: internal &lt;code&gt;lvextend --use-policies&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Retries with backoff if extend fails (up to ~42 minutes per the man page).&lt;/li&gt;
&lt;li&gt;Child environment includes &lt;code&gt;DMEVENTD_THIN_POOL_DATA&lt;/code&gt;, &lt;code&gt;DMEVENTD_THIN_POOL_METADATA&lt;/code&gt;, and &lt;code&gt;LVM_RUN_BY_DMEVENTD=1&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verify monitoring after activation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;lvs &lt;span class="nt"&gt;-o&lt;/span&gt; name,data_percent,metadata_percent,seg_monitor vg0
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; lvm2-monitor &lt;span class="nt"&gt;-u&lt;/span&gt; dm-event &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"1 hour ago"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6) Discard / TRIM so deleted data can return to the pool
&lt;/h2&gt;

&lt;p&gt;Without discards, deleting files inside a guest FS often &lt;strong&gt;does not&lt;/strong&gt; free thin-pool blocks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# mount with discard if your workload tolerates inline TRIM, or run periodic:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;fstrim &lt;span class="nt"&gt;-v&lt;/span&gt; /mnt/vm-disk0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pair this with pool &lt;code&gt;--discards passdown&lt;/code&gt; or &lt;code&gt;nopassdown&lt;/code&gt; so the thin target actually processes discards. Periodic &lt;code&gt;fstrim.timer&lt;/code&gt; on hosts that store thin-backed filesystems is a good habit (same idea as SSD maintenance, different layer).&lt;/p&gt;

&lt;h2&gt;
  
  
  7) When things go wrong: needs_check, thin_check, thin_repair
&lt;/h2&gt;

&lt;p&gt;From the kernel thin-provisioning guide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Metadata failures can set &lt;strong&gt;&lt;code&gt;needs_check&lt;/code&gt;&lt;/strong&gt; on the pool.&lt;/li&gt;
&lt;li&gt;While flagged, expect impaired operation; &lt;strong&gt;repair before&lt;/strong&gt; relying on the pool again.&lt;/li&gt;
&lt;li&gt;After serious metadata problems, run filesystem consistency checks on upper layers too — completions may have been acknowledged before the failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Check metadata (offline)
&lt;/h3&gt;

&lt;p&gt;Deactivate thin LVs and the pool first (all users of the pool must be down):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /mnt/vm-disk0 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;lvchange &lt;span class="nt"&gt;-an&lt;/span&gt; vg0/vm-disk0
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvchange &lt;span class="nt"&gt;-an&lt;/span&gt; vg0/tp0

&lt;span class="c"&gt;# Check the metadata LV (name may be tp0_tmeta)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;thin_check /dev/mapper/vg0-tp0_tmeta
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful &lt;code&gt;thin_check&lt;/code&gt; options (&lt;code&gt;thin_check(8)&lt;/code&gt; from thin-provisioning-tools):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--super-block-only&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Quick superblock-only pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--skip-mappings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Skip bulk mapping checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--clear-needs-check-flag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Clear kernel needs_check &lt;strong&gt;only if check succeeded&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--metadata-snapshot&lt;/code&gt; / &lt;code&gt;-m&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Check a held metadata snapshot (can be used on live metadata with limits)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--auto-repair&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fix trivial issues (e.g. metadata leaks)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;thin_check &lt;span class="nt"&gt;--clear-needs-check-flag&lt;/span&gt; /dev/mapper/vg0-tp0_tmeta
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If check fails, use &lt;strong&gt;&lt;code&gt;thin_repair&lt;/code&gt;&lt;/strong&gt; (same package family), then &lt;code&gt;thin_check&lt;/code&gt; again. Do not clear &lt;code&gt;needs_check&lt;/code&gt; on a failed check and hope.&lt;/p&gt;

&lt;h3&gt;
  
  
  Live metadata inspection
&lt;/h3&gt;

&lt;p&gt;The thin-pool target supports reserving a metadata snapshot for userspace. LVM/thin tools integrate with that path; &lt;code&gt;thin_check -m&lt;/code&gt; is the documented way to examine a metadata snap without taking the pool fully offline. Prefer scheduled offline checks on spare capacity when you can.&lt;/p&gt;

&lt;h2&gt;
  
  
  8) Loop-device lab (safe practice)
&lt;/h2&gt;

&lt;p&gt;No spare disk? Use sparse files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;truncate&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; 32G /var/tmp/lvm-thin-lab.img
&lt;span class="nb"&gt;sudo &lt;/span&gt;losetup &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nt"&gt;--show&lt;/span&gt; /var/tmp/lvm-thin-lab.img
&lt;span class="c"&gt;# suppose it printed /dev/loop5&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;pvcreate /dev/loop5
&lt;span class="nb"&gt;sudo &lt;/span&gt;vgcreate thinlab /dev/loop5

&lt;span class="nb"&gt;sudo &lt;/span&gt;lvcreate &lt;span class="nt"&gt;--type&lt;/span&gt; thin-pool &lt;span class="nt"&gt;-n&lt;/span&gt; tp0 &lt;span class="nt"&gt;-L&lt;/span&gt; 8G thinlab
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvcreate &lt;span class="nt"&gt;-T&lt;/span&gt; thinlab/tp0 &lt;span class="nt"&gt;-V&lt;/span&gt; 20G &lt;span class="nt"&gt;-n&lt;/span&gt; vol0
&lt;span class="nb"&gt;sudo &lt;/span&gt;mkfs.xfs /dev/thinlab/vol0
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/thinlab
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount /dev/thinlab/vol0 /mnt/thinlab

&lt;span class="c"&gt;# consume some real pool space&lt;/span&gt;
&lt;span class="nb"&gt;dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/urandom &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/mnt/thinlab/blob &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1M &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1024 &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;progress
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvs &lt;span class="nt"&gt;-o&lt;/span&gt; name,lv_size,data_percent,metadata_percent thinlab

&lt;span class="nb"&gt;sudo &lt;/span&gt;lvcreate &lt;span class="nt"&gt;--snapshot&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; vol0-s1 thinlab/vol0
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvs &lt;span class="nt"&gt;-a&lt;/span&gt; thinlab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cleanup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /mnt/thinlab
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvremove &lt;span class="nt"&gt;-y&lt;/span&gt; thinlab/vol0-s1 thinlab/vol0 thinlab/tp0
&lt;span class="nb"&gt;sudo &lt;/span&gt;vgremove &lt;span class="nt"&gt;-y&lt;/span&gt; thinlab
&lt;span class="nb"&gt;sudo &lt;/span&gt;pvremove /dev/loop5
&lt;span class="nb"&gt;sudo &lt;/span&gt;losetup &lt;span class="nt"&gt;-d&lt;/span&gt; /dev/loop5
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/tmp/lvm-thin-lab.img
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  9) Operational checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pool size is the real capacity.&lt;/strong&gt; Sum of thin virtual sizes can exceed it; plan for actual written bytes + snapshot delta.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch both Data% and Meta%.&lt;/strong&gt; Metadata surprises hurt more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave VG free space&lt;/strong&gt; if you enable autoextend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run &lt;code&gt;lvm2-monitor&lt;/code&gt; / dmeventd&lt;/strong&gt; and confirm &lt;code&gt;seg_monitor&lt;/code&gt; on the pool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick &lt;code&gt;--errorwhenfull&lt;/code&gt; deliberately&lt;/strong&gt; for each host class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TRIM/fstrim&lt;/strong&gt; so deletes can return blocks to the pool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thin snapshots: no &lt;code&gt;-L&lt;/code&gt;&lt;/strong&gt;, learn activation skip (&lt;code&gt;-K&lt;/code&gt;), prune old snaps before the pool fills with unique blocks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups still matter.&lt;/strong&gt; Thin snapshots are rollback/clone tools on the &lt;em&gt;same&lt;/em&gt; pool/disk failure domain—not off-box backups (contrast with &lt;code&gt;btrfs send/receive&lt;/code&gt; or real backup software).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raid the components if the data matters.&lt;/strong&gt; &lt;code&gt;lvmthin(7)&lt;/code&gt; shows building RAID LVs for data/metadata, then &lt;code&gt;lvconvert --type thin-pool&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After metadata repair, fsck/xfs_repair the filesystems&lt;/strong&gt; that lived on affected thin LVs.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  10) Rollback / removal
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# remove snaps first, then thin LVs, then pool&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvremove &lt;span class="nt"&gt;-y&lt;/span&gt; vg0/vm-disk0-snap2 vg0/vm-disk0-snap1
&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /mnt/vm-disk0
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvremove &lt;span class="nt"&gt;-y&lt;/span&gt; vg0/vm-disk0
&lt;span class="nb"&gt;sudo &lt;/span&gt;lvremove &lt;span class="nt"&gt;-y&lt;/span&gt; vg0/tp0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Disable autoextend policy if you are decommissioning monitoring expectations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# threshold 100 = disabled per lvmthin(7)&lt;/span&gt;
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'s/.*thin_pool_autoextend.*/&amp;amp;/p'&lt;/span&gt; /etc/lvm/lvm.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Boundaries (what this post is not)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not&lt;/strong&gt; Btrfs snapshots or &lt;code&gt;btrfs send/receive&lt;/code&gt; off-box replication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not&lt;/strong&gt; mdadm array monitoring/scrub.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not&lt;/strong&gt; DM-Multipath path redundancy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not&lt;/strong&gt; filesystem quotas or project quotas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not&lt;/strong&gt; a license to oversubscribe pools without monitoring — thin is an allocation strategy, not free capacity.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;lvmthin(7)&lt;/code&gt; — LVM thin provisioning overview, autoextend, snapshots, conversion patterns: &lt;a href="https://man7.org/linux/man-pages/man7/lvmthin.7.html" rel="noopener noreferrer"&gt;https://man7.org/linux/man-pages/man7/lvmthin.7.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lvcreate(8)&lt;/code&gt; — thin-pool / thin LV options (&lt;code&gt;--virtualsize&lt;/code&gt;, &lt;code&gt;--errorwhenfull&lt;/code&gt;, &lt;code&gt;--discards&lt;/code&gt;, &lt;code&gt;--chunksize&lt;/code&gt;, &lt;code&gt;--poolmetadatasize&lt;/code&gt;): &lt;a href="https://man7.org/linux/man-pages/man8/lvcreate.8.html" rel="noopener noreferrer"&gt;https://man7.org/linux/man-pages/man8/lvcreate.8.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Linux kernel admin guide — &lt;em&gt;Thin provisioning&lt;/em&gt; (&lt;code&gt;dm-thin-pool&lt;/code&gt; / &lt;code&gt;thin&lt;/code&gt; targets, low water mark, &lt;code&gt;needs_check&lt;/code&gt;, error/queue behavior): &lt;a href="https://www.kernel.org/doc/html/latest/admin-guide/device-mapper/thin-provisioning.html" rel="noopener noreferrer"&gt;https://www.kernel.org/doc/html/latest/admin-guide/device-mapper/thin-provisioning.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dmeventd(8)&lt;/code&gt; — thin plugin thresholds, &lt;code&gt;lvextend --use-policies&lt;/code&gt;, environment variables: &lt;a href="https://man7.org/linux/man-pages/man8/dmeventd.8.html" rel="noopener noreferrer"&gt;https://man7.org/linux/man-pages/man8/dmeventd.8.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;thin_check(8)&lt;/code&gt; (Debian thin-provisioning-tools) — metadata validation and &lt;code&gt;--clear-needs-check-flag&lt;/code&gt;: &lt;a href="https://manpages.debian.org/bookworm/thin-provisioning-tools/thin_check.8.en.html" rel="noopener noreferrer"&gt;https://manpages.debian.org/bookworm/thin-provisioning-tools/thin_check.8.en.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lvm.conf(5)&lt;/code&gt; / &lt;code&gt;lvmconfig&lt;/code&gt; — configuration cascade and activation settings: &lt;a href="https://man7.org/linux/man-pages/man5/lvm.conf.5.html" rel="noopener noreferrer"&gt;https://man7.org/linux/man-pages/man5/lvm.conf.5.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Thin provisioning pays off when virtual sprawl is real but simultaneous dirty data is not—and when you treat pool &lt;strong&gt;Data%&lt;/strong&gt; / &lt;strong&gt;Meta%&lt;/strong&gt; as first-class capacity signals. Build the pool deliberately, monitor it, autoextend into spare VG space, and keep &lt;code&gt;thin_check&lt;/code&gt; in your recovery muscle memory. That is the difference between “clever storage” and a very quiet, very full disk at 3 a.m.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>storage</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Stop Paying Bridge Tax for Every Container IP: Practical MACVLAN and IPVLAN with systemd-networkd on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Tue, 01 Sep 2026 05:04:15 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-paying-bridge-tax-for-every-container-ip-practical-macvlan-and-ipvlan-with-systemd-networkd-26o6</link>
      <guid>https://dev.to/lyraalishaikh/stop-paying-bridge-tax-for-every-container-ip-practical-macvlan-and-ipvlan-with-systemd-networkd-26o6</guid>
      <description>&lt;p&gt;You want a container, VM helper namespace, or service netns to own a &lt;strong&gt;real LAN address&lt;/strong&gt;—same subnet as the host, reachable from the rest of the network—without standing up &lt;code&gt;br0&lt;/code&gt;, enslaving the NIC, and living with bridge hairpin quirks forever.&lt;/p&gt;

&lt;p&gt;That is exactly what &lt;strong&gt;MACVLAN&lt;/strong&gt; and &lt;strong&gt;IPVLAN&lt;/strong&gt; are for.&lt;/p&gt;

&lt;p&gt;They stack a virtual interface on top of a parent NIC. The parent keeps carrying frames; the virtual device filters what it receives—by &lt;strong&gt;MAC&lt;/strong&gt; (MACVLAN) or by &lt;strong&gt;IP&lt;/strong&gt; (IPVLAN)—and shows up as a normal interface you can move into a network namespace, hand to &lt;code&gt;systemd-nspawn&lt;/code&gt;, or address on the host.&lt;/p&gt;

&lt;p&gt;This post is a practical operator guide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;when to pick MACVLAN vs IPVLAN&lt;/li&gt;
&lt;li&gt;what the modes actually mean&lt;/li&gt;
&lt;li&gt;how to wire them with &lt;code&gt;ip link&lt;/code&gt; for a lab&lt;/li&gt;
&lt;li&gt;how to make them &lt;strong&gt;persistent and declarative&lt;/strong&gt; with &lt;code&gt;systemd-networkd&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;how to fix the classic “host cannot talk to its own MACVLAN guests” trap&lt;/li&gt;
&lt;li&gt;how to verify and roll back safely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No bridge required. No NAT hairpin required for LAN peers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What problem these solve
&lt;/h2&gt;

&lt;p&gt;A Linux bridge is a software switch. It is powerful (VLAN filtering, STP, FDB control), and it is also more machinery than many homelab and single-host container layouts need.&lt;/p&gt;

&lt;p&gt;MACVLAN / IPVLAN give you:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;th&gt;Bridge&lt;/th&gt;
&lt;th&gt;MACVLAN / IPVLAN&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Extra L2/L3 endpoints on one NIC&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Software switch FDB/STP/VLAN filtering&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No (parent NIC + kernel mux)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Each endpoint looks like another host on the LAN&lt;/td&gt;
&lt;td&gt;Yes (via bridge port)&lt;/td&gt;
&lt;td&gt;Yes (especially MACVLAN)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Survives “switch allows only one MAC per port”&lt;/td&gt;
&lt;td&gt;Depends&lt;/td&gt;
&lt;td&gt;Prefer &lt;strong&gt;IPVLAN&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hostile netns that must not own a unique MAC&lt;/td&gt;
&lt;td&gt;Awkward&lt;/td&gt;
&lt;td&gt;Prefer &lt;strong&gt;IPVLAN&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Kernel IPVLAN docs put the decision bluntly: choose IPVLAN when the upstream switch policy allows &lt;strong&gt;only one MAC per port&lt;/strong&gt;, when too many MACs would force the NIC into promiscuous mode and hurt performance, or when a slave will live in an untrusted namespace that should not control L2 identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mental model in one paragraph
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MACVLAN&lt;/strong&gt;: parent NIC demuxes by destination (and optionally source) &lt;strong&gt;MAC&lt;/strong&gt;. Each MACVLAN gets its own hardware address. LAN switches see multiple MACs behind one port (unless your switch forbids that).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IPVLAN&lt;/strong&gt;: parent NIC demuxes by &lt;strong&gt;IP&lt;/strong&gt;. Slaves &lt;strong&gt;share the parent MAC&lt;/strong&gt;. L2 identity stays with the master; L3 is where isolation happens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are “stacked devices” in &lt;code&gt;systemd-networkd&lt;/code&gt; terms: &lt;code&gt;Kind=macvlan&lt;/code&gt; / &lt;code&gt;Kind=ipvlan&lt;/code&gt; in a &lt;code&gt;.netdev&lt;/code&gt; file, then a &lt;code&gt;.network&lt;/code&gt; file that binds the parent and addresses the child.&lt;/p&gt;

&lt;h2&gt;
  
  
  MACVLAN modes (what &lt;code&gt;ip-link&lt;/code&gt; and systemd actually support)
&lt;/h2&gt;

&lt;p&gt;From &lt;code&gt;ip link&lt;/code&gt; and &lt;code&gt;systemd.netdev(5)&lt;/code&gt;, MACVLAN modes are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;th&gt;Typical use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;private&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MACVLAN instances on the same parent &lt;strong&gt;cannot&lt;/strong&gt; talk to each other, even if the external switch supports hairpin&lt;/td&gt;
&lt;td&gt;Isolation between guests on one host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;vepa&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Guest-to-guest traffic is sent out the parent toward the external switch (802.1Qbg VEPA style). Needs switch hairpin &lt;strong&gt;or&lt;/strong&gt; an upstream router. &lt;strong&gt;Default&lt;/strong&gt; in &lt;code&gt;ip link&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Data-center VEPA topologies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;bridge&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Guests on the same parent can talk &lt;strong&gt;directly&lt;/strong&gt; without bouncing off the external switch&lt;/td&gt;
&lt;td&gt;Homelab containers that must reach each other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;passthru&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Single endpoint gets almost full control of the parent (often with &lt;code&gt;macvtap&lt;/code&gt;); only &lt;strong&gt;one&lt;/strong&gt; endpoint allowed; default forces parent promisc unless &lt;code&gt;nopromisc&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Handing a NIC-like device to one VM/guest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;source&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Allowlist of source MACs (&lt;code&gt;SourceMACAddress=&lt;/code&gt; in systemd) for MAC-based association&lt;/td&gt;
&lt;td&gt;Specialized MAC-based VLANs / 802.1X-style setups&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;macvtap&lt;/code&gt; is the same filtering idea plus a &lt;code&gt;/dev/tapX&lt;/code&gt; character device for QEMU-style consumers. &lt;code&gt;systemd.netdev&lt;/code&gt; exposes &lt;code&gt;[MACVTAP]&lt;/code&gt; with the same keys as &lt;code&gt;[MACVLAN]&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  IPVLAN modes and flags
&lt;/h2&gt;

&lt;p&gt;From the kernel IPVLAN HOWTO and &lt;code&gt;systemd.netdev(5)&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Modes&lt;/strong&gt; (&lt;code&gt;Mode=&lt;/code&gt; in systemd; &lt;code&gt;mode&lt;/code&gt; in &lt;code&gt;ip link&lt;/code&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Multicast/broadcast&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;L2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;TX up through the slave stack, then queued on the master for send; RX like a normal L2 endpoint&lt;/td&gt;
&lt;td&gt;Slaves can RX/TX mcast/bcast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;L3&lt;/strong&gt; (kernel default)&lt;/td&gt;
&lt;td&gt;TX up to L3 on the slave; L2/routing decisions happen in the &lt;strong&gt;master&lt;/strong&gt; stack instance&lt;/td&gt;
&lt;td&gt;Slaves &lt;strong&gt;do not&lt;/strong&gt; send/receive mcast/bcast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;L3S&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Like L3, but &lt;strong&gt;symmetric&lt;/strong&gt; so conntrack/iptables-style path works&lt;/td&gt;
&lt;td&gt;Same L3 restrictions; slight cost for conntrack correctness&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Flags&lt;/strong&gt; (&lt;code&gt;Flags=&lt;/code&gt; in systemd):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;bridge&lt;/strong&gt; (default)&lt;/td&gt;
&lt;td&gt;Slaves can cross-talk among themselves and via the master&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;private&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No cross-talk between slaves&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;vepa&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Offload switching to external entity (802.1Qbg). Note: IPVLAN reuses the &lt;strong&gt;master MAC&lt;/strong&gt;, so VEPA neighbor paths can look like same src/dst MAC and upset switches&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Important operational rule from the kernel docs: for a given master, &lt;strong&gt;all IPVLAN slaves share one operating mode&lt;/strong&gt; (L2 or L3/L3S). Pick once per parent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lab: create devices with &lt;code&gt;ip link&lt;/code&gt; (ephemeral)
&lt;/h2&gt;

&lt;p&gt;Replace &lt;code&gt;eth0&lt;/code&gt; with your real parent (&lt;code&gt;ip -br link&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  MACVLAN bridge mode (guests can talk)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create two MACVLAN endpoints on the parent&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add &lt;span class="nb"&gt;link &lt;/span&gt;eth0 name macvlan0 &lt;span class="nb"&gt;type &lt;/span&gt;macvlan mode bridge
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add &lt;span class="nb"&gt;link &lt;/span&gt;eth0 name macvlan1 &lt;span class="nb"&gt;type &lt;/span&gt;macvlan mode bridge

&lt;span class="c"&gt;# Optional: pin MACs if your LAN/DHCP cares&lt;/span&gt;
&lt;span class="c"&gt;# sudo ip link set macvlan0 address 02:11:22:33:44:50&lt;/span&gt;
&lt;span class="c"&gt;# sudo ip link set macvlan1 address 02:11:22:33:44:51&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;macvlan0 up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;macvlan1 up

&lt;span class="c"&gt;# Address them (example static lab subnet — use YOUR LAN plan)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip addr add 192.0.2.10/24 dev macvlan0
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip addr add 192.0.2.11/24 dev macvlan1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Move into a netns when you want isolation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns add ns-app
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;macvlan0 netns ns-app
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nt"&gt;-n&lt;/span&gt; ns-app &lt;span class="nb"&gt;link set &lt;/span&gt;lo up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nt"&gt;-n&lt;/span&gt; ns-app &lt;span class="nb"&gt;link set &lt;/span&gt;macvlan0 up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nt"&gt;-n&lt;/span&gt; ns-app addr add 192.0.2.10/24 dev macvlan0
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nt"&gt;-n&lt;/span&gt; ns-app route add default via 192.0.2.1 dev macvlan0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  IPVLAN L2 bridge (share parent MAC, still L2-ish)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add &lt;span class="nb"&gt;link &lt;/span&gt;eth0 name ipvl0 &lt;span class="nb"&gt;type &lt;/span&gt;ipvlan mode l2 bridge
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add &lt;span class="nb"&gt;link &lt;/span&gt;eth0 name ipvl1 &lt;span class="nb"&gt;type &lt;/span&gt;ipvlan mode l2 bridge
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;ipvl0 up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;ipvl1 up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip addr add 192.0.2.20/24 dev ipvl0
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip addr add 192.0.2.21/24 dev ipvl1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  IPVLAN L3 (strict; good for many netns behind one MAC)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add &lt;span class="nb"&gt;link &lt;/span&gt;eth0 name ipvl0 &lt;span class="nb"&gt;type &lt;/span&gt;ipvlan mode l3 bridge
&lt;span class="c"&gt;# L3: no mcast/bcast on slaves; routing context is master-oriented&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inspect details:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nb"&gt;link &lt;/span&gt;show &lt;span class="nb"&gt;type &lt;/span&gt;macvlan
ip &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nb"&gt;link &lt;/span&gt;show &lt;span class="nb"&gt;type &lt;/span&gt;ipvlan
ip &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nb"&gt;link &lt;/span&gt;show macvlan0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The host-reachability trap (MACVLAN)
&lt;/h2&gt;

&lt;p&gt;This is the footgun that wastes hours:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If the &lt;strong&gt;host&lt;/strong&gt; keeps its IP on the bare parent NIC, and guests use MACVLAN on that same parent, the host often &lt;strong&gt;cannot&lt;/strong&gt; reach those guests (and vice versa), because frames never hairpin the way people expect.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix used by the ArchWiki &lt;code&gt;systemd-networkd&lt;/code&gt; MACVLAN guide (and it matches field practice):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do not address the parent&lt;/strong&gt; for host traffic.&lt;/li&gt;
&lt;li&gt;Create a &lt;strong&gt;host-side MACVLAN&lt;/strong&gt; in &lt;code&gt;bridge&lt;/code&gt; mode on the parent.&lt;/li&gt;
&lt;li&gt;Put the host’s IP/DHCP on &lt;strong&gt;that&lt;/strong&gt; MACVLAN.&lt;/li&gt;
&lt;li&gt;Give containers/netns their &lt;strong&gt;own&lt;/strong&gt; MACVLANs on the &lt;strong&gt;same parent&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;BindCarrier=&lt;/code&gt; so the host MACVLAN tracks the parent’s carrier.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Parent becomes a pure underlay; everyone—including the host—talks MACVLAN-to-MACVLAN in bridge mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persistent setup with systemd-networkd
&lt;/h2&gt;

&lt;p&gt;Assume parent NIC &lt;code&gt;enp1s0&lt;/code&gt;, host MACVLAN &lt;code&gt;mv-host&lt;/code&gt;, and you want containers to attach later.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Create the host MACVLAN netdev
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/network/25-mv-host.netdev&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[NetDev]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;mv-host&lt;/span&gt;
&lt;span class="py"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;macvlan&lt;/span&gt;
&lt;span class="c"&gt;# Optional stable MAC (else generated from name + machine-id):
# MACAddress=02:11:22:33:44:10
&lt;/span&gt;
&lt;span class="nn"&gt;[MACVLAN]&lt;/span&gt;
&lt;span class="py"&gt;Mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;bridge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2) Attach parent to that MACVLAN; strip addressing from parent
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/network/30-enp1s0.network&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;enp1s0&lt;/span&gt;

&lt;span class="nn"&gt;[Link]&lt;/span&gt;
&lt;span class="c"&gt;# Parent will not get an IP — wait only for carrier
&lt;/span&gt;&lt;span class="py"&gt;RequiredForOnline&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;carrier&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;MACVLAN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;mv-host&lt;/span&gt;
&lt;span class="py"&gt;DHCP&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;span class="py"&gt;IPv6AcceptRA&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;span class="py"&gt;LinkLocalAddressing&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;span class="py"&gt;MulticastDNS&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;span class="py"&gt;LLMNR&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MACVLAN=&lt;/code&gt; in the parent’s &lt;code&gt;[Network]&lt;/code&gt; section is how &lt;code&gt;systemd-networkd&lt;/code&gt; binds the lower device to the named macvlan netdev.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Address the host on &lt;code&gt;mv-host&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;DHCP example — &lt;code&gt;/etc/systemd/network/35-mv-host.network&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;mv-host&lt;/span&gt;

&lt;span class="nn"&gt;[Link]&lt;/span&gt;
&lt;span class="py"&gt;RequiredForOnline&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;routable&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;BindCarrier&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;enp1s0&lt;/span&gt;
&lt;span class="py"&gt;DHCP&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="c"&gt;# Optional: keep DNS via resolved
# Domains=lan
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Static example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;mv-host&lt;/span&gt;

&lt;span class="nn"&gt;[Link]&lt;/span&gt;
&lt;span class="py"&gt;RequiredForOnline&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;routable&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;BindCarrier&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;enp1s0&lt;/span&gt;
&lt;span class="py"&gt;Address&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;192.0.2.5/24&lt;/span&gt;
&lt;span class="py"&gt;Gateway&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;192.0.2.1&lt;/span&gt;
&lt;span class="py"&gt;DNS&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;192.0.2.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BindCarrier=enp1s0&lt;/code&gt; ties admin/carrier behavior to the physical link so a cable pull takes the host endpoint down with the parent.&lt;/p&gt;

&lt;h3&gt;
  
  
  4) Optional second MACVLAN for a service netns / app endpoint
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/network/25-mv-app.netdev&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[NetDev]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;mv-app&lt;/span&gt;
&lt;span class="py"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;macvlan&lt;/span&gt;

&lt;span class="nn"&gt;[MACVLAN]&lt;/span&gt;
&lt;span class="py"&gt;Mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;bridge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/systemd/network/36-mv-app.network&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;mv-app&lt;/span&gt;

&lt;span class="nn"&gt;[Link]&lt;/span&gt;
&lt;span class="py"&gt;RequiredForOnline&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;routable&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;BindCarrier&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;enp1s0&lt;/span&gt;
&lt;span class="py"&gt;Address&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;192.0.2.50/24&lt;/span&gt;
&lt;span class="py"&gt;Gateway&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;192.0.2.1&lt;/span&gt;
&lt;span class="py"&gt;DNS&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;192.0.2.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You still need the parent file to also create/bind &lt;code&gt;mv-app&lt;/code&gt;. With multiple MACVLANs, list them on the parent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;MACVLAN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;mv-host&lt;/span&gt;
&lt;span class="py"&gt;MACVLAN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;mv-app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Exact multi-value repetition is the normal systemd networkd pattern for several stacked devices of the same type.)&lt;/p&gt;

&lt;h3&gt;
  
  
  5) Apply
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;networkctl reload
&lt;span class="c"&gt;# or:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart systemd-networkd.service

networkctl
networkctl status mv-host
networkctl status enp1s0
ip &lt;span class="nt"&gt;-br&lt;/span&gt; addr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm parent has &lt;strong&gt;no&lt;/strong&gt; global unicast IP, host IP lives on &lt;code&gt;mv-host&lt;/code&gt;, and &lt;code&gt;mv-host&lt;/code&gt; shows &lt;code&gt;macvlan&lt;/code&gt; kind in &lt;code&gt;ip -d link show mv-host&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  systemd-nspawn: attach MACVLAN without a bridge
&lt;/h2&gt;

&lt;p&gt;On the host, keep the parent underlay pattern above (host on &lt;code&gt;mv-host&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;For a machine unit, &lt;code&gt;/etc/systemd/nspawn/web.nspawn&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;MACVLAN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;enp1s0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or on the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemd-nspawn &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--machine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;web &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network-macvlan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;enp1s0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-b&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside the container, nspawn names the interface &lt;code&gt;mv-&amp;lt;parent&amp;gt;&lt;/code&gt; (for example &lt;code&gt;mv-enp1s0&lt;/code&gt;). Configure it with networkd inside the guest:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/network/30-mv-enp1s0.network&lt;/code&gt; &lt;strong&gt;in the container&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;mv-enp1s0&lt;/span&gt;

&lt;span class="nn"&gt;[Link]&lt;/span&gt;
&lt;span class="py"&gt;RequiredForOnline&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;routable&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;DHCP&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the ArchWiki-documented naming and DHCP pattern for nspawn MACVLAN guests.&lt;/p&gt;

&lt;h2&gt;
  
  
  IPVLAN with systemd-networkd
&lt;/h2&gt;

&lt;p&gt;Same stacking idea; different kind and section.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/network/25-ipvl-app.netdev&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[NetDev]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ipvl-app&lt;/span&gt;
&lt;span class="py"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ipvlan&lt;/span&gt;

&lt;span class="nn"&gt;[IPVLAN]&lt;/span&gt;
&lt;span class="py"&gt;Mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;L2&lt;/span&gt;
&lt;span class="py"&gt;Flags&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;bridge&lt;/span&gt;
&lt;span class="c"&gt;# For many netns + one MAC upstream, prefer:
# Mode=L3
# Flags=private
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parent binding uses &lt;code&gt;IPVLAN=&lt;/code&gt; the same way &lt;code&gt;MACVLAN=&lt;/code&gt; works (see &lt;code&gt;systemd.network(5)&lt;/code&gt; network device binding keys alongside &lt;code&gt;Bridge=&lt;/code&gt;, &lt;code&gt;Bond=&lt;/code&gt;, &lt;code&gt;VRF=&lt;/code&gt;, etc.).&lt;/p&gt;

&lt;p&gt;Minimal parent snippet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;enp1s0&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;IPVLAN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ipvl-app&lt;/span&gt;
&lt;span class="c"&gt;# Still strip host addressing from parent if host traffic moves elsewhere
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Address &lt;code&gt;ipvl-app&lt;/code&gt; in its own &lt;code&gt;.network&lt;/code&gt; file. For L3 mode, remember: no multicast/broadcast on the slave—DHCP and neighbor discovery designs must respect that (static addressing or master-side helpers are common).&lt;/p&gt;

&lt;p&gt;Kernel example workflow for netns (document-accurate):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns add ns0
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add &lt;span class="nb"&gt;link &lt;/span&gt;eth0 name ipvl0 &lt;span class="nb"&gt;type &lt;/span&gt;ipvlan mode l2
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;dev ipvl0 netns ns0
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;ns0 bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'
  ip link set lo up
  ip link set ipvl0 up
  ip -4 addr add 192.0.2.30/24 dev ipvl0
  ip -4 route add default via 192.0.2.1 dev ipvl0
'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Choosing quickly
&lt;/h2&gt;

&lt;p&gt;Use &lt;strong&gt;MACVLAN bridge&lt;/strong&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your switch is fine with multiple MACs per port&lt;/li&gt;
&lt;li&gt;you want each guest to look like a normal Ethernet host&lt;/li&gt;
&lt;li&gt;guests must talk to each other and the host (with host-on-MACVLAN pattern)&lt;/li&gt;
&lt;li&gt;you integrate with &lt;code&gt;systemd-nspawn --network-macvlan=&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use &lt;strong&gt;IPVLAN L2/L3&lt;/strong&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;upstream allows &lt;strong&gt;one MAC&lt;/strong&gt; only&lt;/li&gt;
&lt;li&gt;you create &lt;strong&gt;lots&lt;/strong&gt; of endpoints and MAC pressure / promisc is a concern&lt;/li&gt;
&lt;li&gt;untrusted netns must not control L2 identity&lt;/li&gt;
&lt;li&gt;you are OK with L3-mode multicast limitations when you pick L3/L3S&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use a &lt;strong&gt;real bridge&lt;/strong&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you need VLAN-aware switching, STP, hairpin control, or complex L2 policy on the host&lt;/li&gt;
&lt;li&gt;you already standardized on &lt;code&gt;br0&lt;/code&gt; + veth for the fleet&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MACVLAN/IPVLAN are not a replacement for VRF isolation, bond failover, or nftables edge policy—they compose with those layers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification checklist
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Kinds and modes&lt;/span&gt;
ip &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nb"&gt;link &lt;/span&gt;show &lt;span class="nb"&gt;type &lt;/span&gt;macvlan
ip &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nb"&gt;link &lt;/span&gt;show &lt;span class="nb"&gt;type &lt;/span&gt;ipvlan

&lt;span class="c"&gt;# Addresses and routes&lt;/span&gt;
ip &lt;span class="nt"&gt;-br&lt;/span&gt; addr
ip route
networkctl status mv-host
networkctl status mv-app

&lt;span class="c"&gt;# From host MACVLAN to guest MACVLAN (same L2)&lt;/span&gt;
ping &lt;span class="nt"&gt;-c3&lt;/span&gt; 192.0.2.50

&lt;span class="c"&gt;# From another LAN host (not the parent-only host mistake)&lt;/span&gt;
&lt;span class="c"&gt;# ping the guest IP from a laptop on the same subnet&lt;/span&gt;

&lt;span class="c"&gt;# nspawn&lt;/span&gt;
machinectl status web
&lt;span class="c"&gt;# inside: ip -br link; ip -br addr&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If host↔guest fails but laptop↔guest works, you almost certainly left the host IP on the bare parent. Move host addressing onto a MACVLAN in &lt;code&gt;bridge&lt;/code&gt; mode.&lt;/p&gt;

&lt;p&gt;If guest↔guest fails, check mode: &lt;code&gt;private&lt;/code&gt; and some &lt;code&gt;vepa&lt;/code&gt; layouts will not local-switch. Prefer &lt;code&gt;Mode=bridge&lt;/code&gt; for same-host east-west.&lt;/p&gt;

&lt;p&gt;If DHCP fails on IPVLAN L3, that is expected multicast/broadcast limitation—use static addressing or redesign with L2 mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollback
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Ephemeral lab devices&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;del macvlan0 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;del macvlan1 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;del ipvl0 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;ip netns del ns-app 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;ip netns del ns0 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# networkd: remove or mask the .netdev/.network files you added&lt;/span&gt;
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/systemd/network/25-mv-host.netdev &lt;span class="se"&gt;\&lt;/span&gt;
           /etc/systemd/network/25-mv-app.netdev &lt;span class="se"&gt;\&lt;/span&gt;
           /etc/systemd/network/35-mv-host.network &lt;span class="se"&gt;\&lt;/span&gt;
           /etc/systemd/network/36-mv-app.network
&lt;span class="c"&gt;# Restore a normal parent .network with DHCP/static on enp1s0&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;networkctl reload
&lt;span class="c"&gt;# or: sudo systemctl restart systemd-networkd.service&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep a known-good parent &lt;code&gt;.network&lt;/code&gt; snippet nearby before you cut over. Cable-pull and reboot once after the first successful cutover—&lt;code&gt;RequiredForOnline=&lt;/code&gt; mistakes show up there, not in a happy &lt;code&gt;networkctl reload&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security and ops notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;MACVLAN guests are &lt;strong&gt;on your LAN&lt;/strong&gt;. Treat them like physical hosts: firewall them (&lt;code&gt;nftables&lt;/code&gt; on the guest or at the gateway), do not assume “container” means “isolated from L2.”&lt;/li&gt;
&lt;li&gt;IPVLAN private + untrusted netns is a better story when L2 spoofing is in the threat model; still enforce IP policy upstream.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;passthru&lt;/code&gt; MACVLAN/MACVTAP is powerful and exclusive—do not stack casual guests on the same parent in that mode.&lt;/li&gt;
&lt;li&gt;Parent NIC offloads, MTU, and bonding still matter: you can stack MACVLAN on a bond; keep MTU consistent end-to-end.&lt;/li&gt;
&lt;li&gt;This is &lt;strong&gt;not&lt;/strong&gt; hairpin NAT. If you need LAN clients to reach a DNAT VIP on the gateway itself, that is a different nftables problem (SNAT/DNAT/hairpin NAT). MACVLAN solves “give the workload its own L2/L3 identity,” not “rewrite ports on a router.”&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://manpages.debian.org/bookworm/iproute2/ip-link.8.en.html" rel="noopener noreferrer"&gt;ip-link(8)&lt;/a&gt; — MACVLAN/MACVTAP modes (&lt;code&gt;private&lt;/code&gt;, &lt;code&gt;vepa&lt;/code&gt;, &lt;code&gt;bridge&lt;/code&gt;, &lt;code&gt;passthru&lt;/code&gt;, &lt;code&gt;source&lt;/code&gt;), &lt;code&gt;bcqueuelen&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://manpages.debian.org/testing/systemd/systemd.netdev.5.en.html" rel="noopener noreferrer"&gt;systemd.netdev(5)&lt;/a&gt; — &lt;code&gt;Kind=macvlan&lt;/code&gt; / &lt;code&gt;ipvlan&lt;/code&gt;, &lt;code&gt;[MACVLAN] Mode=&lt;/code&gt;, &lt;code&gt;[IPVLAN] Mode=&lt;/code&gt; / &lt;code&gt;Flags=&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://manpages.debian.org/testing/systemd/systemd.network.5.en.html" rel="noopener noreferrer"&gt;systemd.network(5)&lt;/a&gt; — matching, &lt;code&gt;RequiredForOnline=&lt;/code&gt;, &lt;code&gt;BindCarrier=&lt;/code&gt;, parent binding keys&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.kernel.org/networking/ipvlan.html" rel="noopener noreferrer"&gt;IPVLAN Driver HOWTO&lt;/a&gt; — L2/L3/L3S, flags, macvlan vs ipvlan decision criteria, netns example&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wiki.archlinux.org/title/Systemd-networkd" rel="noopener noreferrer"&gt;ArchWiki: systemd-networkd — MACVLAN bridge&lt;/a&gt; — host-on-MACVLAN pattern, nspawn &lt;code&gt;MACVLAN=&lt;/code&gt; / &lt;code&gt;--network-macvlan=&lt;/code&gt;, &lt;code&gt;mv-&amp;lt;parent&amp;gt;&lt;/code&gt; naming&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;If you only remember three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;MACVLAN&lt;/strong&gt; = per-guest MAC on a shared parent; &lt;strong&gt;IPVLAN&lt;/strong&gt; = shared MAC, IP demux—pick IPVLAN when the switch or threat model hates extra MACs.&lt;/li&gt;
&lt;li&gt;For same-host east-west and host access, use &lt;strong&gt;bridge&lt;/strong&gt; mode and put the &lt;strong&gt;host IP on a MACVLAN too&lt;/strong&gt;, not on the bare parent.&lt;/li&gt;
&lt;li&gt;Make it boring with &lt;strong&gt;&lt;code&gt;systemd-networkd&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;.netdev&lt;/code&gt; for the virtual device, parent &lt;code&gt;.network&lt;/code&gt; with &lt;code&gt;MACVLAN=&lt;/code&gt;/&lt;code&gt;IPVLAN=&lt;/code&gt;, child &lt;code&gt;.network&lt;/code&gt; with addressing + &lt;code&gt;BindCarrier=&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is how you stop paying bridge tax for every “just give this container a LAN IP” request—without pretending NAT hairpins or full software switches are free.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>systemd</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stop Broken LAN Port Forwards: Practical nftables SNAT, DNAT, Masquerade, and Hairpin NAT</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Mon, 31 Aug 2026 05:03:45 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-broken-lan-port-forwards-practical-nftables-snat-dnat-masquerade-and-hairpin-nat-18l6</link>
      <guid>https://dev.to/lyraalishaikh/stop-broken-lan-port-forwards-practical-nftables-snat-dnat-masquerade-and-hairpin-nat-18l6</guid>
      <description>&lt;h1&gt;
  
  
  Stop Broken LAN Port Forwards: Practical nftables SNAT, DNAT, Masquerade, and Hairpin NAT
&lt;/h1&gt;

&lt;p&gt;You published a port forward. External clients reach the service. Clients on the same LAN try the public hostname or WAN IP and hang. Or outbound traffic works until the ISP rotates your address and every hard-coded SNAT rule dies.&lt;/p&gt;

&lt;p&gt;That is not mysterious routing. It is incomplete &lt;strong&gt;Network Address Translation&lt;/strong&gt; (NAT) ownership in nftables: wrong chain type, missing hairpin SNAT, or SNAT pinned to an address that no longer exists.&lt;/p&gt;

&lt;p&gt;This article is a practical operator guide to &lt;strong&gt;stateful&lt;/strong&gt; nftables NAT on Linux:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;masquerade&lt;/code&gt; for dynamic WAN addresses&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;snat&lt;/code&gt; for stable public addresses and pools&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dnat&lt;/code&gt; for port forwards and multi-service maps&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;redirect&lt;/code&gt; for local-only rewrites&lt;/li&gt;
&lt;li&gt;hairpin NAT so LAN clients can use the public name/IP&lt;/li&gt;
&lt;li&gt;verification, persistence, and rollback&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is intentionally about &lt;strong&gt;address translation&lt;/strong&gt;, not anti-spoof FIB checks, SYNPROXY, dynamic ban sets, or flowtable fastpath.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Linux with nftables userspace (&lt;code&gt;nft&lt;/code&gt;) and kernel Netfilter NAT support&lt;/li&gt;
&lt;li&gt;Root (or equivalent) to load rules and enable forwarding&lt;/li&gt;
&lt;li&gt;A host that forwards traffic (router/gateway), or a lab netns pair&lt;/li&gt;
&lt;li&gt;Kernel &lt;strong&gt;4.18+&lt;/strong&gt; recommended so you do not need empty prerouting/postrouting NAT chains just to make reply traffic work&lt;/li&gt;
&lt;li&gt;Kernel &lt;strong&gt;5.2+&lt;/strong&gt; if you want &lt;strong&gt;inet&lt;/strong&gt;-family stateful NAT (IPv4+IPv6 in one table)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nft &lt;span class="nt"&gt;--version&lt;/span&gt;
sysctl net.ipv4.ip_forward
lsmod | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'nf_nat|nft_nat|nf_conntrack'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable IPv4 forwarding when the box is a router (runtime + persistent example):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.ip_forward&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'net.ipv4.ip_forward=1\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/sysctl.d/99-ip-forward.conf
sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without forwarding, DNAT to a LAN host never leaves the gateway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mental model: NAT is not a filter chain
&lt;/h2&gt;

&lt;p&gt;nftables wiki is explicit: the &lt;strong&gt;&lt;code&gt;nat&lt;/code&gt; chain type&lt;/strong&gt; has special semantics.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;strong&gt;first packet&lt;/strong&gt; of a flow looks up NAT rules and creates a NAT binding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Later packets&lt;/strong&gt; in that flow do &lt;strong&gt;not&lt;/strong&gt; re-walk NAT rules; the NAT engine reuses the binding.&lt;/li&gt;
&lt;li&gt;Putting a NAT statement in a &lt;strong&gt;&lt;code&gt;filter&lt;/code&gt;&lt;/strong&gt; chain is an error.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Create dedicated NAT base chains on the right hooks and priorities:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;th&gt;Hook&lt;/th&gt;
&lt;th&gt;Typical priority keyword / value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Destination rewrite (DNAT / redirect)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;prerouting&lt;/code&gt; (and sometimes &lt;code&gt;output&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dstnat&lt;/code&gt; / &lt;code&gt;-100&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source rewrite (SNAT / masquerade)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postrouting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;srcnat&lt;/code&gt; / &lt;code&gt;100&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Filter still matters. NAT rewrites addresses; &lt;strong&gt;filter&lt;/strong&gt; decides whether the rewritten flow is allowed in &lt;code&gt;forward&lt;/code&gt; / &lt;code&gt;input&lt;/code&gt;. Treat them as two layers.&lt;/p&gt;

&lt;p&gt;Packet path for a forwarded flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prerouting (DNAT) → forward (filter) → postrouting (SNAT/masquerade)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Locally generated traffic uses &lt;code&gt;output&lt;/code&gt; then &lt;code&gt;postrouting&lt;/code&gt;. Local delivery after DNAT-to-self uses &lt;code&gt;input&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lab topology (names used below)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet / "WAN"
        |
   eth0  (WAN)  — gateway 203.0.113.10  (or DHCP)
   eth1  (LAN)  — gateway 192.168.1.1/24
        |
   LAN hosts    — 192.168.1.0/24
   web backend  — 192.168.1.50:80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace interface names and prefixes with yours. Use RFC 5737 documentation addresses in examples if you paste configs into tickets.&lt;/p&gt;

&lt;h2&gt;
  
  
  1) Outbound Internet access: masquerade first
&lt;/h2&gt;

&lt;p&gt;For a home/lab gateway whose WAN address can change, prefer &lt;strong&gt;masquerade&lt;/strong&gt;. It is SNAT where the source address is taken from the egress interface automatically (available since Linux 3.18).&lt;/p&gt;

&lt;p&gt;Minimal NAT table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nft add table ip nat
nft &lt;span class="s1"&gt;'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'&lt;/span&gt;
nft add rule ip nat postrouting ip saddr 192.168.1.0/24 oifname &lt;span class="s2"&gt;"eth0"&lt;/span&gt; masquerade
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or as a file fragment (&lt;code&gt;/etc/nftables.d/nat-masq.nft&lt;/code&gt; style):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;table ip nat {
  chain postrouting {
    type nat hook postrouting priority srcnat; policy accept;
    ip saddr 192.168.1.0/24 oifname "eth0" masquerade
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You still need a &lt;strong&gt;forward&lt;/strong&gt; policy that allows LAN → WAN (and usually established/related back). The nftables home-router sample does both: allow private ingress to forward, then masquerade private sources out the world interface.&lt;/p&gt;

&lt;p&gt;Sketch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;table ip filter {
  chain forward {
    type filter hook forward priority filter; policy drop;
    ct state established,related accept
    ct state invalid drop
    iifname "eth1" oifname "eth0" accept
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  When to use &lt;code&gt;snat&lt;/code&gt; instead of &lt;code&gt;masquerade&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Use &lt;strong&gt;SNAT&lt;/strong&gt; when the public address is stable and you want an explicit mapping (or a pool):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nft add rule ip nat postrouting ip saddr 192.168.1.0/24 oifname &lt;span class="s2"&gt;"eth0"&lt;/span&gt; snat to 203.0.113.10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pools and ranges are supported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# prefix pool&lt;/span&gt;
nft add rule ip nat postrouting snat to 203.0.113.10/31

&lt;span class="c"&gt;# address range&lt;/span&gt;
nft add rule ip nat postrouting snat to 203.0.113.10-203.0.113.20

&lt;span class="c"&gt;# optional TCP source-port range with address pool&lt;/span&gt;
nft add rule ip nat postrouting ip protocol tcp snat to 203.0.113.10-203.0.113.20:30000-40000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NAT flags (kernel 3.18+), combinable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;random&lt;/code&gt; — randomize source port mapping&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fully-random&lt;/code&gt; — full port randomization&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;persistent&lt;/code&gt; — prefer the same address mapping per client
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nft add rule ip nat postrouting ip saddr 192.168.1.0/24 oifname &lt;span class="s2"&gt;"eth0"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  snat to 203.0.113.10 fully-random
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rule of thumb:&lt;/strong&gt; dynamic WAN → &lt;code&gt;masquerade&lt;/code&gt;; fixed WAN or multi-IP egress → &lt;code&gt;snat&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2) Inbound services: DNAT port forwards
&lt;/h2&gt;

&lt;p&gt;DNAT belongs on &lt;strong&gt;prerouting&lt;/strong&gt; with destination-NAT priority:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nft &lt;span class="s1"&gt;'add chain ip nat prerouting { type nat hook prerouting priority dstnat; policy accept; }'&lt;/span&gt;
nft &lt;span class="s1"&gt;'add rule ip nat prerouting iifname "eth0" tcp dport { 80, 443 } dnat to 192.168.1.50'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That rewrites destination to the LAN backend for new flows arriving on the WAN interface.&lt;/p&gt;

&lt;h3&gt;
  
  
  Forward filter for the rewritten destination
&lt;/h3&gt;

&lt;p&gt;DNAT alone is not permission. Allow the post-DNAT path explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;table ip filter {
  chain forward {
    type filter hook forward priority filter; policy drop;
    ct state established,related accept
    ct state invalid drop

    # LAN out
    iifname "eth1" oifname "eth0" accept

    # WAN → published web backend (after DNAT, daddr is internal)
    iifname "eth0" oifname "eth1" ip daddr 192.168.1.50 tcp dport { 80, 443 } accept
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you only match the &lt;strong&gt;pre-DNAT&lt;/strong&gt; public address in forward, you will drop traffic you thought you published.&lt;/p&gt;

&lt;h3&gt;
  
  
  Redirect: DNAT to the local machine
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;redirect&lt;/code&gt; is a special DNAT that targets the local host (kernel 3.19+). It only makes sense in &lt;code&gt;prerouting&lt;/code&gt; and &lt;code&gt;output&lt;/code&gt; NAT chains.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# send inbound SSH on 22 to local 2222&lt;/span&gt;
nft add rule ip nat prerouting tcp dport 22 redirect to 2222

&lt;span class="c"&gt;# send locally generated DoT (853/tcp) to a local proxy on 10053&lt;/span&gt;
nft add rule ip nat output tcp dport 853 redirect to 10053
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use redirect for local proxies and “listen elsewhere” tricks. Use &lt;code&gt;dnat to &amp;lt;lan-ip&amp;gt;&lt;/code&gt; when the real service is another host.&lt;/p&gt;

&lt;h2&gt;
  
  
  3) Map-based multi-port DNAT (stop cloning rules)
&lt;/h2&gt;

&lt;p&gt;If you maintain a wall of one-off DNAT lines, collapse them with maps.&lt;/p&gt;

&lt;p&gt;Classic many-rule pattern becomes one DNAT statement with two maps (service port → backend IP, service port → backend port):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nft add rule ip nat prerouting dnat to &lt;span class="se"&gt;\&lt;/span&gt;
  tcp dport map &lt;span class="o"&gt;{&lt;/span&gt; 8080 : 192.168.1.50, 8443 : 192.168.1.50, 9000 : 192.168.1.60 &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  : tcp dport map &lt;span class="o"&gt;{&lt;/span&gt; 8080 : 80, 8443 : 443, 9000 : 9000 &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Named map with concatenated address &lt;strong&gt;and&lt;/strong&gt; port (great for many UDP/TCP publishes):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nft add map ip nat published &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;type &lt;/span&gt;inet_service : ipv4_addr &lt;span class="nb"&gt;.&lt;/span&gt; inet_service
&lt;span class="o"&gt;}&lt;/span&gt;

nft add element ip nat published &lt;span class="o"&gt;{&lt;/span&gt;
  8080 : 192.168.1.50 &lt;span class="nb"&gt;.&lt;/span&gt; 80,
  8443 : 192.168.1.50 &lt;span class="nb"&gt;.&lt;/span&gt; 443,
  9000 : 192.168.1.60 &lt;span class="nb"&gt;.&lt;/span&gt; 9000
&lt;span class="o"&gt;}&lt;/span&gt;

nft add rule ip nat prerouting iifname &lt;span class="s2"&gt;"eth0"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  dnat ip addr &lt;span class="nb"&gt;.&lt;/span&gt; port to tcp dport map @published
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anonymous maps work when the table is static. Named maps are better when you update publishes without rewriting the whole chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  4) Hairpin NAT: why LAN clients cannot use the public IP
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The failure mode
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;LAN client &lt;code&gt;192.168.1.20&lt;/code&gt; connects to public &lt;code&gt;203.0.113.10:443&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;DNAT rewrites destination to &lt;code&gt;192.168.1.50:443&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Backend replies to &lt;code&gt;192.168.1.20&lt;/code&gt; &lt;strong&gt;directly&lt;/strong&gt; (same L2/L3 LAN).&lt;/li&gt;
&lt;li&gt;Client expected replies from &lt;code&gt;203.0.113.10&lt;/code&gt;, not &lt;code&gt;192.168.1.50&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Stateful stack drops the asymmetric nonsense. Connection fails or stalls.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;External clients never hit this path because their return traffic must reverse through the gateway’s NAT binding.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: SNAT hairpinned LAN→LAN-via-public flows
&lt;/h3&gt;

&lt;p&gt;After DNAT, if the packet still leaves toward the LAN &lt;strong&gt;and&lt;/strong&gt; the original client was also on the LAN, SNAT the source to the gateway’s LAN address. The backend then replies to the gateway; the gateway un-SNATs and un-DNATs correctly toward the client.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;table ip nat {
  chain prerouting {
    type nat hook prerouting priority dstnat; policy accept;

    # publish HTTPS on WAN address/name to LAN backend
    tcp dport 443 dnat to 192.168.1.50
  }

  chain postrouting {
    type nat hook postrouting priority srcnat; policy accept;

    # normal Internet egress
    ip saddr 192.168.1.0/24 oifname "eth0" masquerade

    # hairpin: LAN client used public VIP/hostname, now going back into LAN
    ip saddr 192.168.1.0/24 ip daddr 192.168.1.50 oifname "eth1" \
      snat to 192.168.1.1
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes operators miss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Match &lt;strong&gt;post-DNAT&lt;/strong&gt; destination (&lt;code&gt;192.168.1.50&lt;/code&gt;), not only the public address.&lt;/li&gt;
&lt;li&gt;Hairpin SNAT target is usually the &lt;strong&gt;LAN gateway IP&lt;/strong&gt; on &lt;code&gt;eth1&lt;/code&gt;, not the WAN IP.&lt;/li&gt;
&lt;li&gt;You still need forward rules that allow LAN→LAN via the gateway for that service if your forward policy is not “accept all from LAN”.&lt;/li&gt;
&lt;li&gt;Some designs avoid hairpin entirely with split-horizon DNS (internal name → &lt;code&gt;192.168.1.50&lt;/code&gt;). Hairpin is what you want when one hostname must work everywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5) inet-family NAT (IPv4 + IPv6 together)
&lt;/h2&gt;

&lt;p&gt;Since Linux &lt;strong&gt;5.2&lt;/strong&gt;, stateful NAT works in &lt;strong&gt;&lt;code&gt;inet&lt;/code&gt;&lt;/strong&gt; tables. When you specify addresses, mark the family:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nft add table inet nat
nft &lt;span class="s1"&gt;'add chain inet nat prerouting { type nat hook prerouting priority dstnat; }'&lt;/span&gt;
nft &lt;span class="s1"&gt;'add chain inet nat postrouting { type nat hook postrouting priority srcnat; }'&lt;/span&gt;

nft add rule inet nat prerouting dnat ip to 192.168.1.50
nft add rule inet nat prerouting dnat ip6 to 2001:db8:1::50
nft add rule inet nat postrouting oifname &lt;span class="s2"&gt;"eth0"&lt;/span&gt; masquerade
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;IPv6 “NAT” is often the wrong product goal (prefer native routing/NPTv6 carefully). If you do need family-unified rulesets, inet NAT is the supported path.&lt;/p&gt;

&lt;h2&gt;
  
  
  6) Full compact gateway example
&lt;/h2&gt;

&lt;p&gt;Drop-in style ruleset combining masquerade, one published service, hairpin, and a tight forward policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#!/usr/sbin/nft -f
flush ruleset

define DEV_WAN = "eth0"
define DEV_LAN = "eth1"
define NET_LAN = 192.168.1.0/24
define IP_GW_LAN = 192.168.1.1
define IP_WEB = 192.168.1.50

table inet filter {
  chain input {
    type filter hook input priority filter; policy drop;
    ct state established,related accept
    ct state invalid drop
    iif "lo" accept
    iifname $DEV_LAN tcp dport 22 accept
    iifname $DEV_LAN udp dport 53 accept
    iifname $DEV_LAN tcp dport 53 accept
  }

  chain forward {
    type filter hook forward priority filter; policy drop;
    ct state established,related accept
    ct state invalid drop

    # LAN to Internet
    iifname $DEV_LAN oifname $DEV_WAN accept

    # published web (match post-DNAT)
    iifname $DEV_WAN oifname $DEV_LAN ip daddr $IP_WEB tcp dport { 80, 443 } accept

    # hairpin path LAN → gateway → LAN backend
    iifname $DEV_LAN oifname $DEV_LAN ip daddr $IP_WEB tcp dport { 80, 443 } accept
  }
}

table ip nat {
  chain prerouting {
    type nat hook prerouting priority dstnat; policy accept;
    iifname $DEV_WAN tcp dport { 80, 443 } dnat to $IP_WEB
    # optional: also DNAT when LAN clients target the WAN IP on the gateway
    iifname $DEV_LAN ip daddr 203.0.113.10 tcp dport { 80, 443 } dnat to $IP_WEB
  }

  chain postrouting {
    type nat hook postrouting priority srcnat; policy accept;
    ip saddr $NET_LAN oifname $DEV_WAN masquerade
    ip saddr $NET_LAN ip daddr $IP_WEB oifname $DEV_LAN snat to $IP_GW_LAN
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Load safely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nft &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/nftables.conf    &lt;span class="c"&gt;# syntax check&lt;/span&gt;
nft &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/nftables.conf       &lt;span class="c"&gt;# apply&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Debian/Ubuntu, enable the packaged service once the file is correct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; nftables.service
systemctl status nftables.service &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  7) Verify before you call it done
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Rules and counters
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nft list ruleset
nft list chain ip nat prerouting
nft list chain ip nat postrouting
nft list chain inet filter forward
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;counter&lt;/code&gt; on critical NAT/filter rules while commissioning so hits are obvious:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;iifname "eth0" tcp dport 443 counter dnat to 192.168.1.50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Conntrack is the source of truth
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# watch new DNATed HTTPS flows&lt;/span&gt;
conntrack &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 443

&lt;span class="c"&gt;# or snapshot&lt;/span&gt;
conntrack &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 443 | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want to see original and reply tuples reflecting DNAT/SNAT, not only filter accepts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Functional checks
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# from outside (or a simulated WAN netns)&lt;/span&gt;
curl &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt; 5 http://203.0.113.10/

&lt;span class="c"&gt;# hairpin from a LAN host using the public IP/name&lt;/span&gt;
curl &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt; 5 http://203.0.113.10/

&lt;span class="c"&gt;# direct LAN path still works&lt;/span&gt;
curl &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt; 5 http://192.168.1.50/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If external works and hairpin fails, your DNAT is fine and hairpin SNAT/forward is not.&lt;/p&gt;

&lt;h3&gt;
  
  
  iptables coexistence note
&lt;/h3&gt;

&lt;p&gt;Before kernel 4.18, do not run iptables NAT and nft NAT together; unload &lt;code&gt;iptable_nat&lt;/code&gt; if needed. On newer kernels both can exist; the &lt;strong&gt;first matching NAT mapping&lt;/strong&gt; by priority wins. Prefer one NAT owner.&lt;/p&gt;

&lt;h2&gt;
  
  
  8) Persistence and change discipline
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Keep the authoritative rules in &lt;code&gt;/etc/nftables.conf&lt;/code&gt; (and includes).&lt;/li&gt;
&lt;li&gt;Always &lt;code&gt;nft -c -f&lt;/code&gt; before apply.&lt;/li&gt;
&lt;li&gt;Prefer atomic file loads over dozens of ad-hoc &lt;code&gt;nft add rule&lt;/code&gt; in shell history.&lt;/li&gt;
&lt;li&gt;Save a rollback copy before experiments:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nft list ruleset &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /root/nftables-backup-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F-%H%M&lt;span class="si"&gt;)&lt;/span&gt;.nft
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Document interface names and prefixes as &lt;code&gt;define&lt;/code&gt;s so the next edit does not invent a second topology dialect.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  9) Rollback
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# restore last known-good file&lt;/span&gt;
nft &lt;span class="nt"&gt;-f&lt;/span&gt; /root/nftables-backup-YYYY-MM-DD-HHMM.nft

&lt;span class="c"&gt;# or empty everything (outage risk on remote gateways!)&lt;/span&gt;
nft flush ruleset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a remote gateway, never &lt;code&gt;flush ruleset&lt;/code&gt; without a verified out-of-band path. Load a known-good file instead.&lt;/p&gt;

&lt;p&gt;To remove only NAT while debugging filters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nft delete table ip nat
&lt;span class="c"&gt;# or&lt;/span&gt;
nft flush chain ip nat prerouting
nft flush chain ip nat postrouting
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Common failure checklist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DNAT “works” in rules but service unreachable&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ip_forward=0&lt;/code&gt; or forward filter still drops post-DNAT path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works from Internet, fails on LAN via public name/IP&lt;/td&gt;
&lt;td&gt;missing hairpin SNAT (or no split DNS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbound dies after WAN DHCP renew&lt;/td&gt;
&lt;td&gt;hard-coded &lt;code&gt;snat to&lt;/code&gt; old address; switch to &lt;code&gt;masquerade&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Error: Could not process rule: No such file or directory&lt;/code&gt; style NAT add&lt;/td&gt;
&lt;td&gt;NAT statement in a non-&lt;code&gt;nat&lt;/code&gt; chain, or table/chain never created&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only first packet weirdness on old kernels&lt;/td&gt;
&lt;td&gt;pre-4.18 NAT reply path requires both prerouting and postrouting NAT chains registered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Port forward hits wrong backend after map edit&lt;/td&gt;
&lt;td&gt;stale named map element; list map with &lt;code&gt;nft list map ip nat published&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Established sessions die on policy reload&lt;/td&gt;
&lt;td&gt;expected for some flush/replace patterns; prefer additive map updates where possible&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not&lt;/strong&gt; FIB reverse-path anti-spoof (&lt;code&gt;fib&lt;/code&gt; expressions)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not&lt;/strong&gt; SYNPROXY / syncookie handshake offload&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not&lt;/strong&gt; dynamic set meters / temporary ban automation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not&lt;/strong&gt; flowtable established-flow fastpath&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not&lt;/strong&gt; tc HTB shaping, BBR, MPTCP, or RSS steering&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not&lt;/strong&gt; a substitute for application TLS and backend authentication&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;NAT changes where packets appear to come from and go to. It does not make an open service safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical defaults I use
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Masquerade&lt;/strong&gt; private ranges out the WAN interface; avoid brittle SNAT to DHCP addresses.&lt;/li&gt;
&lt;li&gt;Put &lt;strong&gt;all publishes&lt;/strong&gt; in a named map early — even if there is only one service today.&lt;/li&gt;
&lt;li&gt;Implement &lt;strong&gt;hairpin SNAT&lt;/strong&gt; whenever users will type the public hostname from inside.&lt;/li&gt;
&lt;li&gt;Match &lt;strong&gt;post-DNAT&lt;/strong&gt; addresses in forward allow rules.&lt;/li&gt;
&lt;li&gt;Commission with &lt;strong&gt;counters + conntrack&lt;/strong&gt;, not vibes.&lt;/li&gt;
&lt;li&gt;Keep NAT tables thin; keep policy in filter.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;nftables wiki — &lt;a href="https://wiki.nftables.org/wiki-nftables/index.php/Performing_Network_Address_Translation_(NAT)" rel="noopener noreferrer"&gt;Performing Network Address Translation (NAT)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;nftables wiki — &lt;a href="https://wiki.nftables.org/wiki-nftables/index.php/Multiple_NATs_using_nftables_maps" rel="noopener noreferrer"&gt;Multiple NATs using nftables maps&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;nftables wiki — &lt;a href="https://wiki.nftables.org/wiki-nftables/index.php/Simple_ruleset_for_a_home_router" rel="noopener noreferrer"&gt;Simple ruleset for a home router&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;nftables wiki — &lt;a href="https://wiki.nftables.org/wiki-nftables/index.php/Netfilter_hooks" rel="noopener noreferrer"&gt;Netfilter hooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;nftables wiki — &lt;a href="https://wiki.nftables.org/wiki-nftables/index.php/Configuring_chains" rel="noopener noreferrer"&gt;Configuring chains&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Debian man page — &lt;a href="https://manpages.debian.org/bookworm/nftables/nft.8.en.html" rel="noopener noreferrer"&gt;nft(8)&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Broken LAN access to your own public port forward is almost never “DNS being weird.” It is NAT missing the hairpin leg. Fix the bindings once, verify with conntrack, and keep publishes in maps so the next service does not mean another fragile one-liner.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Stop Thrashing Under Memory Pressure: Practical zram + systemd-oomd on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Fri, 28 Aug 2026 05:03:48 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-thrashing-under-memory-pressure-practical-zram-systemd-oomd-on-linux-1gpi</link>
      <guid>https://dev.to/lyraalishaikh/stop-thrashing-under-memory-pressure-practical-zram-systemd-oomd-on-linux-1gpi</guid>
      <description>&lt;h1&gt;
  
  
  Stop Thrashing Under Memory Pressure: Practical zram + systemd-oomd on Linux
&lt;/h1&gt;

&lt;p&gt;When a homelab box or small VPS runs out of free RAM, the failure mode is rarely a clean kill. More often the machine spends minutes thrashing: anonymous pages bounce to a slow disk swap, the page cache collapses, SSH becomes sticky, and &lt;em&gt;then&lt;/em&gt; the kernel OOM killer finally fires.&lt;/p&gt;

&lt;p&gt;Two complementary tools fix different halves of that story:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;zram&lt;/strong&gt; — a compressed block device in RAM, usually used as high-priority swap so reclaim stays in memory instead of on disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;systemd-oomd&lt;/strong&gt; — a userspace OOM daemon that watches cgroup v2 &lt;strong&gt;pressure stall information (PSI)&lt;/strong&gt; and kills a &lt;em&gt;descendant&lt;/em&gt; cgroup before the whole host livelocks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This post is a practical setup for both, with verification and a clean rollback. It is not a hibernation guide, not a zswap deep dive, and not another &lt;code&gt;MemoryMax=&lt;/code&gt; sandbox recipe.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you are actually fixing
&lt;/h2&gt;

&lt;p&gt;Swap is not “emergency RAM.” Chris Down’s well-known write-up makes the real point: swap exists so rarely used &lt;strong&gt;anonymous&lt;/strong&gt; pages can be reclaimed the same way clean file pages can. Without any swap, those anonymous pages stay pinned, reclaim is less egalitarian, and under pressure you thrash the page cache instead of a swap device.&lt;/p&gt;

&lt;p&gt;Disk-backed swap still works — but on a busy SSD or a slow VPS volume it can turn moderate pressure into multi-second stalls. &lt;strong&gt;zram&lt;/strong&gt; keeps that swap path in RAM with compression (kernel docs note a rough ~2:1 expectation; real ratios vary by workload). &lt;strong&gt;systemd-oomd&lt;/strong&gt; then uses the breathing room that swap creates so it can react on PSI instead of waiting for the global kernel OOM path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Memory pressure
      │
      ├─► reclaim cold anon pages → zram swap (compressed RAM)
      │
      └─► sustained PSI / swap exhaustion → systemd-oomd SIGKILL of a leaf cgroup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Linux with the &lt;code&gt;zram&lt;/code&gt; module (common on modern distros).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cgroup v2&lt;/strong&gt; unified hierarchy (systemd default on current Debian/Ubuntu/Fedora/Arch).&lt;/li&gt;
&lt;li&gt;Kernel &lt;strong&gt;PSI&lt;/strong&gt; support (mainline since 4.20): &lt;code&gt;/proc/pressure/memory&lt;/code&gt; must exist.&lt;/li&gt;
&lt;li&gt;Packages:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;zram-generator&lt;/code&gt; (Fedora ships it by default on many spins; Arch package &lt;code&gt;zram-generator&lt;/code&gt;; Debian/Ubuntu package name is typically &lt;code&gt;zram-tools&lt;/code&gt; &lt;em&gt;or&lt;/em&gt; install upstream/distro &lt;code&gt;systemd-zram-generator&lt;/code&gt; / &lt;code&gt;zram-generator&lt;/code&gt; depending on release — confirm with your package manager).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemd-oomd&lt;/code&gt; (package &lt;code&gt;systemd-oomd&lt;/code&gt; on Debian/Ubuntu; often already present on Fedora).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check the basics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# cgroup v2?&lt;/span&gt;
mount | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'cgroup2|type cgroup2'&lt;/span&gt;

&lt;span class="c"&gt;# PSI present?&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/pressure/memory

&lt;span class="c"&gt;# zram module available?&lt;/span&gt;
modinfo zram | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Part 1 — Compressed swap with zram-generator
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why the generator instead of a one-shot script
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/systemd/zram-generator" rel="noopener noreferrer"&gt;zram-generator&lt;/a&gt; is a systemd unit generator. You drop a small conf file; at boot it creates &lt;code&gt;systemd-zram-setup@zramN.service&lt;/code&gt;, formats the device (swap by default), and activates it. No fragile rc scripts, no hand-rolled &lt;code&gt;mkswap&lt;/code&gt; in &lt;code&gt;rc.local&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Config path precedence (lowest to highest override style matching systemd norms):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/usr/lib/systemd/zram-generator.conf&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/etc/systemd/zram-generator.conf&lt;/code&gt; ← administrator file&lt;/li&gt;
&lt;li&gt;drop-ins under &lt;code&gt;*.conf.d/&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Kernel cmdline &lt;code&gt;systemd.zram=0&lt;/code&gt; disables generator devices; &lt;code&gt;systemd.zram=1&lt;/code&gt; forces &lt;code&gt;zram0&lt;/code&gt; with defaults.&lt;/p&gt;

&lt;h3&gt;
  
  
  Minimal working config
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/systemd/zram-generator.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[zram0]
# Uncompressed capacity as a function of MemTotal (MiB variable: ram).
# Default if omitted: min(ram / 2, 4096)
zram-size = min(ram / 2, 8192)

# Prefer a fast modern compressor when the kernel offers it.
compression-algorithm = zstd

# Higher than typical disk swap so zram is chosen first.
swap-priority = 100

# Default options already include discard; keep it explicit.
options = discard
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes from the man page / upstream docs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Knob&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zram-size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Uncompressed&lt;/strong&gt; max data the device can hold, expression over &lt;code&gt;ram&lt;/code&gt; (MiB). Default &lt;code&gt;min(ram/2, 4096)&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zram-resident-limit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cap on &lt;strong&gt;compressed&lt;/strong&gt; resident RAM (&lt;code&gt;mem_limit&lt;/code&gt;); &lt;code&gt;0&lt;/code&gt; = unlimited.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;host-memory-limit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Skip creating the device if &lt;code&gt;MemTotal&lt;/code&gt; is above this many MiB.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;swap-priority&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Default &lt;code&gt;100&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;compression-algorithm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whitespace list; extras become recompress algorithms when the kernel supports multi-comp.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;writeback-device&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Optional backing block device for incompressible pages.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;After writing the conf:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start systemd-zram-setup@zram0.service
systemctl status systemd-zram-setup@zram0.service &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Verify zram swap
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;zramctl
swapon &lt;span class="nt"&gt;--show&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/swaps

&lt;span class="c"&gt;# Live compression stats (fields documented in the kernel zram guide)&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/block/zram0/mm_stat
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/block/zram0/comp_algorithm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;zramctl&lt;/code&gt; columns to care about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DISKSIZE&lt;/strong&gt; — uncompressed capacity you configured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DATA&lt;/strong&gt; — uncompressed bytes currently stored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;COMPR&lt;/strong&gt; — compressed payload size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TOTAL&lt;/strong&gt; — RAM actually used including allocator overhead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Disable zswap when zram is the primary swap
&lt;/h3&gt;

&lt;p&gt;If the kernel’s &lt;strong&gt;zswap&lt;/strong&gt; pool is enabled, it sits in front of swap devices as a compressed cache. On many stock kernels it is on by default. Arch’s zram page is explicit: leaving zswap enabled can intercept pages before they reach zram and waste the setup.&lt;/p&gt;

&lt;p&gt;Runtime check and disable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Current state (Y/N or 1/0 depending on kernel)&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/module/zswap/parameters/enabled 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'zswap module params not present'&lt;/span&gt;

&lt;span class="c"&gt;# Temporary disable&lt;/span&gt;
&lt;span class="nb"&gt;echo &lt;/span&gt;0 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/module/zswap/parameters/enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persist with a kernel parameter (bootloader-specific):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;zswap.enabled=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or keep zswap and &lt;strong&gt;do not&lt;/strong&gt; use zram — pick one primary strategy. Mixing both without measuring usually confuses operators more than it helps.&lt;/p&gt;

&lt;h3&gt;
  
  
  VM sysctls that make sense for in-memory swap
&lt;/h3&gt;

&lt;p&gt;Disk swap wants conservative swappiness. Compressed RAM swap is different: reclaiming into zram is often cheaper than dropping hot file cache. The kernel’s own &lt;code&gt;vm.swappiness&lt;/code&gt; documentation notes that for in-memory swap, values &lt;strong&gt;beyond 100&lt;/strong&gt; can be reasonable.&lt;/p&gt;

&lt;p&gt;A widely copied starting point (Pop!_OS defaults / community zram benchmarks summarized on the ArchWiki):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/99-vm-zram-parameters.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
# Bias reclaim toward anon pages when swap is fast (zram).
vm.swappiness = 180

# Disable watermark boosting (can cause sudden reclaim spikes).
vm.watermark_boost_factor = 0

# Wake kswapd earlier; scale is in fractions of 10000.
vm.watermark_scale_factor = 125

# Disable swap readahead (page-cluster is log2 pages). zram has no disk locality.
vm.page-cluster = 0
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Treat these as a &lt;strong&gt;starting point&lt;/strong&gt;, not religion. Measure with your workload.&lt;/p&gt;

&lt;h3&gt;
  
  
  Optional: resident cap so zram cannot eat the host
&lt;/h3&gt;

&lt;p&gt;If you want a hard ceiling on compressed memory used by zram:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[zram0]&lt;/span&gt;
&lt;span class="py"&gt;zram-size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;ram / 2&lt;/span&gt;
&lt;span class="py"&gt;zram-resident-limit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;ram / 4&lt;/span&gt;
&lt;span class="py"&gt;compression-algorithm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;zstd&lt;/span&gt;
&lt;span class="py"&gt;swap-priority&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That maps to the kernel &lt;code&gt;mem_limit&lt;/code&gt; sysfs knob described in the zram admin guide.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hibernation warning
&lt;/h3&gt;

&lt;p&gt;Hibernation to a &lt;strong&gt;zram&lt;/strong&gt; swap device is not supported. logind will refuse it. If you hibernate, you still need a disk-backed swap large enough for the image — separate from this guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2 — Early kills with systemd-oomd
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What oomd actually does
&lt;/h3&gt;

&lt;p&gt;From &lt;code&gt;systemd-oomd.service(8)&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Runs in userspace on &lt;strong&gt;cgroup v2&lt;/strong&gt; + &lt;strong&gt;PSI&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Units opt in with &lt;code&gt;ManagedOOMSwap=kill&lt;/code&gt; and/or &lt;code&gt;ManagedOOMMemoryPressure=kill&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;When limits trip, oomd sends &lt;strong&gt;SIGKILL&lt;/strong&gt; to processes in a &lt;strong&gt;descendant&lt;/strong&gt; cgroup (not usually the monitored unit itself).&lt;/li&gt;
&lt;li&gt;Only leaf cgroups, or cgroups with &lt;code&gt;memory.oom.group=1&lt;/code&gt;, are candidates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swap is strongly recommended.&lt;/strong&gt; Without swap, pressure spikes are abrupt and oomd has less time to act; swap-based actions are ignored if there is no swap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Global knobs live in &lt;code&gt;oomd.conf(5)&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SwapUsedLimit=&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;90%&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When &lt;strong&gt;both&lt;/strong&gt; memory and swap usage fractions exceed this, act on high-swap descendants (&amp;gt;5% of total swap).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DefaultMemoryPressureLimit=&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;60%&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fraction of time (10s window) tasks were delayed by memory reclaim.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DefaultMemoryPressureDurationSec=&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;30s&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How long pressure must stay high before action.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Install and enable
&lt;/h3&gt;

&lt;p&gt;Debian/Ubuntu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;systemd-oomd
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; systemd-oomd.service
systemctl status systemd-oomd.service &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fedora often already enables it; confirm with the same &lt;code&gt;systemctl&lt;/code&gt; commands.&lt;/p&gt;

&lt;p&gt;Memory accounting should be on (modern systemd defaults are usually fine):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# system-wide default&lt;/span&gt;
systemctl show &lt;span class="nt"&gt;-p&lt;/span&gt; DefaultMemoryAccounting
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it is false on an older image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/systemd/system.conf.d
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/systemd/system.conf.d/10-memory-accounting.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[Manager]
DefaultMemoryAccounting=yes
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reexec
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Recommended slice policy
&lt;/h3&gt;

&lt;p&gt;systemd’s own usage notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Put &lt;strong&gt;&lt;code&gt;ManagedOOMSwap=kill&lt;/code&gt;&lt;/strong&gt; on a high ancestor (often &lt;code&gt;-.slice&lt;/code&gt; via a drop-in) so swap exhaustion can pick the worst descendant.&lt;/li&gt;
&lt;li&gt;Put &lt;strong&gt;&lt;code&gt;ManagedOOMMemoryPressure=kill&lt;/code&gt;&lt;/strong&gt; on slices below the root — e.g. &lt;code&gt;user.slice&lt;/code&gt; and/or &lt;code&gt;system.slice&lt;/code&gt; — with tighter limits for interactive user sessions.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Swap watchdog at the root&lt;/span&gt;
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/systemd/system/-.slice.d
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/systemd/system/-.slice.d/20-oomd-swap.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[Slice]
ManagedOOMSwap=kill
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# Pressure watchdog for user sessions (stricter limit)&lt;/span&gt;
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/systemd/system/user.slice.d
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/systemd/system/user.slice.d/20-oomd-pressure.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[Slice]
ManagedOOMMemoryPressure=kill
ManagedOOMMemoryPressureLimit=40%
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# Pressure watchdog for system services (more tolerant)&lt;/span&gt;
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/systemd/system/system.slice.d
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/systemd/system/system.slice.d/20-oomd-pressure.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[Slice]
ManagedOOMMemoryPressure=kill
ManagedOOMMemoryPressureLimit=60%
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Optional global tuning drop-in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/systemd/oomd.conf.d
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/systemd/oomd.conf.d/20-local.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[OOM]
# Act a bit earlier on small boxes; raise if you see flapping kills.
SwapUsedLimit=80%
DefaultMemoryPressureLimit=50%
DefaultMemoryPressureDurationSec=20s
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload systemd-oomd.service 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart systemd-oomd.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Protect critical units from being preferred victims:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# drop-in on e.g. ssh.service
&lt;/span&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;ManagedOOMPreference&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;avoid&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;omit&lt;/code&gt; is stronger than &lt;code&gt;avoid&lt;/code&gt; (requires xattr support; see &lt;code&gt;systemd.resource-control(5)&lt;/code&gt; ownership rules).&lt;/p&gt;

&lt;h3&gt;
  
  
  Inspect oomd state
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oomctl dump
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; systemd-oomd.service &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;--no-pager&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dry-run mode exists on newer systemd builds (&lt;code&gt;systemd-oomd --dry-run&lt;/code&gt; / unit drop-in) if you want log lines without kills while testing — check your installed man page for &lt;code&gt;--dry-run&lt;/code&gt; (added in systemd 253).&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3 — Read pressure like an operator
&lt;/h2&gt;

&lt;p&gt;PSI exports stall ratios under &lt;code&gt;/proc/pressure/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/pressure/memory
&lt;span class="c"&gt;# some avg10=... avg60=... avg300=... total=...&lt;/span&gt;
&lt;span class="c"&gt;# full avg10=... avg60=... avg300=... total=...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;some&lt;/strong&gt; — at least some tasks stalled on memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;full&lt;/strong&gt; — non-idle tasks essentially all stalled (thrashing territory).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Per-cgroup files live at &lt;code&gt;memory.pressure&lt;/code&gt; inside the cgroupfs. systemd-oomd is basically an automated consumer of those signals with a kill policy attached.&lt;/p&gt;

&lt;p&gt;Quick health snapshot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'=== mem ==='&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; free &lt;span class="nt"&gt;-h&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'=== swaps ==='&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; swapon &lt;span class="nt"&gt;--show&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'=== zram ==='&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; zramctl
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'=== psi ==='&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /proc/pressure/memory
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'=== oomd ==='&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; oomctl dump | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Safe soak test (use a throwaway cgroup)
&lt;/h2&gt;

&lt;p&gt;Do &lt;strong&gt;not&lt;/strong&gt; OOM-test a production host. On a lab machine or VM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Optional: watch PSI in another terminal&lt;/span&gt;
watch &lt;span class="nt"&gt;-n1&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /proc/pressure/memory

&lt;span class="c"&gt;# Allocate until pressure rises (install stress-ng if needed)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemd-run &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;MemoryMax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;512M &lt;span class="nt"&gt;--unit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;memtest-scope &lt;span class="se"&gt;\&lt;/span&gt;
  stress-ng &lt;span class="nt"&gt;--vm&lt;/span&gt; 2 &lt;span class="nt"&gt;--vm-bytes&lt;/span&gt; 95% &lt;span class="nt"&gt;--timeout&lt;/span&gt; 60s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch whether:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;zramctl&lt;/code&gt; &lt;strong&gt;DATA/COMPR&lt;/strong&gt; climb instead of disk swap thrashing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;oomctl dump&lt;/code&gt; shows monitored cgroups and, if limits trip, journal lines from &lt;code&gt;systemd-oomd&lt;/code&gt; about kills under &lt;code&gt;memtest-scope&lt;/code&gt; / user slices — not a random freeze of the whole machine.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Rollback
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# oomd policy&lt;/span&gt;
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/systemd/system/-.slice.d/20-oomd-swap.conf
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/systemd/system/user.slice.d/20-oomd-pressure.conf
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/systemd/system/system.slice.d/20-oomd-pressure.conf
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/systemd/oomd.conf.d/20-local.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl disable &lt;span class="nt"&gt;--now&lt;/span&gt; systemd-oomd.service   &lt;span class="c"&gt;# only if you want it fully off&lt;/span&gt;

&lt;span class="c"&gt;# zram&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl stop systemd-zram-setup@zram0.service
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/systemd/zram-generator.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="c"&gt;# optionally remove /etc/sysctl.d/99-vm-zram-parameters.conf and re-run sysctl --system&lt;/span&gt;

&lt;span class="c"&gt;# re-enable zswap if you disabled it and still want it&lt;/span&gt;
&lt;span class="nb"&gt;echo &lt;/span&gt;1 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/module/zswap/parameters/enabled
&lt;span class="c"&gt;# and remove zswap.enabled=0 from kernel cmdline if added&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Design boundaries (so this stays the right tool)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Better tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Per-service hard caps for a known hungry app&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;MemoryHigh=&lt;/code&gt; / &lt;code&gt;MemoryMax=&lt;/code&gt; on that unit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long-lived self-hosted model RAM isolation&lt;/td&gt;
&lt;td&gt;dedicated slice + memory controls (not oomd alone)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk-backed swap cache in front of a real swap partition&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;zswap&lt;/strong&gt;, not zram&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hibernate&lt;/td&gt;
&lt;td&gt;disk swap sized ≥ RAM; not zram&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kernel panic / whole-box hang recovery&lt;/td&gt;
&lt;td&gt;watchdog / kdump — different stack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;“Just disable swap forever”&lt;/td&gt;
&lt;td&gt;usually makes pressure &lt;em&gt;worse&lt;/em&gt; for anon reclaim&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Practical defaults I actually use
&lt;/h2&gt;

&lt;p&gt;On a 8–32 GiB homelab host:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;zram-size = min(ram / 2, 8192)&lt;/code&gt; with &lt;code&gt;zstd&lt;/code&gt;, priority &lt;code&gt;100&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;zswap.enabled=0&lt;/code&gt; when zram is primary.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vm.swappiness=180&lt;/code&gt;, &lt;code&gt;vm.page-cluster=0&lt;/code&gt; as a baseline.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemd-oomd&lt;/code&gt; enabled with swap kill on &lt;code&gt;-.slice&lt;/code&gt; and pressure kill on &lt;code&gt;user.slice&lt;/code&gt; / &lt;code&gt;system.slice&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ManagedOOMPreference=avoid&lt;/code&gt; on &lt;code&gt;sshd&lt;/code&gt; and the backup runner.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On a 1–2 GiB VPS, shrink &lt;code&gt;zram-size&lt;/code&gt; with a resident limit (&lt;code&gt;zram-resident-limit = ram / 3&lt;/code&gt;) so compression metadata cannot surprise you, and lower &lt;code&gt;DefaultMemoryPressureDurationSec&lt;/code&gt; so oomd fails closed faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Linux kernel — &lt;a href="https://docs.kernel.org/admin-guide/blockdev/zram.html" rel="noopener noreferrer"&gt;Compressed RAM-based block devices (zram)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;systemd project — &lt;a href="https://github.com/systemd/zram-generator/blob/main/man/zram-generator.conf.md" rel="noopener noreferrer"&gt;zram-generator.conf(5)&lt;/a&gt; / &lt;a href="https://github.com/systemd/zram-generator" rel="noopener noreferrer"&gt;github.com/systemd/zram-generator&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Debian manpages — &lt;a href="https://manpages.debian.org/trixie/systemd-oomd/systemd-oomd.service.8.en.html" rel="noopener noreferrer"&gt;systemd-oomd.service(8)&lt;/a&gt;, &lt;a href="https://manpages.debian.org/trixie/systemd-oomd/oomd.conf.5.en.html" rel="noopener noreferrer"&gt;oomd.conf(5)&lt;/a&gt;, &lt;a href="https://manpages.debian.org/trixie/systemd-oomd/oomctl.1.en.html" rel="noopener noreferrer"&gt;oomctl(1)&lt;/a&gt;, &lt;a href="https://manpages.debian.org/trixie/systemd/systemd.resource-control.5.en.html" rel="noopener noreferrer"&gt;systemd.resource-control(5)&lt;/a&gt; (&lt;code&gt;ManagedOOM*&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Kernel — &lt;a href="https://docs.kernel.org/accounting/psi.html" rel="noopener noreferrer"&gt;Pressure Stall Information (PSI)&lt;/a&gt;, &lt;a href="https://docs.kernel.org/admin-guide/sysctl/vm.html" rel="noopener noreferrer"&gt;Documentation for /proc/sys/vm/&lt;/a&gt; (&lt;code&gt;swappiness&lt;/code&gt;, &lt;code&gt;page-cluster&lt;/code&gt;, watermarks)&lt;/li&gt;
&lt;li&gt;ArchWiki — &lt;a href="https://wiki.archlinux.org/title/Zram" rel="noopener noreferrer"&gt;zram&lt;/a&gt;, &lt;a href="https://wiki.archlinux.org/title/Zswap" rel="noopener noreferrer"&gt;zswap&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Chris Down — &lt;a href="https://chrisdown.name/2018/01/02/in-defence-of-swap.html" rel="noopener noreferrer"&gt;In defence of swap: common misconceptions&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Pop!_OS default-settings discussion of zram VM tunables (via ArchWiki citations): &lt;a href="https://github.com/pop-os/default-settings/pull/163" rel="noopener noreferrer"&gt;pop-os/default-settings#163&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; give the kernel a &lt;em&gt;fast&lt;/em&gt; place to put cold anonymous pages (zram), tune reclaim so it actually uses that path, and let systemd-oomd cut the worst cgroup loose when PSI says the box is stalling — before disk thrash turns a recoverable spike into a hard reset.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>systemd</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stop Confusing Snapshots with Backups: Practical btrfs send/receive on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Sun, 23 Aug 2026 05:01:54 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-confusing-snapshots-with-backups-practical-btrfs-sendreceive-on-linux-43io</link>
      <guid>https://dev.to/lyraalishaikh/stop-confusing-snapshots-with-backups-practical-btrfs-sendreceive-on-linux-43io</guid>
      <description>&lt;p&gt;Local Btrfs snapshots feel like backups until the disk dies.&lt;/p&gt;

&lt;p&gt;They are fast, space-efficient, and perfect for “undo that upgrade.” They are &lt;strong&gt;not&lt;/strong&gt; a second copy of your data. Official Btrfs documentation is blunt about this: a snapshot and its origin share the same underlying blocks, so media damage or a wiped filesystem can take both with them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;btrfs send&lt;/code&gt; and &lt;code&gt;btrfs receive&lt;/code&gt; close that gap. They turn a read-only snapshot into a stream you can pipe to another disk, another host, or an archive file—and after the first full transfer, incremental streams only carry what changed.&lt;/p&gt;

&lt;p&gt;This post is a practical operator guide: layout, first full send, daily incrementals over SSH, parent tracking, retention, verification, and the footguns that break incremental chains.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you are building
&lt;/h2&gt;

&lt;p&gt;On the &lt;strong&gt;source&lt;/strong&gt; host:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Keep data in a dedicated subvolume (not nested junk under &lt;code&gt;/&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Take a &lt;strong&gt;read-only&lt;/strong&gt; snapshot on a schedule.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;btrfs send&lt;/code&gt; that snapshot (full once, then incremental with &lt;code&gt;-p&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Keep enough old read-only snapshots to act as parents.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On the &lt;strong&gt;destination&lt;/strong&gt; host (another disk or machine):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Receive into a Btrfs filesystem mounted at a stable path.&lt;/li&gt;
&lt;li&gt;Keep received snapshots &lt;strong&gt;read-only&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Prune old generations only after both sides agree on the new parent.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Result: point-in-time copies that survive source-disk failure, with bandwidth that looks more like &lt;code&gt;rsync&lt;/code&gt; of deltas than “copy the world every night.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Source and destination are Btrfs (receive cannot reconstruct a Btrfs subvolume on ext4/XFS).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;btrfs-progs&lt;/code&gt; installed on both sides.&lt;/li&gt;
&lt;li&gt;Root (or equivalent capabilities) for snapshot/send/receive.&lt;/li&gt;
&lt;li&gt;For remote backups: SSH access that can run &lt;code&gt;btrfs receive&lt;/code&gt; on the destination.&lt;/li&gt;
&lt;li&gt;Linux 6.0+ and btrfs-progs 6.0+ on both ends if you want send protocol v2 / &lt;code&gt;--compressed-data&lt;/code&gt; (optional; protocol v1 still works everywhere modern enough for normal ops).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example layout used below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Source:
  /mnt/data              # Btrfs mount (top-level or dedicated data FS)
  /mnt/data/svc          # writable subvolume with real data
  /mnt/data/.snaps       # directory for read-only snapshots

Destination:
  /mnt/backup            # separate Btrfs filesystem
  /mnt/backup/svc        # received snapshot tree
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the source subvolume if you do not already have one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On source&lt;/span&gt;
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/data/.snaps
&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs subvolume create /mnt/data/svc
&lt;span class="c"&gt;# put application data under /mnt/data/svc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the backup host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On destination&lt;/span&gt;
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/backup/svc
&lt;span class="c"&gt;# ensure /mnt/backup is the Btrfs top-level (or a writable parent subvolume)&lt;/span&gt;
findmnt &lt;span class="nt"&gt;-no&lt;/span&gt; FSTYPE,TARGET /mnt/backup
&lt;span class="c"&gt;# expect: btrfs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Rule zero: send only from read-only snapshots
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;btrfs-send(8)&lt;/code&gt; requires every snapshot involved in a send to be &lt;strong&gt;read-only&lt;/strong&gt;. A read-only &lt;em&gt;mount&lt;/em&gt; of a writable subvolume is not enough—another mount could still write.&lt;/p&gt;

&lt;p&gt;Always snapshot with &lt;code&gt;-r&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;STAMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y%m%dT%H%M%SZ&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;SRC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/mnt/data/svc
&lt;span class="nv"&gt;SNAP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/mnt/data/.snaps/svc-&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;STAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs subvolume snapshot &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SRC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SNAP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs property get &lt;span class="nt"&gt;-ts&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SNAP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ro
&lt;span class="c"&gt;# ro=true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That frozen snapshot is your consistent backup source. Applications that need true freeze consistency (busy databases) still need their own flush/quiesce story before the snapshot; the filesystem snapshot alone is crash-consistent, not app-transaction-aware.&lt;/p&gt;

&lt;h2&gt;
  
  
  First backup: full send
&lt;/h2&gt;

&lt;p&gt;A full stream contains the entire snapshot. Pipe it locally or over SSH.&lt;/p&gt;

&lt;h3&gt;
  
  
  Same machine, second disk
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs send &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SNAP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs receive /mnt/backup/svc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Remote host over SSH
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs send &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SNAP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | ssh backup-host &lt;span class="s1"&gt;'sudo btrfs receive /mnt/backup/svc'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After success, destination has a new read-only subvolume named like the snapshot basename (&lt;code&gt;svc-20260823T050000Z&lt;/code&gt;). Confirm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Destination&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs subvolume list &lt;span class="nt"&gt;-o&lt;/span&gt; /mnt/backup/svc
&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs subvolume show /mnt/backup/svc/svc-20260823T050000Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;show&lt;/code&gt; output, look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Flags: readonly&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;a non-empty &lt;code&gt;Received UUID&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Parent UUID&lt;/code&gt; / UUID fields that identify the received generation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Received UUID is how Btrfs ties the destination copy back to the source snapshot identity for incremental use. Do &lt;strong&gt;not&lt;/strong&gt; casually flip the destination to read-write; that path resets &lt;code&gt;received_uuid&lt;/code&gt; (with force) and can break later incrementals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incremental backups: &lt;code&gt;-p&lt;/code&gt; parent
&lt;/h2&gt;

&lt;p&gt;Once both sides have the same parent snapshot content, send only the difference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Source: create today's RO snapshot&lt;/span&gt;
&lt;span class="nv"&gt;STAMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y%m%dT%H%M%SZ&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;NEW&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/mnt/data/.snaps/svc-&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;STAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs subvolume snapshot &lt;span class="nt"&gt;-r&lt;/span&gt; /mnt/data/svc &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# PARENT must exist on BOTH sides, still read-only, still matching&lt;/span&gt;
&lt;span class="nv"&gt;PARENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/mnt/data/.snaps/svc-20260823T050000Z

&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs send &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PARENT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | ssh backup-host &lt;span class="s1"&gt;'sudo btrfs receive /mnt/backup/svc'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What &lt;code&gt;-p&lt;/code&gt; does (from the man page): generate an incremental stream from &lt;em&gt;parent&lt;/em&gt; to &lt;em&gt;subvol&lt;/em&gt;. Both must be read-only. The parent on the receiver must still be the unmodified received snapshot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Clone sources (&lt;code&gt;-c&lt;/code&gt;) when useful
&lt;/h3&gt;

&lt;p&gt;If you keep multiple related snapshots and want the sender to reuse extents from more than one common ancestor, add clone sources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs send &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PARENT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; /mnt/data/.snaps/svc-other-common &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | ssh backup-host &lt;span class="s1"&gt;'sudo btrfs receive /mnt/backup/svc'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules from &lt;code&gt;btrfs-send(8)&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every &lt;code&gt;-c&lt;/code&gt; snapshot must be &lt;strong&gt;exactly&lt;/strong&gt; the same on sender and receiver.&lt;/li&gt;
&lt;li&gt;You can omit &lt;code&gt;-p&lt;/code&gt; when &lt;code&gt;-c&lt;/code&gt; is given; send will pick a suitable parent among clone sources.&lt;/li&gt;
&lt;li&gt;Do not invent clone sources that only exist on one side.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most homelab/service backups, a single linear parent chain (&lt;code&gt;-p&lt;/code&gt; only) is simpler and safer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small, honest backup script
&lt;/h2&gt;

&lt;p&gt;Save as &lt;code&gt;/usr/local/sbin/btrfs-send-svc.sh&lt;/code&gt; on the source. Track the last successfully sent snapshot name in a state file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;SRC_SUBVOL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SRC_SUBVOL&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;/mnt/data/svc&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;SNAP_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SNAP_DIR&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;/mnt/data/.snaps&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;PREFIX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PREFIX&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;svc&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;STATE_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;STATE_FILE&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;/var/lib/btrfs-send/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PREFIX&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="p"&gt;.last&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;DEST_SSH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DEST_SSH&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;backup&lt;/span&gt;&lt;span class="p"&gt;-host&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;DEST_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DEST_PATH&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;/mnt/backup/svc&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;KEEP_LOCAL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;KEEP_LOCAL&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;14&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;umask &lt;/span&gt;077
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SNAP_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;stamp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y%m%dT%H%M%SZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;new_snap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SNAP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PREFIX&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;stamp&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

btrfs subvolume snapshot &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SRC_SUBVOL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$new_snap&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$parent&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Parent missing on source: &lt;/span&gt;&lt;span class="nv"&gt;$parent&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
  &lt;span class="k"&gt;fi&lt;/span&gt;
  &lt;span class="c"&gt;# Incremental&lt;/span&gt;
  btrfs send &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$parent&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$new_snap&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | ssh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST_SSH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"btrfs receive &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DEST_PATH&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="c"&gt;# First full&lt;/span&gt;
  btrfs send &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$new_snap&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | ssh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST_SSH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"btrfs receive &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DEST_PATH&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$new_snap&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Local retention: keep newest KEEP_LOCAL RO snaps for this prefix&lt;/span&gt;
&lt;span class="nb"&gt;mapfile&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; snaps &amp;lt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-1d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SNAP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PREFIX&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-"&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;sort&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="k"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;snaps&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; KEEP_LOCAL&lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;drop&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((${#&lt;/span&gt;&lt;span class="nv"&gt;snaps&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; KEEP_LOCAL&lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;old &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;snaps&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;:0:drop&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="c"&gt;# Never delete the recorded parent&lt;/span&gt;
    &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$old&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue
    &lt;/span&gt;btrfs subvolume delete &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$old&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
  &lt;/span&gt;&lt;span class="k"&gt;done
fi

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"OK sent &lt;/span&gt;&lt;span class="nv"&gt;$new_snap&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make it executable and dry-run the first full once by hand before automation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;750 /usr/local/sbin/btrfs-send-svc.sh
&lt;span class="nb"&gt;sudo&lt;/span&gt; /usr/local/sbin/btrfs-send-svc.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Destination retention is separate: prune only snapshots older than your RPO window &lt;strong&gt;and&lt;/strong&gt; never delete the parent the next incremental still needs. A simple approach is “keep N newest received names matching &lt;code&gt;svc-*&lt;/code&gt;” after a successful send, run from the source over SSH, or as a second timer on the backup host.&lt;/p&gt;

&lt;h2&gt;
  
  
  systemd timer (no cron required)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/system/btrfs-send-svc.service&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Incremental btrfs send of /mnt/data/svc&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/sbin/btrfs-send-svc.sh&lt;/span&gt;
&lt;span class="py"&gt;Nice&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;
&lt;span class="py"&gt;IOSchedulingClass&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;best-effort&lt;/span&gt;
&lt;span class="py"&gt;IOSchedulingPriority&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;6&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/systemd/system/btrfs-send-svc.timer&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Daily btrfs send of /mnt/data/svc&lt;/span&gt;

&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;*-*-* 03:30:00&lt;/span&gt;
&lt;span class="py"&gt;Persistent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;RandomizedDelaySec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10m&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; btrfs-send-svc.timer
systemctl list-timers btrfs-send-svc.timer
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; btrfs-send-svc.service &lt;span class="nt"&gt;-n&lt;/span&gt; 50 &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verification checklist
&lt;/h2&gt;

&lt;p&gt;After a run, prove the chain instead of assuming it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs subvolume show /mnt/data/.snaps/svc-YYYYMMDDTHHMMSSZ
&lt;span class="c"&gt;# Flags: readonly&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /var/lib/btrfs-send/svc.last
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Destination&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs subvolume list &lt;span class="nt"&gt;-o&lt;/span&gt; /mnt/backup/svc
&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs subvolume show /mnt/backup/svc/svc-YYYYMMDDTHHMMSSZ
&lt;span class="c"&gt;# Flags: readonly&lt;/span&gt;
&lt;span class="c"&gt;# Received UUID: &amp;lt;non-empty&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Stream inspection without writing&lt;/strong&gt; (useful when debugging a saved stream file):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;btrfs receive &lt;span class="nt"&gt;--dump&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /path/to/stream.btrfs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Optional metadata-only send&lt;/strong&gt; to inspect differences without bulk data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs send &lt;span class="nt"&gt;--no-data&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PARENT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | btrfs receive &lt;span class="nt"&gt;--dump&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--no-data&lt;/code&gt; is for inspection, not backup restore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restore drill&lt;/strong&gt; (do this before you need it):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On destination, snapshot the received RO snap to a writable restore target&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs subvolume snapshot &lt;span class="se"&gt;\&lt;/span&gt;
  /mnt/backup/svc/svc-YYYYMMDDTHHMMSSZ &lt;span class="se"&gt;\&lt;/span&gt;
  /mnt/restore/svc-restored
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That writable clone is for recovery testing. Leave the original received snapshot read-only so future incrementals still have a clean parent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Footguns that break incremental chains
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Nested subvolumes are not recursive
&lt;/h3&gt;

&lt;p&gt;Snapshotting is not recursive. Nested child subvolumes appear as empty stubs (inode 2) inside a parent snapshot and are &lt;strong&gt;not&lt;/strong&gt; sent. If &lt;code&gt;/var/lib/postgres&lt;/code&gt; is its own subvolume under a parent you snapshot, Postgres data will not ride along.&lt;/p&gt;

&lt;p&gt;Fix: send each data subvolume on its own schedule, or flatten the layout so each backup unit is one subvolume.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Making received snapshots read-write
&lt;/h3&gt;

&lt;p&gt;Incremental send/receive assumes matching, unmodified snapshots on both sides. Flipping a received subvolume to RW clears &lt;code&gt;received_uuid&lt;/code&gt; (with force on modern btrfs-progs) and can poison the next &lt;code&gt;-p&lt;/code&gt; / &lt;code&gt;-c&lt;/code&gt; use. Prefer cloning a &lt;em&gt;new&lt;/em&gt; writable snapshot for restores.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Deleting the only common parent
&lt;/h3&gt;

&lt;p&gt;If you prune the parent on either side before the next incremental, send fails or you are forced into another full. Order of operations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Send new snapshot successfully.&lt;/li&gt;
&lt;li&gt;Update “last parent” state.&lt;/li&gt;
&lt;li&gt;Then prune older generations, keeping the new parent on &lt;strong&gt;both&lt;/strong&gt; sides.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  4. Default subvolume / mount path surprises on receive
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;btrfs-receive(8)&lt;/code&gt; fails when the receiving subvolume already exists, when a previously received subvolume was changed, or when the destination filesystem is not mounted at the top-level in a way receive can resolve. If you are in a weird mount namespace or chroot, pass &lt;code&gt;-m /path/to/btrfs-root&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Untrusted streams
&lt;/h3&gt;

&lt;p&gt;The receive man page warns that crafted streams can create dangerous reflinks on the destination filesystem. Only receive streams from hosts and paths you trust, and protect streams in transit (SSH is the easy default).&lt;/p&gt;

&lt;h3&gt;
  
  
  6. “Snapshot finished instantly, so backup is done”
&lt;/h3&gt;

&lt;p&gt;Snapshot creation is cheap metadata. The &lt;strong&gt;send&lt;/strong&gt; is the real work. Watch the oneshot unit, not just the snapshot command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optional: protocol v2 and compressed send
&lt;/h2&gt;

&lt;p&gt;On Linux 6.0+ with recent btrfs-progs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs send &lt;span class="nt"&gt;--proto&lt;/span&gt; 2 &lt;span class="nt"&gt;--compressed-data&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PARENT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | ssh backup-host &lt;span class="s1"&gt;'btrfs receive /mnt/backup/svc'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--proto 2&lt;/code&gt; encodes file data more efficiently.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--compressed-data&lt;/code&gt; can avoid decompress/recompress when the receiver supports encoded write; otherwise it falls back.&lt;/li&gt;
&lt;li&gt;Passing &lt;code&gt;--proto 0&lt;/code&gt; asks for the highest version the running kernel supports.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stick to defaults until both ends are known-good on 6.0+, then bench on a large compressible dataset.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this differs from nearby tools
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tooling&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local RO snapshots / Snapper&lt;/td&gt;
&lt;td&gt;Fast local rollback, not off-disk survival&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;btrfs scrub&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Checksum verification on one filesystem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;rsync&lt;/code&gt; / &lt;code&gt;rclone&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;File-level sync; great, but no native Btrfs extent clone semantics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;btrfs send/receive&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Subvolume-level full + incremental replication to another Btrfs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rest dumps (pg_dump, etc.)&lt;/td&gt;
&lt;td&gt;Application-consistent logical backups—still complementary&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use send/receive for filesystem-level disaster copies. Keep app-aware dumps where transactional restore matters more than block identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal lab (two loop files)
&lt;/h2&gt;

&lt;p&gt;If you want a throwaway practice FS without touching production disks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fallocate &lt;span class="nt"&gt;-l&lt;/span&gt; 2G /var/tmp/btrfs-src.img
fallocate &lt;span class="nt"&gt;-l&lt;/span&gt; 2G /var/tmp/btrfs-dst.img
&lt;span class="nb"&gt;sudo &lt;/span&gt;losetup &lt;span class="nt"&gt;-fP&lt;/span&gt; /var/tmp/btrfs-src.img
&lt;span class="nb"&gt;sudo &lt;/span&gt;losetup &lt;span class="nt"&gt;-fP&lt;/span&gt; /var/tmp/btrfs-dst.img
&lt;span class="c"&gt;# note loop devices from losetup -a&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mkfs.btrfs &lt;span class="nt"&gt;-f&lt;/span&gt; /dev/loopX
&lt;span class="nb"&gt;sudo &lt;/span&gt;mkfs.btrfs &lt;span class="nt"&gt;-f&lt;/span&gt; /dev/loopY
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/lab-src /mnt/lab-dst
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount /dev/loopX /mnt/lab-src
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount /dev/loopY /mnt/lab-dst
&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs subvolume create /mnt/lab-src/data
&lt;span class="nb"&gt;echo &lt;/span&gt;hello | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /mnt/lab-src/data/file
&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs subvolume snapshot &lt;span class="nt"&gt;-r&lt;/span&gt; /mnt/lab-src/data /mnt/lab-src/data-ro1
&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs send /mnt/lab-src/data-ro1 | &lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs receive /mnt/lab-dst
&lt;span class="nb"&gt;echo &lt;/span&gt;world | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /mnt/lab-src/data/file
&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs subvolume snapshot &lt;span class="nt"&gt;-r&lt;/span&gt; /mnt/lab-src/data /mnt/lab-src/data-ro2
&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs send &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/lab-src/data-ro1 /mnt/lab-src/data-ro2 &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs receive /mnt/lab-dst
&lt;span class="nb"&gt;sudo &lt;/span&gt;btrfs subvolume list /mnt/lab-dst
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean up loops/mounts when finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Btrfs snapshots are an outstanding local undo button. &lt;code&gt;btrfs send&lt;/code&gt; / &lt;code&gt;btrfs receive&lt;/code&gt; are how those snapshots become &lt;strong&gt;backups&lt;/strong&gt;: full once, incremental forever after, preferably to another disk or host, always from read-only parents, with retention that never murders the next common ancestor.&lt;/p&gt;

&lt;p&gt;Wire a oneshot + timer, keep received copies read-only, and schedule a restore drill. That is the difference between “we have snapshots” and “we can get the data back.”&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://btrfs.readthedocs.io/en/latest/Send-receive.html" rel="noopener noreferrer"&gt;Btrfs Send/receive overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://btrfs.readthedocs.io/en/latest/btrfs-send.html" rel="noopener noreferrer"&gt;btrfs-send(8)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://btrfs.readthedocs.io/en/latest/btrfs-receive.html" rel="noopener noreferrer"&gt;btrfs-receive(8)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://btrfs.readthedocs.io/en/latest/btrfs-subvolume.html" rel="noopener noreferrer"&gt;btrfs-subvolume(8)&lt;/a&gt; — especially subvolume flags, nested snapshot barriers, and “a snapshot is not a backup”&lt;/li&gt;
&lt;li&gt;&lt;a href="https://btrfs.readthedocs.io/en/latest/Subvolumes.html" rel="noopener noreferrer"&gt;Subvolumes documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>storage</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Stop Spinning Up Full VMs for Every Test Lab: Practical systemd-nspawn Containers on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Sat, 22 Aug 2026 05:02:38 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-spinning-up-full-vms-for-every-test-lab-practical-systemd-nspawn-containers-on-linux-pgn</link>
      <guid>https://dev.to/lyraalishaikh/stop-spinning-up-full-vms-for-every-test-lab-practical-systemd-nspawn-containers-on-linux-pgn</guid>
      <description>&lt;h1&gt;
  
  
  Stop Spinning Up Full VMs for Every Test Lab: Practical systemd-nspawn Containers on Linux
&lt;/h1&gt;

&lt;p&gt;You need a throwaway Debian box to try a package upgrade. Or a clean Fedora root to reproduce a packaging bug. Or a second "machine" on the same host so you can break networking without touching production.&lt;/p&gt;

&lt;p&gt;A full VM works — and costs you another kernel, another bootloader, another virtual disk, and minutes of cold-start time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;systemd-nspawn&lt;/strong&gt; is the other option: a chroot with real namespaces. Same host kernel, full userspace OS tree, PID/IPC/UTS isolation, optional private networking and user namespaces, managed like any other systemd unit through &lt;strong&gt;machinectl&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is not "replace Podman for every app container." Application containers (OCI images, Quadlet units, auto-updates) stay the right tool for single services. nspawn shines when you want a &lt;strong&gt;whole OS&lt;/strong&gt; — systemd as PID 1, package manager, multi-service stack — without virtualizing hardware.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get (and what you do not)
&lt;/h2&gt;

&lt;p&gt;From the man page, nspawn virtualizes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the filesystem hierarchy&lt;/li&gt;
&lt;li&gt;the process tree&lt;/li&gt;
&lt;li&gt;IPC&lt;/li&gt;
&lt;li&gt;hostname / domain name&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It deliberately locks down host-affecting interfaces: &lt;code&gt;/sys&lt;/code&gt; and &lt;code&gt;/proc/sys&lt;/code&gt; are read-only from the container, the host clock and host NICs cannot be reconfigured from inside, device nodes cannot be created, modules cannot be loaded, and the host cannot be rebooted from the guest.&lt;/p&gt;

&lt;p&gt;Important safety note from upstream: &lt;strong&gt;that sandbox is weak without user namespaces&lt;/strong&gt;. Untrusted workloads should always run with &lt;code&gt;--private-users&lt;/code&gt; / &lt;code&gt;-U&lt;/code&gt; (the &lt;code&gt;systemd-nspawn@.service&lt;/code&gt; template enables this by default when the kernel supports it).&lt;/p&gt;

&lt;p&gt;Boundary check:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;systemd-nspawn&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full OS trees, package labs, multi-service "mini hosts"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Podman / Docker&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;OCI app images, rootless services, image registries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;QEMU / KVM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Different kernels, full hardware isolation, Secure Boot guest tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;chroot&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Quick filesystem pivot with almost no isolation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Packages
&lt;/h2&gt;

&lt;p&gt;On Debian/Ubuntu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;systemd-container debootstrap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;systemd-nspawn&lt;/code&gt;, &lt;code&gt;machinectl&lt;/code&gt;, and the &lt;code&gt;systemd-nspawn@.service&lt;/code&gt; template ship in &lt;strong&gt;systemd-container&lt;/strong&gt;. &lt;code&gt;debootstrap&lt;/code&gt; builds Debian/Ubuntu root trees.&lt;/p&gt;

&lt;p&gt;Enable the machine registration target so boot-time containers can start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; machines.target
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; systemd-machined.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Build a Debian root under &lt;code&gt;/var/lib/machines&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;machinectl looks for images primarily in &lt;code&gt;/var/lib/machines/&lt;/code&gt;. Machine names must be valid hostnames: &lt;strong&gt;ASCII letters, digits, and hyphens only&lt;/strong&gt; (underscores are rejected).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;deb-lab
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /var/lib/machines

&lt;span class="c"&gt;# Include dbus + PAM/NSS systemd bits so machinectl shell/login work cleanly&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;debootstrap &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dbus,libpam-systemd,libnss-systemd,systemd-resolved,iproute2,iputils-ping &lt;span class="se"&gt;\&lt;/span&gt;
  bookworm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"/var/lib/machines/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  http://deb.debian.org/debian
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why those extra packages? Debootstrap does not pull recommended packages by default. Without &lt;code&gt;dbus&lt;/code&gt; and the systemd PAM/NSS pieces, a booted container often cannot talk to the host's machine manager the way you expect.&lt;/p&gt;

&lt;p&gt;Set a root password (shell mode — no &lt;code&gt;--boot&lt;/code&gt; yet):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemd-nspawn &lt;span class="nt"&gt;-D&lt;/span&gt; &lt;span class="s2"&gt;"/var/lib/machines/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; passwd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or drop straight into a shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemd-nspawn &lt;span class="nt"&gt;-D&lt;/span&gt; &lt;span class="s2"&gt;"/var/lib/machines/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# inside: passwd ; exit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm the OS identity file exists (nspawn requires &lt;code&gt;/etc/os-release&lt;/code&gt; or &lt;code&gt;/usr/lib/os-release&lt;/code&gt; before boot):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo cat&lt;/span&gt; &lt;span class="s2"&gt;"/var/lib/machines/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/etc/os-release"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  First boot: interactive vs managed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Interactive console boot
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemd-nspawn &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; &lt;span class="s2"&gt;"/var/lib/machines/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-b&lt;/code&gt; / &lt;code&gt;--boot&lt;/code&gt; searches for an init and runs it as PID 1. Log in as root. From inside the container, &lt;code&gt;poweroff&lt;/code&gt; shuts it down cleanly. From the attached console, &lt;strong&gt;Ctrl+] pressed three times quickly&lt;/strong&gt; detaches nspawn itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Managed service boot (preferred for daily use)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl start &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl status &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;machinectl start&lt;/code&gt; instantiates &lt;code&gt;systemd-nspawn@deb-lab.service&lt;/code&gt;. That template applies different defaults than a bare CLI invoke:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Default from the template&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--boot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full init as PID 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--network-veth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Private netns + host &lt;code&gt;ve-&amp;lt;name&amp;gt;&lt;/code&gt; ↔ guest &lt;code&gt;host0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;-U&lt;/code&gt; (when supported)&lt;/td&gt;
&lt;td&gt;User namespace (&lt;code&gt;PrivateUsers=&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--link-journal=try-guest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Journal integration when possible&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Open a root shell without a full getty login:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl shell root@&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or a login prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl login &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Host-side journal for that machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-M&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resource view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-cgtop
systemd-cgls &lt;span class="nt"&gt;-M&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Persistent settings: &lt;code&gt;.nspawn&lt;/code&gt; files
&lt;/h2&gt;

&lt;p&gt;Do not fork the template unit for every tweak. Put durable options in:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/nspawn/&amp;lt;machine&amp;gt;.nspawn&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Example for a lab box with port publish, tighter filesystem policy, and explicit private networking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/nspawn/deb-lab.nspawn
&lt;/span&gt;&lt;span class="nn"&gt;[Exec]&lt;/span&gt;
&lt;span class="py"&gt;Boot&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="py"&gt;PrivateUsers&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="c"&gt;# Optional: drop a capability you never need in this lab
&lt;/span&gt;&lt;span class="py"&gt;DropCapability&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;CAP_SYS_MODULE&lt;/span&gt;

&lt;span class="nn"&gt;[Files]&lt;/span&gt;
&lt;span class="c"&gt;# Keep the image writable for apt; use Volatile=overlay for disposable runs instead
&lt;/span&gt;&lt;span class="py"&gt;ReadOnly&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;span class="c"&gt;# Example host bind for shared packages or test fixtures
&lt;/span&gt;&lt;span class="py"&gt;BindReadOnly&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/var/cache/lab-fixtures:/fixtures&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;Private&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="c"&gt;# Required workaround when using .nspawn with systemd-nspawn@.service:
# restate veth even though the unit already passes --network-veth
&lt;/span&gt;&lt;span class="py"&gt;VirtualEthernet&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="c"&gt;# Publish container :80 on host TCP 8080 (non-loopback interfaces only)
&lt;/span&gt;&lt;span class="py"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;tcp:8080:80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes that bite people in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Privileged keys&lt;/strong&gt; (&lt;code&gt;Bind=&lt;/code&gt;, &lt;code&gt;Capability=&lt;/code&gt;, &lt;code&gt;PrivateUsers=&lt;/code&gt;, …) only fully apply from &lt;code&gt;/etc/systemd/nspawn/&lt;/code&gt; or &lt;code&gt;/run/systemd/nspawn/&lt;/code&gt;. Files next to the image are partially ignored for safety.&lt;/li&gt;
&lt;li&gt;With the template's &lt;code&gt;--settings=override&lt;/code&gt;, some network keys interact poorly unless you &lt;strong&gt;restate &lt;code&gt;VirtualEthernet=yes&lt;/code&gt;&lt;/strong&gt; in the &lt;code&gt;.nspawn&lt;/code&gt; file (documented Arch/systemd issue).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;machinectl remove&lt;/code&gt; can delete a settings file under &lt;code&gt;/etc/systemd/nspawn/&lt;/code&gt; in some versions — keep a copy in config management.&lt;/li&gt;
&lt;li&gt;Port mapping intentionally &lt;strong&gt;skips the host loopback&lt;/strong&gt;. &lt;code&gt;curl 127.0.0.1:8080&lt;/code&gt; hits the host, not the container. Use a real host address or test from another machine/namespace.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Inspect / edit settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl &lt;span class="nb"&gt;cat &lt;/span&gt;deb-lab
&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl edit deb-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Networking that actually works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Default private veth (machinectl)
&lt;/h3&gt;

&lt;p&gt;Host side: &lt;code&gt;ve-deb-lab&lt;/code&gt; (truncated with an altname if the name is long).&lt;br&gt;&lt;br&gt;
Guest side: &lt;code&gt;host0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;systemd-networkd&lt;/strong&gt; runs on the host, the shipped unit &lt;code&gt;/usr/lib/systemd/network/80-container-ve.network&lt;/code&gt; matches &lt;code&gt;ve-*&lt;/code&gt;, runs a DHCP server toward the container, and can enable IP masquerade. Inside a networkd-enabled guest, &lt;code&gt;80-container-host0.network&lt;/code&gt; DHCP-clients &lt;code&gt;host0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Minimal host enablement when the rest of the host still uses NetworkManager or ifupdown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; systemd-networkd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;networkd only manages interfaces it has &lt;code&gt;.network&lt;/code&gt; files for — existing host NICs stay alone unless you write units for them.&lt;/p&gt;

&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip &lt;span class="nt"&gt;-br&lt;/span&gt; &lt;span class="nb"&gt;link &lt;/span&gt;show &lt;span class="nb"&gt;type &lt;/span&gt;veth
ip &lt;span class="nt"&gt;-4&lt;/span&gt; addr show ve-deb-lab
&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl shell root@deb-lab /sbin/ip &lt;span class="nt"&gt;-br&lt;/span&gt; a
ping &lt;span class="nt"&gt;-c2&lt;/span&gt; &amp;lt;container-ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Forwarding / NAT caveats
&lt;/h3&gt;

&lt;p&gt;networkd may install a masquerade rule for the container subnet, but &lt;strong&gt;filter FORWARD policy is still yours&lt;/strong&gt;. On an nftables host, allow the veth path explicitly, for example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft add table ip filter 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;nft &lt;span class="s1"&gt;'add chain ip filter forward { type filter hook forward priority filter; policy drop; }'&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;nft add rule ip filter forward iifname &lt;span class="s2"&gt;"ve-*"&lt;/span&gt; oifname &lt;span class="s2"&gt;"eth0"&lt;/span&gt; accept
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft add rule ip filter forward iifname &lt;span class="s2"&gt;"eth0"&lt;/span&gt; oifname &lt;span class="s2"&gt;"ve-*"&lt;/span&gt; ct state established,related accept
&lt;span class="c"&gt;# DHCP to containers&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft add rule ip filter input iifname &lt;span class="s2"&gt;"ve-*"&lt;/span&gt; udp dport 67 accept
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adjust &lt;code&gt;eth0&lt;/code&gt; to your real uplink name. Keep these rules in your normal nftables config, not one-off memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Host networking (share the host stack)
&lt;/h3&gt;

&lt;p&gt;For simple command containers where you do &lt;strong&gt;not&lt;/strong&gt; want a private netns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/nspawn/deb-lab.nspawn
&lt;/span&gt;&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;VirtualEthernet&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the container sees the host's interfaces and addresses. Fine for trusted package builds; poor isolation for anything that binds ports or rewrites routes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bridge or zone
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--network-bridge=br0&lt;/code&gt; / &lt;code&gt;Bridge=&lt;/code&gt; — attach the host end of the veth to an existing bridge (&lt;code&gt;vb-&lt;/code&gt; prefix).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--network-zone=lab&lt;/code&gt; / &lt;code&gt;Zone=&lt;/code&gt; — let nspawn/networkd maintain a &lt;code&gt;vz-lab&lt;/code&gt; bridge shared by several containers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  MACVLAN / IPVLAN
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;--network-macvlan=eth0&lt;/code&gt; puts the container on the LAN with its own MAC (&lt;code&gt;mv-eth0&lt;/code&gt; naming). Useful when the lab must look like a physical peer. Remember the host cannot talk to macvlan children on the same parent without a second macvlan on the host side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resource limits (cgroup v2)
&lt;/h2&gt;

&lt;p&gt;Treat the container unit like any other service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Soft reclaim pressure + hard stop&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl set-property systemd-nspawn@deb-lab.service &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;MemoryHigh&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1G &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;MemoryMax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2G &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;CPUQuota&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;200%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That writes drop-ins under &lt;code&gt;/etc/systemd/system.control/&lt;/code&gt;. Temporary only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl set-property &lt;span class="nt"&gt;--runtime&lt;/span&gt; systemd-nspawn@deb-lab.service &lt;span class="nv"&gt;CPUQuota&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm with &lt;code&gt;systemctl show systemd-nspawn@deb-lab.service -p MemoryMax -p CPUQuota&lt;/code&gt; and &lt;code&gt;systemd-cgtop&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ephemeral and volatile modes
&lt;/h2&gt;

&lt;p&gt;Three related knobs, different tradeoffs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;th&gt;Good for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--ephemeral&lt;/code&gt; / &lt;code&gt;Ephemeral=yes&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;COW snapshot of the whole tree; discarded on exit&lt;/td&gt;
&lt;td&gt;One-shot tests that may &lt;code&gt;apt install&lt;/code&gt; freely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--volatile=overlay&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read-only root + overlayfs tmpfs upper&lt;/td&gt;
&lt;td&gt;Fast disposable boots without cloning the tree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--volatile=state&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read-only OS, tmpfs &lt;code&gt;/var&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Stateless appliances that repopulate &lt;code&gt;/var&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--volatile=yes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;tmpfs root + read-only &lt;code&gt;/usr&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Images that support &lt;code&gt;/usr&lt;/code&gt;-only boot&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ephemeral example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemd-nspawn &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; /var/lib/machines/deb-lab
&lt;span class="c"&gt;# changes vanish when the container exits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Btrfs/XFS reflinks make snapshot modes cheap; plain ext4 still works but copies more data.&lt;/p&gt;

&lt;p&gt;Clone a durable second image when you want a named fork instead of a temp snapshot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl clone deb-lab deb-lab-try2
&lt;span class="c"&gt;# hostname / machine-id are NOT rewritten — fix them inside the clone&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemd-nspawn &lt;span class="nt"&gt;-D&lt;/span&gt; /var/lib/machines/deb-lab-try2 &lt;span class="se"&gt;\&lt;/span&gt;
  hostnamectl &lt;span class="nb"&gt;hostname &lt;/span&gt;deb-lab-try2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  User namespaces without the footguns
&lt;/h2&gt;

&lt;p&gt;Managed containers default to &lt;code&gt;-U&lt;/code&gt; when available (&lt;code&gt;--private-users=pick --private-users-ownership=auto&lt;/code&gt;). That maps container UID 0 to an unprivileged high range on the host.&lt;/p&gt;

&lt;p&gt;Implications:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Host paths bind-mounted into the container show up as "nobody" / odd UIDs unless you plan ownership.&lt;/li&gt;
&lt;li&gt;After a chown-style shift, keep using private users consistently or shift back with an explicit range starting at 0 (see Arch Wiki / man page) before abandoning userns.&lt;/li&gt;
&lt;li&gt;Nested containers need extra delegated ranges (&lt;code&gt;PrivateUsersDelegate=&lt;/code&gt; on newer systemd) — out of scope for a first lab, but know the knob exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Enable at boot
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl &lt;span class="nb"&gt;enable &lt;/span&gt;deb-lab
&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl start deb-lab   &lt;span class="c"&gt;# if not already running&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This enables &lt;code&gt;systemd-nspawn@deb-lab.service&lt;/code&gt;. Disable with &lt;code&gt;machinectl disable deb-lab&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Daily operator cheat sheet
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;machinectl list
machinectl list-images
machinectl status deb-lab
machinectl shell root@deb-lab
machinectl poweroff deb-lab     &lt;span class="c"&gt;# clean shutdown (SIGRTMIN+4 to PID 1)&lt;/span&gt;
machinectl reboot deb-lab
machinectl terminate deb-lab    &lt;span class="c"&gt;# hard kill&lt;/span&gt;
machinectl copy-to deb-lab ./payload.tar.gz /root/
machinectl copy-from deb-lab /var/log/apt/history.log ./
journalctl &lt;span class="nt"&gt;-M&lt;/span&gt; deb-lab &lt;span class="nt"&gt;-u&lt;/span&gt; ssh.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cleanup leftovers after a killed session (newer systemd):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemd-nspawn &lt;span class="nt"&gt;--cleanup&lt;/span&gt; &lt;span class="nt"&gt;-M&lt;/span&gt; deb-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remove an image you no longer need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl poweroff deb-lab
&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl remove deb-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Minimal end-to-end lab script
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;deb&lt;/span&gt;&lt;span class="p"&gt;-lab&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;ROOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/lib/machines/&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; systemd-container debootstrap
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; machines.target systemd-machined.service systemd-networkd.service

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ROOT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;/etc/os-release &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;debootstrap &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dbus,libpam-systemd,libnss-systemd,systemd-resolved,iproute2,iputils-ping,openssh-server &lt;span class="se"&gt;\&lt;/span&gt;
    bookworm &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ROOT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; http://deb.debian.org/debian
  &lt;span class="c"&gt;# empty root password for lab-only; change immediately on shared hosts&lt;/span&gt;
  &lt;span class="nb"&gt;sudo &lt;/span&gt;systemd-nspawn &lt;span class="nt"&gt;-D&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ROOT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo "root:root" | chpasswd'&lt;/span&gt;
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/systemd/nspawn
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/systemd/nspawn/&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;.nspawn &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
[Exec]
Boot=yes
PrivateUsers=yes

[Network]
Private=yes
VirtualEthernet=yes
Port=tcp:2222:22
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl set-property systemd-nspawn@&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;.service &lt;span class="nv"&gt;MemoryMax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2G &lt;span class="nv"&gt;CPUQuota&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;200%
&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl start &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl status &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Shell: sudo machinectl shell root@&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"SSH via published port on a non-loopback host IP: ssh -p 2222 root@&amp;lt;host-ip&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Treat the default password as a lab convenience only. On any shared host, set a real password or install SSH keys and disable password auth inside the container.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes worth knowing
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Invalid machine name&lt;/code&gt;&lt;/strong&gt; — rename; no underscores.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;machinectl shell hangs / fails&lt;/strong&gt; — install &lt;code&gt;dbus&lt;/code&gt; + &lt;code&gt;libpam-systemd&lt;/code&gt; in the guest; ensure the guest actually booted with systemd.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No outbound network&lt;/strong&gt; — host networkd not running, missing FORWARD accepts, or &lt;code&gt;ip_forward&lt;/code&gt; disabled (&lt;code&gt;sysctl net.ipv4.ip_forward&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port publish "works" but localhost fails&lt;/strong&gt; — by design; nspawn skips loopback for &lt;code&gt;-p&lt;/code&gt;/&lt;code&gt;Port=&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permission chaos on binds&lt;/strong&gt; — user namespace UID shift; use &lt;code&gt;BindReadOnly=&lt;/code&gt; for host content or align ownership deliberately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Untrusted code without &lt;code&gt;-U&lt;/code&gt;&lt;/strong&gt; — treat as nearly equivalent to root on the host for escape purposes.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Rollback
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Stop and disable&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl poweroff deb-lab &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;machinectl disable deb-lab &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# Remove unit property drop-ins&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl revert systemd-nspawn@deb-lab.service 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /etc/systemd/system.control/systemd-nspawn@deb-lab.service.d

&lt;span class="c"&gt;# Remove settings + image&lt;/span&gt;
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/systemd/nspawn/deb-lab.nspawn
&lt;span class="nb"&gt;sudo &lt;/span&gt;machinectl remove deb-lab
&lt;span class="c"&gt;# or: sudo rm -rf /var/lib/machines/deb-lab&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No kernel modules were loaded, no permanent sysctl was required for the basic lab. Only your nftables FORWARD/NAT additions need a matching delete if you added them by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to reach for something else
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single process, OCI image, registry workflow&lt;/strong&gt; → Podman/Docker (and Quadlet if you want systemd-native units).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different kernel, Secure Boot guest, full device model&lt;/strong&gt; → libvirt/QEMU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maximum untrusted isolation on the same kernel&lt;/strong&gt; → still consider a VM; nspawn userns is good, not magical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orchestrated fleets&lt;/strong&gt; → Kubernetes/Nomad; nspawn is a host-local machine manager.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://man.archlinux.org/man/systemd-nspawn.1.en" rel="noopener noreferrer"&gt;systemd-nspawn(1)&lt;/a&gt; — container runtime options, volatile/ephemeral modes, networking flags&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://man.archlinux.org/man/systemd.nspawn.5.en" rel="noopener noreferrer"&gt;systemd.nspawn(5)&lt;/a&gt; — &lt;code&gt;.nspawn&lt;/code&gt; settings file format&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://man.archlinux.org/man/machinectl.1.en" rel="noopener noreferrer"&gt;machinectl(1)&lt;/a&gt; — start/shell/enable/clone/remove workflow&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://man.archlinux.org/man/systemd.resource-control.5.en" rel="noopener noreferrer"&gt;systemd.resource-control(5)&lt;/a&gt; — &lt;code&gt;MemoryMax=&lt;/code&gt;, &lt;code&gt;CPUQuota=&lt;/code&gt;, etc.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wiki.archlinux.org/title/Systemd-nspawn" rel="noopener noreferrer"&gt;ArchWiki: systemd-nspawn&lt;/a&gt; — debootstrap examples, veth/NAT notes, userns tips&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://manpages.debian.org/bookworm/systemd-container/systemd-nspawn.1.en.html" rel="noopener noreferrer"&gt;Debian man: systemd-nspawn&lt;/a&gt; — distro-packaged reference&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://manpages.debian.org/bookworm/debootstrap/debootstrap.8.en.html" rel="noopener noreferrer"&gt;debootstrap(8)&lt;/a&gt; — building Debian rootfs trees&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Full OS. Same kernel. Unit-file lifecycle. Once the root tree exists, &lt;code&gt;machinectl start&lt;/code&gt; is closer to flipping on a service than waiting on a hypervisor — and that is exactly when a "test VM" stops being worth the weight.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>systemd</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Stop Spoofed Packets at the Edge: Practical nftables FIB Reverse Path Filtering on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Fri, 21 Aug 2026 05:02:25 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-spoofed-packets-at-the-edge-practical-nftables-fib-reverse-path-filtering-on-linux-3jd0</link>
      <guid>https://dev.to/lyraalishaikh/stop-spoofed-packets-at-the-edge-practical-nftables-fib-reverse-path-filtering-on-linux-3jd0</guid>
      <description>&lt;p&gt;Spoofed source addresses are still one of the cheapest ways to waste firewall CPU, poison logs, and bounce abuse complaints onto the wrong network.&lt;/p&gt;

&lt;p&gt;Linux already has a classic defense: &lt;code&gt;rp_filter&lt;/code&gt; in the IPv4 sysctl tree. It works. It is also blunt, per-interface, and easy to disable “just for a minute” during asymmetric routing debugging — and then never turn back on.&lt;/p&gt;

&lt;p&gt;nftables gives you the same idea as an explicit packet-path rule: the &lt;code&gt;fib&lt;/code&gt; expression. You ask the kernel’s forwarding information base (FIB) what it thinks about a source or destination address, then accept or drop based on that answer. You can log the rejects, scope them to one WAN interface, combine them with other edge policy, and keep the logic next to the rest of your firewall.&lt;/p&gt;

&lt;p&gt;This post is a practical recipe for reverse-path filtering with stock nftables on Linux edge hosts and small gateways.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you are solving
&lt;/h2&gt;

&lt;p&gt;Three common edge problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Forged sources on the WAN&lt;/strong&gt; — packets arrive on &lt;code&gt;eth0&lt;/code&gt; claiming to come from your LAN, loopback, or some other customer prefix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Martian and unroutable sources&lt;/strong&gt; — traffic whose source address has no usable route at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local-address confusion&lt;/strong&gt; — packets aimed at addresses that are not configured on the receiving interface (useful on multi-homed servers).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Comparison of nearby tools:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Main job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;net.ipv4.conf.*.rp_filter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;kernel routing validation&lt;/td&gt;
&lt;td&gt;strict/loose reverse-path check for IPv4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nftables &lt;code&gt;fib&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Netfilter rule expression&lt;/td&gt;
&lt;td&gt;query FIB and decide per-rule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nftables dynamic sets / meters&lt;/td&gt;
&lt;td&gt;packet-path abuse control&lt;/td&gt;
&lt;td&gt;rate-limit or temp-ban noisy sources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nftables SYNPROXY&lt;/td&gt;
&lt;td&gt;TCP handshake proxy&lt;/td&gt;
&lt;td&gt;stop unauthenticated SYNs from filling conntrack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VRF / policy routing&lt;/td&gt;
&lt;td&gt;routing domains&lt;/td&gt;
&lt;td&gt;isolate tables; does not by itself anti-spoof WAN ingress&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use &lt;code&gt;fib&lt;/code&gt; when you want reverse-path policy that is visible, countable, and composable inside the firewall ruleset — not only a silent sysctl.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Linux 4.10+ and nftables 0.7+ (FIB matching landed there; modern Debian/Ubuntu/Fedora are fine)&lt;/li&gt;
&lt;li&gt;root on the host that sees the traffic&lt;/li&gt;
&lt;li&gt;A routing table you understand (&lt;code&gt;ip route&lt;/code&gt;, optional policy rules)&lt;/li&gt;
&lt;li&gt;A recovery path if you lock yourself out of a remote box&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Packages (Debian/Ubuntu-style):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; nftables iproute2
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before changing anything live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list ruleset &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"/root/nftables-before-fib-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F-%H%M&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.nft"&lt;/span&gt;
ip &lt;span class="nt"&gt;-4&lt;/span&gt; route show table all &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"/root/routes-before-fib-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F-%H%M&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.txt"&lt;/span&gt;
ip &lt;span class="nt"&gt;-6&lt;/span&gt; route show table all &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"/root/routes-before-fib-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F-%H%M&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.txt"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Core ideas (short)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What &lt;code&gt;fib&lt;/code&gt; does
&lt;/h3&gt;

&lt;p&gt;From &lt;code&gt;nft(8)&lt;/code&gt; and the nftables wiki:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fib {saddr | daddr | mark | iif | oif} [. ...] {oif | oifname | type}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You feed the FIB a key (source address, destination address, optional mark/interfaces) and read back:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;oif&lt;/code&gt; / &lt;code&gt;oifname&lt;/code&gt;&lt;/strong&gt; — which interface the kernel would use to reach that address&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;type&lt;/code&gt;&lt;/strong&gt; — address type such as &lt;code&gt;local&lt;/code&gt;, &lt;code&gt;broadcast&lt;/code&gt;, &lt;code&gt;unicast&lt;/code&gt;, &lt;code&gt;blackhole&lt;/code&gt;, …&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Reverse-path filtering in one line
&lt;/h3&gt;

&lt;p&gt;The manpage’s canonical anti-spoof pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fib saddr . iif oif missing drop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take the packet’s &lt;strong&gt;source address&lt;/strong&gt; and &lt;strong&gt;input interface&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Ask the FIB: “If I needed to reply toward this source, which output interface would I use?”&lt;/li&gt;
&lt;li&gt;If the answer is &lt;strong&gt;missing&lt;/strong&gt; (no usable reverse path for that &lt;code&gt;saddr&lt;/code&gt;/&lt;code&gt;iif&lt;/code&gt; pair), &lt;strong&gt;drop&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When &lt;code&gt;iif&lt;/code&gt; is part of the lookup key, a successful reverse path means the reverse route goes out the &lt;strong&gt;same&lt;/strong&gt; interface the packet arrived on (strict-style). If you omit &lt;code&gt;iif&lt;/code&gt; and only check &lt;code&gt;fib saddr oif&lt;/code&gt;, any valid outgoing interface counts (loose-style).&lt;/p&gt;

&lt;h3&gt;
  
  
  Strict vs loose (and vs sysctl)
&lt;/h3&gt;

&lt;p&gt;Kernel &lt;code&gt;rp_filter&lt;/code&gt; (from &lt;code&gt;ip-sysctl&lt;/code&gt; docs):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;no source validation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;strict (RFC 3704)&lt;/td&gt;
&lt;td&gt;ingress interface must be the &lt;strong&gt;best&lt;/strong&gt; reverse path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;loose (RFC 3704)&lt;/td&gt;
&lt;td&gt;source must be reachable via &lt;strong&gt;some&lt;/strong&gt; interface&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;nftables equivalents (conceptually):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;th&gt;nftables shape&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Strict RPF&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fib saddr . iif oif missing drop&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Loose RPF&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fib saddr oif missing drop&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accept only from a named reverse iface&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fib saddr . iif oif eq "eth0" accept&lt;/code&gt; then drop the rest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can run nftables &lt;code&gt;fib&lt;/code&gt; checks even when sysctl &lt;code&gt;rp_filter=0&lt;/code&gt;, or keep both. If both are on, either layer can drop the packet. Prefer one clear owner of RPF policy so debugging stays sane.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recipe 1 — Strict reverse-path filter on WAN ingress
&lt;/h2&gt;

&lt;p&gt;Goal: on the public interface, drop packets whose source address would not be routed back out that same interface.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/nftables.d/fib-rpfilter.nft &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
table inet edge_rpf {
  chain prerouting_rpf {
    type filter hook prerouting priority mangle; policy accept;

    # Never RPF-filter loopback
    iifname "lo" accept

    # Optional: skip link-local IPv6 ND noise early if you prefer
    # icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert,
    #               nd-router-solicit, nd-router-advert } accept

    # Strict RPF on WAN: reverse path must exist via the ingress iface
    iifname "eth0" fib saddr . iif oif missing counter drop

    # IPv6 uses the same fib expression in an inet table
    # (kernel performs the family-appropriate FIB lookup)
  }
}
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/nftables.d/fib-rpfilter.nft
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/nftables.d/fib-rpfilter.nft
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list table inet edge_rpf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;eth0&lt;/code&gt; with your real WAN device name (&lt;code&gt;enp1s0&lt;/code&gt;, &lt;code&gt;ppp0&lt;/code&gt;, WireGuard &lt;code&gt;wg0&lt;/code&gt; only if you truly want RPF there, etc.).&lt;/p&gt;

&lt;h3&gt;
  
  
  Why prerouting + mangle priority?
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;prerouting&lt;/strong&gt; sees packets before the local-delivery vs forward decision — the right place for edge anti-spoofing on both host-destined and forwarded traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;priority mangle (-150)&lt;/strong&gt; is a conventional early filter priority from the Netfilter hook table. You can use &lt;code&gt;filter&lt;/code&gt; (0) instead if you already own that slot; just keep RPF &lt;strong&gt;before&lt;/strong&gt; broad accepts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conntrack still runs around its own priorities. RPF does not replace &lt;code&gt;ct state invalid drop&lt;/code&gt;; it answers a different question: “Is this source plausible on this wire?”&lt;/p&gt;

&lt;h2&gt;
  
  
  Recipe 2 — Loose reverse-path filter
&lt;/h2&gt;

&lt;p&gt;Use loose mode when reverse traffic legitimately leaves a different interface than the one that received the request (asymmetric routing, some multi-homed designs, certain VPN hairpins).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;table inet edge_rpf_loose {
  chain prerouting_rpf {
    type filter hook prerouting priority mangle; policy accept;

    iifname "lo" accept

    # Source must be reachable via *some* interface
    iifname "eth0" fib saddr oif missing counter drop
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loose mode still kills pure martians (sources with no route) while allowing asymmetric return paths. It will &lt;strong&gt;not&lt;/strong&gt; catch “arrived on WAN but source belongs to LAN behind another NIC” if that LAN source is still globally routable in your FIB via the LAN interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recipe 3 — Require reverse path exactly via the ingress device
&lt;/h2&gt;

&lt;p&gt;The wiki’s positive form is useful when you want an allow-list style:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;table inet edge_rpf_allow {
  chain prerouting_rpf {
    type filter hook prerouting priority mangle; policy accept;

    iifname "lo" accept

    # Accept only if FIB reverse path is eth0; drop otherwise for WAN
    iifname "eth0" fib saddr . iif oif eq "eth0" accept
    iifname "eth0" counter drop
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Functionally this is the strict check written as accept-then-drop. Prefer &lt;code&gt;oif missing drop&lt;/code&gt; when you only care about failure; prefer the positive form when you are composing multiple interface-specific outcomes (for example with a verdict map).&lt;/p&gt;

&lt;h2&gt;
  
  
  Recipe 4 — Drop packets not addressed to this host on this NIC
&lt;/h2&gt;

&lt;p&gt;Multi-homed servers sometimes receive traffic for foreign destinations on the wrong interface (miswiring, cloud secondary IPs, odd anycast). The manpage pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Drop unless destination is local/broadcast/multicast on this iif
fib daddr . iif type != { local, broadcast, multicast } counter drop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example limited to WAN:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;table inet edge_local_dst {
  chain prerouting_local {
    type filter hook prerouting priority mangle; policy accept;

    iifname "lo" accept

    # Host firewall angle: WAN should deliver local addresses, not random transit
    iifname "eth0" fib daddr . iif type != { local, broadcast, multicast } counter drop
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Do not&lt;/strong&gt; paste this on a router’s WAN if that box must forward transit traffic. Forwarding hosts need reverse-path checks on &lt;strong&gt;source&lt;/strong&gt; addresses (Recipes 1–3), not “destination must be local.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Recipe 5 — Wire RPF into a real server policy
&lt;/h2&gt;

&lt;p&gt;Dynamic RPF is not a full firewall. Drop it in front of a normal default-deny input policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#!/usr/sbin/nft -f
flush ruleset

table inet firewall {
  chain prerouting_rpf {
    type filter hook prerouting priority mangle; policy accept;

    iifname "lo" accept
    iifname "eth0" fib saddr . iif oif missing counter drop
  }

  chain input {
    type filter hook input priority filter; policy drop;

    ct state established,related accept
    ct state invalid drop
    iifname "lo" accept

    # IPv6 neighbor discovery
    icmpv6 type { nd-neighbor-solicit, nd-router-advert,
                  nd-neighbor-advert, nd-redirect } accept

    tcp dport { 22, 80, 443 } accept
  }

  chain forward {
    type filter hook forward priority filter; policy drop;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persist the usual way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/nftables.d
&lt;span class="c"&gt;# ensure /etc/nftables.conf includes your files, or paste the table there&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; nftables
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/nftables.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload nftables
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Recipe 6 — Gateway / forward path
&lt;/h2&gt;

&lt;p&gt;On a router, keep RPF on &lt;strong&gt;prerouting&lt;/strong&gt; so both forwarded and locally terminated packets are covered once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;table inet edge_forward_rpf {
  chain prerouting_rpf {
    type filter hook prerouting priority mangle; policy accept;

    iifname "lo" accept

    # WAN customers/uplink: strict RPF
    iifname "eth0" fib saddr . iif oif missing counter drop

    # Optional: loose on a path known to be asymmetric
    # iifname "gre1" fib saddr oif missing counter drop
  }

  chain forward {
    type filter hook forward priority filter; policy drop;

    ct state established,related accept
    ct state invalid drop

    # your normal LAN egress / DNAT-related allows…
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you use nftables &lt;strong&gt;flowtables&lt;/strong&gt; for established fastpath, keep RPF on prerouting for &lt;strong&gt;new&lt;/strong&gt; packets. Spoof checks belong where every fresh flow still visits the classic path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optional: coexist with sysctl &lt;code&gt;rp_filter&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Check current IPv4 behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysctl net.ipv4.conf.all.rp_filter &lt;span class="se"&gt;\&lt;/span&gt;
       net.ipv4.conf.default.rp_filter &lt;span class="se"&gt;\&lt;/span&gt;
       net.ipv4.conf.eth0.rp_filter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remember: the kernel uses the &lt;strong&gt;max&lt;/strong&gt; of &lt;code&gt;conf/all/rp_filter&lt;/code&gt; and &lt;code&gt;conf/&amp;lt;iface&amp;gt;/rp_filter&lt;/code&gt; for that interface.&lt;/p&gt;

&lt;p&gt;If nftables owns RPF, you can make the sysctl intentional rather than mysterious:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/90-rpfilter-owner.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
# nftables fib rules own reverse-path policy on this host.
# Keep kernel RPF off to avoid double-drops that are hard to attribute.
net.ipv4.conf.all.rp_filter = 0
net.ipv4.conf.default.rp_filter = 0
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# Per-interface files may still set rp_filter=1 on some distros — audit them:&lt;/span&gt;
&lt;span class="c"&gt;# grep -R rp_filter /etc/sysctl.conf /etc/sysctl.d /usr/lib/sysctl.d 2&amp;gt;/dev/null&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you prefer belt-and-suspenders, leave &lt;code&gt;rp_filter=1&lt;/code&gt; on and still add nftables counters for visibility. Just document which layer you trust when a packet disappears.&lt;/p&gt;

&lt;p&gt;IPv6 note: classic &lt;code&gt;rp_filter&lt;/code&gt; sysctls are an &lt;strong&gt;IPv4&lt;/strong&gt; story. nftables &lt;code&gt;fib&lt;/code&gt; in an &lt;code&gt;inet&lt;/code&gt; or &lt;code&gt;ip6&lt;/code&gt; table is one of the clean ways to apply the same operational idea to IPv6.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification checklist
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Rules are attached where you think
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list table inet edge_rpf
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list hooks 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm a prerouting chain contains &lt;code&gt;fib saddr ...&lt;/code&gt; and a counter.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. FIB answers match your mental model
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip route get 203.0.113.50
ip &lt;span class="nt"&gt;-6&lt;/span&gt; route get 2001:db8::50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick a source you expect to arrive on WAN. &lt;code&gt;ip route get&lt;/code&gt; should egress via that WAN interface for strict mode to accept it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Counter moves on spoofed lab traffic
&lt;/h3&gt;

&lt;p&gt;In a lab netns or on a packet generator you own (never third-party networks), send a packet into WAN with a source that only exists behind LAN:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Conceptual lab check — adjust interfaces/netns to your harness&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft reset counters table inet edge_rpf 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="c"&gt;# ... inject spoofed source toward the WAN NIC ...&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list chain inet edge_rpf prerouting_rpf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;fib ... missing counter drop&lt;/code&gt; rule should increment.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Legitimate clients still work
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 10 https://your.example
ssh user@your.example &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If real clients break immediately after enabling strict mode, you almost certainly have &lt;strong&gt;asymmetric routing&lt;/strong&gt;. Switch that path to loose mode or fix the routes/policy rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Compare with kernel RPF if still enabled
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Dropped by kernel rp_filter may never hit your nft counter&lt;/span&gt;
nstat &lt;span class="nt"&gt;-az&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; IPReversePath &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;journalctl &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;--grep&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'rp_filter|martian'&lt;/span&gt; &lt;span class="nt"&gt;--no-pager&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If only the kernel path is dropping, your nftables counter will stay quiet. That is a common “I added fib rules and nothing happens” confusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Asymmetric routing + strict mode&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Multi-homed edge, BGP unequal paths, some SD-WAN overlays: reverse path ≠ ingress interface. Use loose mode on those interfaces or fix symmetry.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Policy routing / multiple tables&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;fib&lt;/code&gt; follows the kernel FIB lookup rules, including marks when you key &lt;code&gt;fib daddr . mark&lt;/code&gt; / &lt;code&gt;fib saddr . mark&lt;/code&gt;. If you set marks after the RPF chain, the lookup will not see them yet. Order matters.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;VRF&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Addresses living in a VRF are looked up in that VRF’s world. A source that is valid only in another table will fail RPF. That is desirable for isolation — and surprising if you forgot the VRF.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DHCP clients / weird bootstrap&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Rare boot windows can look “wrong” before routes land. Prefer applying strict RPF after networking is configured, or start in log-only mode:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  iifname "eth0" fib saddr . iif oif missing log prefix "RPF " counter drop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Router vs host destination checks&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;fib daddr . iif type local&lt;/code&gt; is a host/edge filter. On a forwarder it will blackhole transit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Flowtable / offload assumptions&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Established fastpaths skip a lot of classic filtering. Put anti-spoofing where new flows still pass (prerouting), and do not expect prerouting counters to tick for every offloaded packet.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Double RPF with silent sysctl&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Distros often ship &lt;code&gt;rp_filter=1&lt;/code&gt;. Know which layer drops before you chase nftables.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;IPv4-only thinking on dual-stack edges&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Spoofers use IPv6 too. An &lt;code&gt;inet&lt;/code&gt; table with &lt;code&gt;fib&lt;/code&gt; covers both families; plain &lt;code&gt;ip&lt;/code&gt; tables do not.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rollback
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft delete table inet edge_rpf 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;nft delete table inet edge_rpf_loose 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;nft delete table inet edge_rpf_allow 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;nft delete table inet edge_local_dst 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;nft delete table inet edge_forward_rpf 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# Or restore the pre-change dump&lt;/span&gt;
&lt;span class="c"&gt;# sudo nft -f /root/nftables-before-fib-YYYY-MM-DD-HHMM.nft&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload nftables 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep console/out-of-band access when testing remote edge policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal production recipe
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1) Backup&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list ruleset &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"/root/nftables-before-fib-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.nft"&lt;/span&gt;

&lt;span class="c"&gt;# 2) Audit kernel RPF so you know who owns drops&lt;/span&gt;
sysctl net.ipv4.conf.all.rp_filter net.ipv4.conf.default.rp_filter

&lt;span class="c"&gt;# 3) Install strict RPF on WAN (edit interface name first)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/nftables.d/fib-rpfilter.nft
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/nftables.d/fib-rpfilter.nft

&lt;span class="c"&gt;# 4) Verify&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list table inet edge_rpf
ip route get 1.1.1.1

&lt;span class="c"&gt;# 5) Confirm real services still answer&lt;/span&gt;
&lt;span class="c"&gt;# curl -I https://your.example&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;If your edge still trusts every source address that arrives on the wire, you are letting attackers choose their own return identity.&lt;/p&gt;

&lt;p&gt;nftables &lt;code&gt;fib&lt;/code&gt; reverse-path filtering is not a full anti-DDoS platform. It is a precise kernel feature with a clear contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ask the FIB whether a source (or destination) makes sense&lt;/li&gt;
&lt;li&gt;enforce strict or loose reverse-path policy in the firewall itself&lt;/li&gt;
&lt;li&gt;count and log the rejects&lt;/li&gt;
&lt;li&gt;keep asymmetric paths on loose mode instead of silently disabling protection forever&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use SYNPROXY when unauthenticated handshakes are the cost center. Use dynamic sets and meters when rates and temp-bans matter. Use &lt;code&gt;fib&lt;/code&gt; when the first question is simpler: &lt;strong&gt;should this source address be here at all?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources and references
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;nftables wiki — Matching routing information (&lt;code&gt;fib&lt;/code&gt;, &lt;code&gt;rt nexthop&lt;/code&gt;): &lt;a href="https://wiki.nftables.org/wiki-nftables/index.php/Matching_routing_information" rel="noopener noreferrer"&gt;https://wiki.nftables.org/wiki-nftables/index.php/Matching_routing_information&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;nftables wiki — Netfilter hooks and priorities: &lt;a href="https://wiki.nftables.org/wiki-nftables/index.php/Netfilter_hooks" rel="noopener noreferrer"&gt;https://wiki.nftables.org/wiki-nftables/index.php/Netfilter_hooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;nftables wiki — Configuring chains: &lt;a href="https://wiki.nftables.org/wiki-nftables/index.php/Configuring_chains" rel="noopener noreferrer"&gt;https://wiki.nftables.org/wiki-nftables/index.php/Configuring_chains&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;nftables wiki — Simple ruleset for a server: &lt;a href="https://wiki.nftables.org/wiki-nftables/index.php/Simple_ruleset_for_a_server" rel="noopener noreferrer"&gt;https://wiki.nftables.org/wiki-nftables/index.php/Simple_ruleset_for_a_server&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Debian &lt;code&gt;nft(8)&lt;/code&gt; — FIB expressions (&lt;code&gt;fib saddr . iif oif missing drop&lt;/code&gt;): &lt;a href="https://manpages.debian.org/bookworm/nftables/nft.8.en.html" rel="noopener noreferrer"&gt;https://manpages.debian.org/bookworm/nftables/nft.8.en.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Netfilter nftables manpage hub: &lt;a href="https://www.netfilter.org/projects/nftables/manpage.html" rel="noopener noreferrer"&gt;https://www.netfilter.org/projects/nftables/manpage.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Linux kernel networking docs — &lt;code&gt;rp_filter&lt;/code&gt; strict/loose (RFC 3704): &lt;a href="https://www.kernel.org/doc/Documentation/networking/ip-sysctl.txt" rel="noopener noreferrer"&gt;https://www.kernel.org/doc/Documentation/networking/ip-sysctl.txt&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;RFC 3704 — Ingress Filtering for Multihomed Networks: &lt;a href="https://www.rfc-editor.org/rfc/rfc3704" rel="noopener noreferrer"&gt;https://www.rfc-editor.org/rfc/rfc3704&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>security</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
