Stop Assembling Boot Pieces by Hand: Practical Unified Kernel Images with ukify on Linux
Most Linux boots still treat the kernel, initrd, command line, and splash as separate moving parts. That works — until you want one signed PE binary the firmware or boot loader can trust as a whole.
A Unified Kernel Image (UKI) packs those pieces into a single UEFI PE/COFF application. ukify is the recommended builder: it wraps a kernel and initrd with systemd-stub, embeds metadata sections, can Secure Boot-sign the result, and can pre-calculate TPM2 PCR 11 policies via systemd-measure.
This guide is the builder-side companion to boot-manager work with bootctl. Here the focus is constructing Type #2 UKIs correctly, inspecting them, signing them, and wiring kernel-install so package updates keep producing them.
What a UKI actually is
Per the UAPI.5 Unified Kernel Image specification, a UKI is a PE/COFF UEFI application (IMAGE_SUBSYSTEM_EFI_APPLICATION) that embeds:
| PE section | Role |
|---|---|
stub (.text/… ) |
systemd-stub UEFI entry point |
.linux |
ELF kernel image (required) |
.osrel |
os-release contents for menus/versioning |
.cmdline |
Kernel command line |
.initrd |
Initramfs |
.ucode |
Microcode initrd (uncompressed; first) |
.splash |
BMP splash |
.dtb / .dtbauto
|
DeviceTree (fixed or auto-matched) |
.hwids |
Hardware ID table for auto DTB/firmware match |
.uname |
uname -r string |
.sbat |
Shim SBAT revocation metadata |
.pcrsig |
Signed expected TPM2 PCR 11 values (JSON) |
.pcrpkey |
PEM public key matching those signatures |
systemd-stub loads the embedded sections, measures most of them into TPM PCR 11, optionally collects companion files next to the UKI, then boots the kernel. Because Secure Boot signs the PE as a whole, kernel + initrd + cmdline travel under one trust decision.
You can assemble this with objcopy. Don't. Section alignment, SBAT merges, Secure Boot signing, and PCR measurement are easy to get subtly wrong. Use ukify.
Install the builder
On Debian 13 / Ubuntu with systemd 257-era packages:
sudo apt update
sudo apt install systemd-ukify systemd-boot-efi sbsigntool
# Optional but useful
sudo apt install systemd-boot binutils # bootctl + objdump helpers
systemd-ukify pulls in python3-pefile (and friends). The EFI stub lives under:
ls /usr/lib/systemd/boot/efi/
# linuxx64.efi.stub linuxia32.efi.stub linuxaa64.efi.stub …
Confirm the CLI:
ukify --version
ukify --help | head
Verbs you will use:
-
ukify build— assemble a UKI (or addon) -
ukify genkey— create Secure Boot + PCR key material from config -
ukify inspect— dump sections, sizes, digests, text payloads
Lab: build a minimal unsigned UKI
Work in a throwaway directory. Paths below match a typical Debian layout; adjust KERNEL_VER to your running kernel.
KERNEL_VER="$(uname -r)"
WORKDIR="$HOME/uki-lab"
mkdir -p "$WORKDIR" && cd "$WORKDIR"
# Kernel image (Debian/Ubuntu usually ship vmlinuz under /boot as well)
LINUX="/lib/modules/${KERNEL_VER}/vmlinuz"
# Fallback if your distro only keeps it in /boot:
[[ -f "$LINUX" ]] || LINUX="/boot/vmlinuz-${KERNEL_VER}"
# Existing initrd from the package/initramfs tools
INITRD="/boot/initrd.img-${KERNEL_VER}"
# Some systems use initramfs-*.img — pick what exists:
[[ -f "$INITRD" ]] || INITRD="/boot/initramfs-${KERNEL_VER}.img"
test -f "$LINUX" && test -f "$INITRD"
ukify build \
--linux="$LINUX" \
--initrd="$INITRD" \
--cmdline='quiet rw' \
--os-release=@"/etc/os-release" \
--uname="$KERNEL_VER" \
--output="${WORKDIR}/minimal.unsigned.efi"
If you omit --output=, ukify names the file after the kernel with a .unsigned.efi or .signed.efi suffix depending on whether Secure Boot signing ran.
Inspect what you built
ukify inspect minimal.unsigned.efi
You should see well-known sections (at least .linux, and whatever you embedded). Dig into one section:
# Print .cmdline as text
ukify inspect minimal.unsigned.efi --section=.cmdline:text
# JSON summary (ukify ≥255)
ukify inspect minimal.unsigned.efi --json=pretty | head -n 80
# PE headers via llvm/binutils if installed
llvm-objdump -p minimal.unsigned.efi 2>/dev/null | head
# or:
objdump -p minimal.unsigned.efi | head
bootctl and kernel-install recognize UKIs partly via .osrel and PE layout — leaving .osrel empty is allowed by ukify but not recommended, because other tools may stop treating the artifact as a UKI.
Config file workflow (preferred for hosts)
Command-line flags are fine for labs. On real machines, put policy in a config file. ukify loads the first of:
-
--config=PATH(explicit) /etc/systemd/ukify.conf/run/systemd/ukify.conf/usr/local/lib/systemd/ukify.conf/usr/lib/systemd/ukify.conf
kernel-install's ukify plugin path conventionally uses /etc/kernel/uki.conf. Example:
sudo tee /etc/kernel/uki.conf >/dev/null <<'EOF'
[UKI]
Cmdline=quiet rw
OSRelease=@/etc/os-release
# SecureBootPrivateKey=/etc/kernel/secure-boot-key.pem
# SecureBootCertificate=/etc/kernel/secure-boot-certificate.pem
# SignKernel=yes
# PCRBanks=sha256
# [PCRSignature:initrd]
# Phases=enter-initrd
# PCRPrivateKey=/etc/systemd/tpm2-pcr-private-key-initrd.pem
# PCRPublicKey=/etc/systemd/tpm2-pcr-public-key-initrd.pem
# [PCRSignature:system]
# Phases=enter-initrd:leave-initrd enter-initrd:leave-initrd:sysinit enter-initrd:leave-initrd:sysinit:ready
# PCRPrivateKey=/etc/systemd/tpm2-pcr-private-key-system.pem
# PCRPublicKey=/etc/systemd/tpm2-pcr-public-key-system.pem
EOF
Build with that policy while still passing the kernel/initrd on the CLI (they change every upgrade):
ukify build \
--config=/etc/kernel/uki.conf \
--linux="$LINUX" \
--initrd="$INITRD" \
--uname="$KERNEL_VER" \
--output="${WORKDIR}/host-style.unsigned.efi"
--summary is excellent for debugging merge order (config vs CLI):
ukify --config=/etc/kernel/uki.conf --summary build \
--linux="$LINUX" --initrd="$INITRD"
Secure Boot signing with ukify genkey
If the target machine verifies PE signatures (or you enroll your own keys with sbctl / firmware Setup Mode), sign the UKI as a whole.
1. Decide key paths in config
sudo tee /etc/kernel/uki.conf >/dev/null <<'EOF'
[UKI]
Cmdline=quiet rw
OSRelease=@/etc/os-release
SecureBootPrivateKey=/etc/kernel/secure-boot-key.pem
SecureBootCertificate=/etc/kernel/secure-boot-certificate.pem
SignKernel=yes
SecureBootCertificateValidity=3650
EOF
2. Generate keys once
# Files must not already exist
sudo ukify genkey --config=/etc/kernel/uki.conf
sudo chmod 600 /etc/kernel/secure-boot-key.pem
sudo chmod 644 /etc/kernel/secure-boot-certificate.pem
genkey writes whatever private/public material the config declares (Secure Boot cert/key and any PCR key pairs). Certificate validity defaults to 3650 days (10 years) unless you override SecureBootCertificateValidity=.
3. Build a signed UKI
ukify build \
--config=/etc/kernel/uki.conf \
--linux="$LINUX" \
--initrd="$INITRD" \
--uname="$KERNEL_VER" \
--output="${WORKDIR}/minimal.signed.efi"
Signing tool selection (SecureBootSigningTool= / --signtool=):
-
sbsign(default) — needssbsigntool -
pesign— NSS cert DB (SecureBootCertificateDir=,SecureBootCertificateName=) -
systemd-sbsign— supports OpenSSL providers
Boundary vs sbctl: sbctl owns platform key enrollment (PK/KEK/db) and re-signing hooks for arbitrary EFI binaries. ukify owns UKI assembly and can call a signtool for the resulting PE. Use both: enroll with sbctl, build/sign UKIs with ukify (or sign the ukify output with sbctl sign if that is your house style).
With Secure Boot enabled, if a .cmdline section is present, firmware/stub ignores attempt to override the command line at invocation time. That is intentional: the signed cmdline is part of the trusted image. Want local overrides? Omit .cmdline from the UKI, or ship signed addons (below).
PCR 11 measurement and signed policies
systemd-stub measures UKI sections into PCR 11 (the .pcrsig section itself is excluded so signatures are not circular). systemd-measure pre-calculates those values the same way the stub will, optionally signs them, and ukify embeds the JSON into .pcrsig.
Typical goals:
- Unlock LUKS only for kernels you signed (
systemd-cryptenrollTPM2 policies) - Unlock
systemd-credsencrypted credentials only for known UKIs - Bind secrets to boot phases (initrd vs multi-user)
Default phase paths used by systemd-measure when you do not pass --phases= / Phases=:
enter-initrdenter-initrd:leave-initrdenter-initrd:leave-initrd:sysinitenter-initrd:leave-initrd:sysinit:ready
Example: separate keys for initrd-only secrets vs runtime secrets (from the ukify manual’s “bells and whistles” pattern):
ukify build \
--linux="$LINUX" \
--initrd="$INITRD" \
--cmdline='quiet rw' \
--pcr-private-key=tpm2-pcr-private-key-initrd.pem \
--pcr-public-key=tpm2-pcr-public-key-initrd.pem \
--phases='enter-initrd' \
--pcr-private-key=tpm2-pcr-private-key-system.pem \
--pcr-public-key=tpm2-pcr-public-key-system.pem \
--phases='enter-initrd:leave-initrd enter-initrd:leave-initrd:sysinit enter-initrd:leave-initrd:sysinit:ready' \
--pcr-banks=sha256 \
--output="${WORKDIR}/measured.efi"
On the command line, each --pcr-private-key= pairs with the matching --phases= in order. In config files, group them under [PCRSignature:NAME] sections instead.
Useful related flags:
# Print calculated PCR values while building
ukify build ... --measure --output=out.efi
# Compare expectation vs the live TPM after boot (on systems with a TPM)
sudo /usr/lib/systemd/systemd-measure status
After boot, stub-provided signature material is commonly exposed under /run/systemd/tpm2-pcr-signature.json and /run/systemd/tpm2-pcr-public-key.pem (via a synthetic initrd + tmpfiles). systemd-cryptsetup, systemd-cryptenroll, and systemd-creds look there automatically.
Note: systemd-measure is still marked experimental in its man page; prefer going through ukify rather than hand-rolling JSON into PE sections.
Microcode and multiple initrds
Initrd= / --initrd= may be repeated. ukify concatenates them into one .initrd section in order. Microcode can also use the dedicated .ucode section (Microcode= / --microcode=), which the stub hands to the kernel before other initrds and which must be uncompressed.
ukify build \
--linux="$LINUX" \
--microcode=/boot/intel-ucode.img \
--initrd="$INITRD" \
--cmdline='quiet rw' \
--output="${WORKDIR}/with-ucode.efi"
Or early+late initrds without .ucode:
ukify build \
--linux="$LINUX" \
--initrd=/boot/early_cpio \
--initrd="$INITRD" \
--cmdline='quiet rw' \
--output="${WORKDIR}/concat-initrd.efi"
Command-line PE addons
Sometimes you want a signed extra cmdline (or other UKI-like auxiliary PE) without rebuilding the base UKI. systemd-stub loads *.addon.efi companions from:
-
ESP/.../foo.efi.extra.d/*.addon.efinext tofoo.efi -
ESP/loader/addons/*.addon.efiglobally
Build one:
ukify build \
--cmdline='debug systemd.log_level=debug' \
--sbat='sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md
uki-addon.example,1,Example addon,uki-addon.example,1,https://example.local' \
--output="${WORKDIR}/debug.addon.efi"
# Optionally sign with the same Secure Boot key as the UKI:
# --secureboot-private-key=... --secureboot-certificate=...
Addons are PE binaries that are not full bootable UKIs; they carry auxiliary sections the stub merges at boot. Sign them if Secure Boot is on, or the stub will ignore untrusted companions on locked platforms.
Multi-profile UKIs (quick tour)
Since systemd 257, one PE can carry multiple profiles separated by .profile sections. Build profile fragments, then join them:
ukify build --profile=$'TITLE=Base\nID=base' --output=profile0.efi
ukify build \
--profile=$'TITLE=Storage Target Mode\nID=storagetm' \
--cmdline='quiet rw rd.systemd.unit=storage-target-mode.target' \
--output=profile1.efi
ukify build \
--linux="$LINUX" \
--initrd="$INITRD" \
--cmdline='quiet rw' \
--join-profile=profile0.efi \
--join-profile=profile1.efi \
--output="${WORKDIR}/multi-profile.efi"
Boot loaders that understand multi-profile UKIs can present each TITLE= / ID= as a selectable entry without storing multiple full kernels. PCR measurement covers the selected profile (plus base sections not overridden).
Install with kernel-install layout=uki
Manual cp to the ESP works for experiments. For upgrades, teach kernel-install to install Type #2 UKIs under $BOOT/EFI/Linux/.
Inspect current detection:
sudo kernel-install inspect
# Look for KERNEL_INSTALL_LAYOUT, BOOT, ENTRY_TOKEN, machine-id, etc.
Configure layout (and optional generators) in /etc/kernel/install.conf:
sudo tee /etc/kernel/install.conf >/dev/null <<'EOF'
layout=uki
# uki_generator=ukify
# initrd_generator=dracut # or mkinitcpio / the distro default
EOF
What the stock plugins do:
| Plugin | layout | Behavior on add
|
|---|---|---|
90-loaderentry.install |
bls |
Type #1 entry under $BOOT/loader/entries/ + linux/initrd files |
90-uki-copy.install |
uki |
Copies staged uki.efi (or a .efi kernel argument) to $BOOT/EFI/Linux/ENTRY-TOKEN-KERNEL-VERSION.efi
|
With layout=uki, after your UKI generator stages $KERNEL_INSTALL_STAGING_AREA/uki.efi, 90-uki-copy.install places:
$BOOT/EFI/Linux/<entry-token>-<kernel-version>.efi
$BOOT is discovered as the first among /efi/, /boot/, /boot/efi/ that already looks like a BLS tree. Prefer mounting the ESP on /efi when you can.
Install one kernel version explicitly:
# After building/staging a UKI for this version — distro plugins vary
sudo kernel-install add "$KERNEL_VER" "$LINUX" "$INITRD"
# Or list / remove
kernel-install list
sudo kernel-install remove "$KERNEL_VER"
bootctl list should then show the Type #2 entry. Selecting it boots the PE directly; no separate initrd path in a Type #1 conf file.
Practical packaging note: On Debian, systemd-ukify provides the builder; your image/initrd generator plugin must actually call ukify and leave uki.efi in the staging area. If uki_generator=ukify is not wired on your distro version, keep a small /etc/kernel/install.d/60-ukify.install that builds into "$KERNEL_INSTALL_STAGING_AREA/uki.efi" using /etc/kernel/uki.conf, then let 90-uki-copy.install finish the job. Return 0 on success; return 77 only if you intend to abort the entire kernel-install run.
End-to-end checklist
-
Install
systemd-ukify, EFI stub package, and a signtool if you need Secure Boot PE signatures. -
Write
/etc/kernel/uki.confwith cmdline, os-release, and optional SB/PCR keys. -
ukify genkey --config=...once; lock down private key modes (0600). -
Build a lab UKI;
ukify inspectsections and digests. -
Enroll the Secure Boot certificate into the platform db (via
sbctlor firmware) if verification is enabled. -
Set
layout=ukiin/etc/kernel/install.confand verify withkernel-install inspect. -
Reinstall a kernel package or run
kernel-install addand confirm$BOOT/EFI/Linux/*.efi. -
bootctl list/ reboot into the UKI; on TPM hosts comparesystemd-measure statuswith build-time--measureoutput. - Only then enroll LUKS/creds policies against the PCR public key.
Operational pitfalls
-
Missing stub package: ukify needs
linuxx64.efi.stub(or arch equivalent) fromsystemd-boot-efi/ distro equivalent. - Wrong initrd path after upgrade: always take initrd from the same kernel version you embed; UKIs freeze that pairing on purpose.
-
Secure Boot + embedded cmdline: local
BootNextcmdline overrides are ignored; use addons or rebuild. -
PCR bank mismatch: if the OS policy disables SHA-1 signatures, restrict
PCRBanks=/--pcr-banks=tosha256(and whatever your TPM actually supports). -
Empty
.osrel: other tools may not classify the PE as a UKI. - Signing keys on the build host: treat PCR and Secure Boot private keys like production CA material; split initrd vs runtime PCR keys if unlock policies differ by phase.
-
Companion files:
.cred/.sysext.raw/.confext.rawnext to the UKI are measured into PCR 12 and only accepted when authentic under Secure Boot policy — do not treat the ESP drop-in folder as a free-form unsigned config dump on locked systems.
What this is not
| Topic | Covered elsewhere / out of scope |
|---|---|
bootctl install, loader.conf, BLS entry lifecycle |
Boot manager ops (Type #1 vs consuming Type #2) |
sbctl PK/KEK/db enrollment |
Platform Secure Boot key ownership |
systemd-sysupdate A/B image pulls |
Shipping whole OS slots that may include UKIs |
systemd-cryptenroll TPM2 unlock UX |
Consuming .pcrsig after the UKI exists |
GRUB linux/initrd snippets |
Non-UKI boot path |
| Distro-custom signed kernel packages | Vendor-signed vmlinuz without a UKI wrapper |
Wrap-up
UKIs turn “kernel + initrd + cmdline + metadata” into one PE you can sign, measure, and install like any other EFI binary. ukify is the practical assembly line: build for images, genkey for key material, inspect for verification, with systemd-measure handling PCR 11 policy blobs and kernel-install layout=uki dropping results into $BOOT/EFI/Linux/.
Start unsigned in a lab directory, inspect every section, then add Secure Boot signing and PCR signatures once the shape is right. After that, every kernel upgrade can produce the same artifact shape instead of another pile of loosely coupled boot files.
Sources and references
- ukify(1) — Debian trixie man page
- ukify(1) — man7
- systemd-stub(7)
- systemd-measure(1)
- kernel-install(8)
- UAPI.5 Unified Kernel Image specification
- UAPI Boot Loader Specification (Type #1 text entries vs Type #2 UKIs)
- Shim SBAT documentation
- Debian package metadata:
systemd-ukify257.x (apt-cache show systemd-ukify)
Top comments (0)