Stop losetup Guesswork: Practical systemd-dissect for Discoverable Disk Images on Linux
You have a disk image. Maybe it came from mkosi, a cloud vendor, a portable service build, a sysext raw, or last night's A/B update pipeline. The old muscle memory is familiar:
losetup -fP ./image.raw
fdisk -l /dev/loopX
mount /dev/loopXp2 /mnt
# ... later, which partitions did I attach again?
That works until the image has LUKS, dm-verity, a multi-partition GPT layout, or architecture-specific root types. Then the one-liner becomes a script, and the script becomes the bug.
systemd-dissect is the systemd-native tool for introspecting and operating on Discoverable Disk Images (DDIs). It understands GPT labels from the UAPI Discoverable Partitions Specification, can mount whole OS trees correctly, copy files in and out, validate image policy, and even plug into classic mount / fstab as mount.ddi.
This guide is operational. Commands and options below are taken from systemd-dissect(1), systemd.image-policy(7), and the DPS.
What a DDI is (and what dissect does)
A Discoverable Disk Image is an OS disk image systemd can reason about automatically. systemd-dissect accepts three shapes:
- GPT image with DPS partition types (root, usr, home, srv, esp, verity, …)
- Plain filesystem image (no partition table — treated as the root FS)
- GPT/MBR with a single partition (that partition is the root FS)
Inside those images you can have normal Linux filesystems, LUKS, and dm-verity data. The same class of images boots with systemd-nspawn --image= and can back RootImage= on services.
When you run systemd-dissect with no command switch, it does not dump every GPT entry like fdisk. It shows the partitions it understands and would operate on: unknown types are skipped, duplicates are ignored, and root/usr partitions for foreign architectures are filtered. That is intentional — dissect reports the OS view, not the full partition editor view.
| Tool | Job |
|---|---|
fdisk / sfdisk
|
Edit/list the raw partition table |
losetup |
Attach a file as a block device |
veritysetup / cryptsetup
|
Manual integrity / encryption setup |
systemd-dissect |
OS-aware inspect, mount, copy, validate, archive |
Prerequisites
# Debian/Ubuntu
sudo apt install systemd-container
# Arch
sudo pacman -S systemd
systemd-dissect --version
# mount.ddi should resolve to the same binary (symlink helper)
command -v mount.ddi || ls -l /usr/bin/mount.ddi /sbin/mount.ddi 2>/dev/null
Most inspect/validate paths need read access to the image. Mount, attach, copy-in, and write paths need privileges (typically root) so loop devices and nested mounts can be set up.
Lab 0 — Build a tiny plain-FS image to practice on
You do not need a full multi-partition DDI to learn the tool. A single ext4 image is enough for mount/list/copy/with flows:
mkdir -p ~/dissect-lab && cd ~/dissect-lab
# 256 MiB sparse file + ext4 (plain FS image = valid DDI shape #2)
truncate -s 256M plain.raw
mkfs.ext4 -L labroot plain.raw
# Put something recognizable inside via a temporary loop mount
sudo mkdir -p /mnt/plain-lab
sudo mount -o loop plain.raw /mnt/plain-lab
sudo mkdir -p /mnt/plain-lab/etc
printf 'ID=lab\nVERSION_ID=1\nPRETTY_NAME="dissect-lab"\n' | sudo tee /mnt/plain-lab/etc/os-release >/dev/null
echo 'hello from plain DDI' | sudo tee /mnt/plain-lab/ROOT.txt >/dev/null
sudo umount /mnt/plain-lab
For a real GPT + DPS image, build with mkosi or systemd-repart (covered in earlier posts). Dissect is happiest when partition type GUIDs match DPS (for example SD_GPT_ROOT_X86_64 = 4f68bce3-e8cd-4db1-96e7-fbcaf984b709 on amd64).
Lab 1 — Inspect before you mount
# Human-readable OS / partition summary
systemd-dissect plain.raw
# Machine-readable
systemd-dissect --json=pretty plain.raw
Expect os-release fields (when present), architecture hints, and the design designations dissect derived. On multi-partition DDIs this is where you confirm "yes, this has root + usr + verity" before you hand the file to nspawn, vmspawn, or a service RootImage=.
Useful companion:
# Full GPT including types dissect ignores — use when debugging image builds
sudo fdisk -l plain.raw
Lab 2 — Mount and unmount the OS view
sudo mkdir -p /mnt/ddi-plain
# Create target if missing (-M == --mount --mkdir)
sudo systemd-dissect -M plain.raw /mnt/ddi-plain
# Read-only mount
sudo systemd-dissect --mount --read-only plain.raw /mnt/ddi-plain
ls /mnt/ddi-plain
cat /mnt/ddi-plain/ROOT.txt
cat /mnt/ddi-plain/etc/os-release
# Recursive unmount + remove empty dir (-U == --umount --rmdir)
sudo systemd-dissect -U /mnt/ddi-plain
What mount does for you (from the man page):
- Dissects the image and mounts the root (and nested DPS mounts such as
/usr,/home, … when present) - Sets up LUKS and Verity automatically when the image carries them, and tears them down on unmount
- Runs fsck in automatic-fix mode on writable access unless you pass
--fsck=no - Honors GPT growfs bit 59 by growing the filesystem to the partition size unless
--growfs=no
Disable grow/fsck when you want a surgical inspection:
sudo systemd-dissect --mount --fsck=no --growfs=no --read-only \
./image.raw /mnt/ddi
mount.ddi and fstab
systemd-dissect can be invoked as mount.ddi, implementing mount(8)'s external helper for type ddi:
sudo mkdir -p /mnt/ddi-plain
sudo mount -t ddi plain.raw /mnt/ddi-plain
# Multi-FS DDIs need recursive unmount
sudo umount -R /mnt/ddi-plain
Boot-time / persistent mount via fstab:
/path/to/myimage.raw /images/myimage/ ddi defaults 0 0
Mapped mount options from the man page:
| fstab / mount option | dissect equivalent |
|---|---|
ro |
--read-only |
rw |
writable (default for --mount) |
discard |
--discard=all |
nodiscard |
--discard=disabled |
Those options apply to how the image is attached; they are not generically passed through to every inner filesystem.
Lab 3 — Run a command inside a temporary mount (--with)
This is the cleanest one-shot pattern — mount, cwd into the tree, run a command, always unmount:
# Interactive shell in the image
sudo systemd-dissect --with --read-only plain.raw
# One command (man page tarball example)
sudo systemd-dissect --with plain.raw tar cz . > plain-from-with.tar.gz
# Environment the child sees (documented):
# $SYSTEMD_DISSECT_ROOT — absolute temp mount path
# $SYSTEMD_DISSECT_DEVICE — loop device path
sudo systemd-dissect --with --read-only plain.raw \
sh -c 'echo root=$SYSTEMD_DISSECT_ROOT; echo dev=$SYSTEMD_DISSECT_DEVICE; ls -la'
Exit status of --with is the exit status of the child command.
Writable by default; add --read-only for inspection. Prefer --in-memory when you need a writable scratch view of a read-only golden image without dirtying it:
sudo systemd-dissect --with --in-memory plain.raw \
sh -c 'echo scratch > ./SCRATCH.txt; cat ./SCRATCH.txt'
# original plain.raw unchanged
Lab 4 — List files and mtree manifests
# All paths in the image
systemd-dissect --list plain.raw | head
# BSD mtree-compatible manifest with SHA256 content digests
# (timestamps/nlink/ino intentionally omitted for reproducibility)
systemd-dissect --mtree plain.raw | head
# Faster mtree without content hashes on large images
systemd-dissect --mtree --mtree-hash=no ./big.raw | head
--mtree is excellent for CI diffing of image builds. The man page notes current limitations: no xattrs, capabilities, MAC labels, chattr flags, or btrfs subvolume metadata in the manifest.
Lab 5 — Copy files in and out without a long-lived mount
# Host ← image
systemd-dissect --copy-from plain.raw /ROOT.txt ./ROOT-from-image.txt
# stdout:
systemd-dissect --copy-from plain.raw /etc/os-release -
# Host → image (writable; fsck runs first by default)
echo 'injected' > ./inject.txt
sudo systemd-dissect --copy-to plain.raw ./inject.txt /opt/inject.txt
# Verify
systemd-dissect --copy-from plain.raw /opt/inject.txt -
Rules of thumb from the docs:
- Regular files copy mode/xattrs/timestamps; ownership is not copied for single files
- Directories copy recursively and include ownership
- Source
-reads stdin (--copy-to); destination-writes stdout (--copy-from)
Lab 6 — Attach as a loop device (when you still need block tools)
Sometimes you want cfdisk, btrfs inspect-internal, or another block-level tool. Prefer dissect's attach over bare losetup so sector size and partition nodes are correct:
# Prints the loop path, creates partition sub-nodes before returning
LOOP=$(sudo systemd-dissect --attach --loop-ref=labplain plain.raw)
echo "$LOOP"
ls -l /dev/disk/by-loop-ref/labplain
# Use the stable by-loop-ref name
sudo cfdisk /dev/disk/by-loop-ref/labplain
# Detach by loop path or by backing image path
sudo systemd-dissect --detach "$LOOP"
# or: sudo systemd-dissect --detach plain.raw
--loop-ref= sets the kernel .lo_file_name field (up to 63 chars) used for /dev/disk/by-loop-ref/.... That is distinct from sysfs backing_file, which is mount-namespace translated.
Lab 7 — Validate image policy before you trust an image
Image policy is a colon-separated string of partition=flags rules. Flags include unprotected, verity, signed, encrypted, unused, absent, plus GPT flag requirements like read-only-on / growfs-off. Shortcuts:
| Policy | Meaning |
|---|---|
* |
use everything recognized (common default) |
- |
use nothing (unused+absent) |
~ |
everything must be absent |
# Syntax/meaning helper
systemd-analyze image-policy 'usr=verity+read-only-on:root=encrypted:swap=encrypted'
# Validate arrangement + policy without mounting (unprivileged if the file is readable)
systemd-dissect --validate plain.raw
systemd-dissect --validate --image-policy='root=unprotected' plain.raw
# Refuse unprotected roots in an automation gate
systemd-dissect --validate --image-policy='root=verity+signed' ./golden.raw && echo OK
--validate parses the partition table and probes filesystems but does not mount or set up LUKS/Verity. It prints OK and exits 0 when the image matches policy.
Example policies from systemd.image-policy(7):
# Read-only Verity /usr, encrypted root+swap; ignore the rest
usr=verity+read-only-on:root=encrypted:swap=encrypted
# Encrypted writable root; /srv encrypted if present; no swap
root=encrypted+read-only-off:srv=encrypted+absent:swap=absent
Lab 8 — Discover images on the system and archive them
# Machines, portables, sysext/confext locations systemd knows about
systemd-dissect --discover
# Archive the filesystem view (format from output suffix; libarchive-backed)
sudo systemd-dissect --make-archive plain.raw plain.tar.gz
# stdout tarball (uncompressed when path omitted):
sudo systemd-dissect --make-archive plain.raw - > plain.tar
--discover walks the usual trees: /usr/lib/machines/, /var/lib/machines/, /usr/lib/portables/, /var/lib/portables/, /usr/lib/extensions/, /var/lib/extensions/, confext paths, and friends.
Verity and encrypted images
When the DDI embeds Verity per DPS, mount/attach paths set it up for you. For detached metadata:
sudo systemd-dissect --mount \
--root-hash=HEX_ROOT_HASH \
--root-hash-sig=./roothash.p7s \
--verity-data=./image.verity \
./image.raw /mnt/ddi
Prefer embedding Verity in the GPT image (root + root-verity + optional signature partitions) so tools do not need extra flags. That is the same model used by systemd-repart image builds and image-based update flows.
Discard policy for thin/encrypted backends:
# disabled | loop | all | crypto
sudo systemd-dissect --mount --discard=loop ./image.raw /mnt/ddi
Wire-up: nspawn, services, and friends
Once dissect is happy with an image, the rest of the systemd image ecosystem lights up:
# Namespace OS container from the same DDI
sudo systemd-nspawn --image=./image.raw
# Service with image root (unit fragment)
# RootImage=/var/lib/machines/app.raw
# RootImagePolicy=root=verity+signed+encrypted+unprotected+absent:...
RootImage=, MountImage=, and ExtensionImage= all take related image-policy settings (RootImagePolicy= and friends in systemd.exec(5)). Dissect is how you debug those images before a unit fails at boot.
A practical CI-shaped workflow
#!/usr/bin/env bash
set -euo pipefail
IMG=${1:?image path}
# 1. Policy gate (no mount, minimal privilege)
systemd-dissect --validate --image-policy='root=unprotected+verity+signed+encrypted' "$IMG"
# 2. Show what systemd will actually use
systemd-dissect --json=short "$IMG" >/tmp/ddi-meta.json
# 3. Content fingerprint for regression
systemd-dissect --mtree --mtree-hash=no "$IMG" | sha256sum
# 4. Smoke: read os-release without leaving mounts behind
sudo systemd-dissect --with --read-only "$IMG" cat etc/os-release
Boundaries — pick the right tool
| Need | Prefer |
|---|---|
| Inspect / mount / copy / validate a DDI | systemd-dissect |
| Boot the image as a container | systemd-nspawn --image= |
| Boot the image as a full VM | systemd-vmspawn --image= |
| Attach a portable service image | portablectl |
Merge /usr add-ons at runtime |
systemd-sysext / confext
|
| Build/grow GPT layouts | systemd-repart |
| Low-level Verity format/open | veritysetup |
| Raw partition editing |
sfdisk / sgdisk
|
Dissect does not replace your image builder. It replaces the fragile glue between "image file on disk" and "filesystem tree I can trust and use."
Troubleshooting checklist
-
Empty or sparse inspect output — foreign-arch root type, unknown GPT types, or not a filesystem systemd can probe. Confirm with
fdisk -land rebuild with DPS types. - Permission denied on mount/attach — need root (or equivalent) for loop + mount namespace operations.
-
fsck delays on every write open — expected; use
--fsck=nofor throwaway labs. -
Image grew unexpectedly — GPT growfs flag (bit 59); disable with
--growfs=no. -
leftover loop devices — always
--umount/--detach; formount -t ddi, useumount -R. -
Policy validate fails on a "fine" image — your policy may require
verity/signedwhile the lab image isunprotected. Start from*and tighten. -
--withleft nothing mounted but command failed — check the child exit status; dissect propagates it.
Wrap-up
If you already live in the systemd image world — nspawn, vmspawn, portable services, sysext, repart, sysupdate — systemd-dissect is the missing inspection plane.
Day-to-day defaults:
# What is this file?
systemd-dissect ./image.raw
# Safe look around
sudo systemd-dissect --with --read-only ./image.raw
# Policy gate
systemd-dissect --validate --image-policy='root=unprotected+verity+signed' ./image.raw
# Persistent host mount
# /var/lib/images/app.raw /srv/app-image ddi ro 0 0
Stop writing losetup scaffolding for every OS image. Let dissect own the loop, the nested mounts, and the teardown.
References
- systemd-dissect(1) — Debian man page (commands, options, mount.ddi, examples)
- systemd.image-policy(7) — dissection policy language
-
systemd.exec(5) —
RootImage=, image policy unit settings - UAPI.2 Discoverable Partitions Specification — GPT type UUIDs and auto-mount rules
- systemd-nspawn(1) — boot DDIs as containers
- systemd-vmspawn(1) — boot DDIs as VMs
-
systemd-analyze(1) —
image-policyhelper - Related posts on this blog: systemd-vmspawn, systemd-nspawn, sysext/confext, portablectl, systemd-repart, dm-verity
Top comments (0)