Stop Patching the Live Root: Practical A/B OS Updates with systemd-sysupdate
Package managers are great at evolving a mutable system in place. They are a poorer fit when you want the opposite model: ship a whole, known-good OS image, keep the currently running root untouched while the next version lands beside it, then flip roles on reboot.
That is the job of systemd-sysupdate.
It is the image-update tool that pairs naturally with GPT discoverable partitions, dm-verity roots, UKIs on the ESP, systemd-repart spare slots, and portable/container disk images. You describe transfers (source → target). The tool enumerates versions, verifies downloads, writes into free A/B (or A/B/C…) slots, and only then finalizes names/labels so a reboot can pick up the new set.
This article is a practical operator guide based on the current systemd-sysupdate(8) and sysupdate.d(5) manuals. The feature is still marked experimental upstream—pin versions, read the man pages for your release, and expect setting names to evolve—but the core model has been stable enough since systemd 251 to use deliberately.
What problem it solves
Typical in-place upgrade pain:
- Mid-upgrade reboot leaves a half-applied userspace.
- Kernel, initrd, rootfs, and verity data can drift out of lockstep.
- “Rollback” means hoping the package manager’s undo path still works.
- Mutable
/makes fleet drift inevitable.
systemd-sysupdate flips the model:
- Keep multiple concurrent versions of each resource (files, directories/subvolumes, or GPT partitions).
- Download the next version into empty slots while the current version keeps running.
- Bind several resources together with a shared version id (
@v) so root + verity + UKI move as one logical release. - Activate by booting the new entry point (usually the new UKI / boot loader entry), not by rewriting the live root underneath PID 1.
It can update:
- the host OS online from inside itself
- offline disk images via
--image= - container images, portable service images, and other file/dir trees the same way
Mental model: transfers + versions
Each resource you update is one transfer file under:
/etc/sysupdate.d//run/sysupdate.d//usr/local/lib/sysupdate.d//usr/lib/sysupdate.d/
Current docs name these *.transfer (older trees and some man page editions still show *.conf—check man 5 sysupdate.d on your box).
Every transfer has three sections:
-
[Transfer]— policy (min version, protect running version, verify, optional features) -
[Source]— where versions come from -
[Target]— where versions are installed
Sources and targets are typed:
| Source type | Typical target | Auth / integrity |
|---|---|---|
url-file |
regular-file or partition
|
SHA256SUMS + optional SHA256SUMS.gpg |
url-tar |
directory or subvolume
|
same |
regular-file |
regular-file or partition
|
no remote auth |
tar |
directory / subvolume
|
local only |
directory / subvolume
|
directory / subvolume
|
local copy |
HTTP(S) catalogs are deliberately simple: a GNU sha256sum-format SHA256SUMS next to the payloads, optionally signed as SHA256SUMS.gpg. Payloads are always checked against the hashes. Verify= (default yes) controls whether the manifest signature is validated against the import keyring (/usr/lib/systemd/import-pubring.pgp and /etc/systemd/import-pubring.pgp, with older .gpg names still documented).
Version extraction uses match patterns. The mandatory wildcard is @v. Useful extras include partition UUID/flags (@u, @f, @a, @g, @r), file mode/size/time, boot-assessment counters (@d / @l), and more.
A complete OS update: root + verity + UKI
The manuals’ canonical example is still the clearest. Updating “foobarOS” to version 47 means three coordinated transfers of the same @v:
-
foobarOS_47.root.xz→ empty GPT root partition (x86-64 type UUID4f68bce3-e8cd-4db1-96e7-fbcaf984b709) -
foobarOS_47.verity.xz→ empty GPT root verity partition (2c7357ed-ebd2-46d9-aec1-23d437ec2bf5) -
foobarOS_47.efi→$BOOT/EFI/Linux/foobarOS_47.efi(Boot Loader Spec Type #2 UKI)
Empty partition slots are marked with GPT label _empty. After a successful write, labels become versioned names like foobarOS_47 / foobarOS_47_verity.
Critical ordering rule: transfers run in alphabetical filename order. Download+write happens first for all transfers; final rename/relabel happens second, still in that order, with disk sync points. Put the boot entry point last (for example 90-uki.transfer) so a crash never leaves a bootable UKI pointing at incomplete root/verity backing.
Example transfer set
# /etc/sysupdate.d/50-root.transfer
[Transfer]
ProtectVersion=%A
Verify=yes
[Source]
Type=url-file
Path=https://download.example.com/foobarOS
MatchPattern=foobarOS_@v.root.xz
[Target]
Type=partition
Path=auto
MatchPattern=foobarOS_@v
# Prefer DPS type names when your systemd supports them; otherwise use the GPT UUID.
TypeUUID=root-x86-64
InstancesMax=2
ReadOnly=yes
# /etc/sysupdate.d/60-verity.transfer
[Transfer]
ProtectVersion=%A
Verify=yes
[Source]
Type=url-file
Path=https://download.example.com/foobarOS
MatchPattern=foobarOS_@v.verity.xz
[Target]
Type=partition
Path=auto
MatchPattern=foobarOS_@v_verity
TypeUUID=root-x86-64-verity
InstancesMax=2
ReadOnly=yes
# /etc/sysupdate.d/90-uki.transfer
[Transfer]
ProtectVersion=%A
Verify=yes
[Source]
Type=url-file
Path=https://download.example.com/foobarOS
MatchPattern=foobarOS_@v.efi
[Target]
Type=regular-file
Path=/EFI/Linux
PathRelativeTo=boot
MatchPattern=foobarOS_@v.efi
Mode=0644
InstancesMax=2
Notes that save real debugging time:
-
ProtectVersion=%Aexpands fromIMAGE_VERSION=in/etc/os-releaseand refuses to delete/overwrite the currently booted image version while making room for the next one.%w(VERSION_ID=) and%B(BUILD_ID=) are related alternatives. -
Path=autoon partition targets means “the block device that backs the booted root.” -
PathRelativeTo=boot(alsoesp,xbootldr,root, …) anchors file targets relative to the right firmware/boot volume instead of hard-coding mount points. -
InstancesMax=should usually match across the whole transfer set. With two root slots you effectively keep current + next. - Partition targets must already exist.
systemd-sysupdatewill not create GPT entries. Pair this withsystemd-repartso first boot (or image build) materializes enough_emptyslots of the right types.
Server-side layout
On the download host:
https://download.example.com/foobarOS/SHA256SUMS
https://download.example.com/foobarOS/SHA256SUMS.gpg
https://download.example.com/foobarOS/foobarOS_47.root.xz
https://download.example.com/foobarOS/foobarOS_47.verity.xz
https://download.example.com/foobarOS/foobarOS_47.efi
https://download.example.com/foobarOS/foobarOS_48.root.xz
...
Generate the manifest with GNU sha256sum (binary mode is recommended in the man page even on Linux):
cd /srv/foobarOS
sha256sum --binary foobarOS_*.root.xz foobarOS_*.verity.xz foobarOS_*.efi > SHA256SUMS
gpg --detach-sign --armor -o SHA256SUMS.gpg SHA256SUMS
Optional freshness brake: include a special BEST-BEFORE-YYYY-MM-DD entry in SHA256SUMS. Past that date, the listing is rejected.
Day-2 commands operators actually use
Install the tool first. On Debian/Ubuntu it typically ships in the systemd-container package; some other distros ship it with core systemd. Confirm:
command -v systemd-sysupdate
man 8 systemd-sysupdate
man 5 sysupdate.d
Enumerate and inspect
# Default command is list
systemd-sysupdate list
systemd-sysupdate list 48 # detail one version + required transfers
systemd-sysupdate --json=pretty list
See if anything newer exists
if ver=$(systemd-sysupdate check-new); then
echo "candidate: $ver"
else
echo "already current or no catalog"
fi
Exit status 0 means a newer installable version exists; its id is printed on stdout.
Install / stage
# Newest available
systemd-sysupdate update
# Explicit version
systemd-sysupdate update 48
# Download now, install later (newer systemd)
systemd-sysupdate acquire 48
systemd-sysupdate update --offline 48
# Install then reboot immediately (host OS only)
systemd-sysupdate update --reboot
acquire (added in 260 on current docs) separates bandwidth work from the finalization window. Older releases only have update.
Space management
systemd-sysupdate vacuum
systemd-sysupdate --instances-max=3 update
vacuum deletes or empties old instances until InstancesMax= is satisfied. update/acquire already call this implicitly when they need a free slot.
Pending activation
# Newer installed than running IMAGE_VERSION= ?
systemd-sysupdate pending && echo "reboot to activate"
# Reboot only if pending
systemd-sysupdate reboot
pending compares the newest installed version id against IMAGE_VERSION= in /etc/os-release. That is your “update written, not yet booted” signal.
Components (update planes that move independently)
Resources that must always ship together belong in the same sysupdate.d/ directory as multiple transfers.
Resources that may move on different cadences get components:
systemd-sysupdate components
systemd-sysupdate --component=addon list
systemd-sysupdate --component=addon update
That switches the search path to /etc/sysupdate.<name>.d/ (and the /run + /usr/lib twins). Do not split root/verity/UKI across components if they must stay version-locked.
Newer systemd also adds optional features, enable-feature / disable-feature, enable-component / disable-component, and cleanup for orphaned filesystem installs. Use them when your image product actually exposes optional payload sets; ignore them for a minimal A/B OS.
Offline image maintenance
# Apply transfers defined inside a disk image, writing partitions in-image
systemd-sysupdate --image=/var/lib/machines/edge.raw update
# Or point at an explicit definitions directory
systemd-sysupdate --definitions=/srv/transfers.d --image=/srv/appliance.img update
This is especially useful for factory-updating nspawn machines, portable service images, or appliance RAW files from a build host.
Timers: download by day, reboot by night
Do not couple “fetch” and “bounce the fleet” into one unit.
Current unit names (check your release; older trees used systemd-sysupdate.service / .timer without the -update infix):
# Pull updates on a schedule
systemctl enable --now systemd-sysupdate-update.timer
# Optionally reboot later when a newer installed version is pending
systemctl enable --now systemd-sysupdate-reboot.timer
systemctl list-timers 'systemd-sysupdate*'
systemctl status systemd-sysupdate-update.service
Why separate?
- Downloads can be frequent and opportunistic.
- Reboots need change windows, draining, and monitoring.
-
pending+ reboot timer gives you “already staged, activate overnight” without re-hitting the network.
Some releases also ship systemd-sysupdate-auto-enable.service to auto-enable suggested components/features before each update. Leave it off unless you explicitly want that product behavior.
Partition prep with systemd-repart
Remember: sysupdate fills slots; it does not invent them.
A minimal repart mental model for two root + two verity slots:
# /etc/repart.d/10-root-a.conf
[Partition]
Type=root
Label=_empty
SizeMinBytes=4G
SizeMaxBytes=4G
# /etc/repart.d/11-root-b.conf
[Partition]
Type=root
Label=_empty
SizeMinBytes=4G
SizeMaxBytes=4G
# /etc/repart.d/20-verity-a.conf
[Partition]
Type=root-verity
Label=_empty
SizeMinBytes=128M
SizeMaxBytes=128M
# /etc/repart.d/21-verity-b.conf
[Partition]
Type=root-verity
Label=_empty
SizeMinBytes=128M
SizeMaxBytes=128M
Exact Type= aliases and verity pairing rules come from the UAPI Discoverable Partitions Specification and systemd-repart(8). Factory images often bake these empty slots at build time; devices can also grow them on first boot from free disk space.
Verification checklist after first staging
# 1) Catalog vs installed
systemd-sysupdate list
# 2) os-release identity the tool uses for pending/protect
grep -E '^(IMAGE_ID|IMAGE_VERSION|VERSION_ID)=' /etc/os-release
# 3) Partition labels / empty slots
lsblk -o NAME,PARTTYPENAME,PARTLABEL,SIZE,FSTYPE
# or
sfdisk -d /dev/disk/by-id/… | grep -E 'label:|type='
# 4) UKIs present on $BOOT
bootctl status
ls -l /efi/EFI/Linux 2>/dev/null || ls -l /boot/EFI/Linux
# 5) After update, before reboot
systemd-sysupdate pending; echo exit:$?
If list sees remote versions but update will not move:
- GPG verify failing (
Verify=yes+ missing key in import-pubring) - no free
_empty/ reclaimable slot (InstancesMax/ partition count) - mismatched
@vsets (root 48 present remotely but verity 48 missing → incomplete version) -
MinVersion=filtering candidates -
ProtectVersion=preventing reclamation of the only spare that still holds the running image
Boundaries: when not to use this
| Tool | Job |
|---|---|
| apt/dnf/zypper | Mutable package OS, rich dependency solves, classic servers |
| ostree / rpm-ostree / bootc | Content-addressed tree deployments with their own client stack |
| systemd-sysupdate | Whole-file / whole-partition A/B transfers driven by simple HTTPS manifests |
| systemd-repart | Create/grow GPT layout and factories—not the updater |
| dm-verity / fs-verity | Integrity of what you already have—not distribution |
| portable services / sysext | Add software onto a base OS—not replacing the base OS image |
| soft-reboot | Userspace restart without firmware/kernel; complementary activation tactic, not an image transporter |
If you are mostly pinning a few packages on Debian stable, stay with APT. If you are building appliances, edge nodes, kiosks, or verified image fleets where “version 48” means a concrete root+verity+UKI tuple, sysupdate is the systemd-native answer.
Safer rollout pattern
- Build images with
IMAGE_ID=/IMAGE_VERSION=baked into/etc/os-release. - Publish payloads +
SHA256SUMS(+.gpg) on HTTPS. - Ensure GPT has ≥2 slots per updated partition type (repart).
- Deploy matching
sysupdate.dtransfers; put UKI/entry-point last alphabetically. -
systemd-sysupdate listandcheck-newon a canary. -
updateon canary →pending→ controlled reboot → health checks. - Enable update timer fleet-wide; keep reboot timer tighter or manual.
- Keep one known-good UKI and partition set around (
InstancesMax=3helps) until the new version proves itself. - Treat the whole stack as experimental in change control: read the man page for your systemd version before copying flags from the internet.
References
-
systemd-sysupdate(8)— commands, timers,--image=, components, pending/reboot https://manpages.debian.org/unstable/systemd-container/systemd-sysupdate.8.en.html https://man7.org/linux/man-pages/man8/systemd-sysupdate.8.html -
sysupdate.d(5)— transfer files, resource types, match patterns,InstancesMax=, verify/keyring https://manpages.debian.org/unstable/systemd-container/sysupdate.d.5.en.html https://man7.org/linux/man-pages/man5/sysupdate.d.5.html - UAPI Discoverable Partitions Specification (root / root-verity GPT types) https://uapi-group.org/specifications/specs/discoverable_partitions_specification/
- UAPI Boot Loader Specification (Type #2 UKI paths under
$BOOT/EFI/Linux) https://uapi-group.org/specifications/specs/boot_loader_specification/ -
systemd-repart(8)— creating empty partition slots for A/B https://manpages.debian.org/unstable/systemd-repart/systemd-repart.8.en.html -
os-release(5)—IMAGE_VERSION=used bypendingand%Ahttps://man.archlinux.org/man/os-release.5.en - GNU
sha256sum(1)—SHA256SUMSmanifest format https://man7.org/linux/man-pages/man1/sha256sum.1.html
Bottom line: stop treating OS updates as a long transaction against the live root. Define versioned transfers, give yourself empty slots, verify the catalog, stage beside the running system, and reboot on purpose. That is systemd-sysupdate—simple HTTPS manifests, A/B (or better) slots, and activation you control.
Top comments (0)