Building a Custom Minimal Linux Distribution¶
You build OCI images with custom bash frameworks. This article extends that skill to full OS images -- exactly what Warmwind ships to every kiosk agent. We walk through mmdebstrap rootfs creation, kernel tuning, systemd init for a kiosk target, read-only rootfs with OverlayFS, dm-verity integrity, A/B updates with RAUC, the Docker-native bootc path, and a complete pipeline script that chains it all together.
Build Pipeline Overview¶
graph LR
MMD["mmdebstrap<br/>minbase rootfs"] --> PKG["Install<br/>sway, wayvnc,<br/>chromium"]
PKG --> SYS["Configure<br/>systemd targets"]
SYS --> SQF["mksquashfs<br/>rootfs.squashfs"]
SQF --> VER["veritysetup<br/>hash tree"]
VER --> IMG["Assemble<br/>GPT disk image"]
IMG --> RAUC["RAUC bundle<br/>for OTA"]
1. mmdebstrap from Zero¶
mmdebstrap is a single-pass Debian rootfs builder that resolves all dependencies up front using apt, supports multiple mirrors, and runs without root via user namespaces. It replaces debootstrap for reproducible image builds.
Variant Selection¶
| Variant | What it installs | Typical size | Use case |
|---|---|---|---|
extract |
Nothing -- packages extracted, not configured | ~25 MB | Raw layer for OCI base images |
essential |
Essential:yes packages + deps |
~80 MB | Minimal container base |
minbase |
Essential + Priority:required |
~150 MB | Smallest bootable system |
buildd |
minbase + apt + build-essential | ~300 MB | Build environments, CI |
important |
required + Priority:important (default) |
~400 MB | General-purpose installs |
standard |
important + Priority:standard |
~600 MB | Full interactive system |
For a kiosk image, minbase is the right starting point -- it gives you dpkg,
apt, coreutils, and a working package manager, but nothing you don't need.
Hook Scripts¶
Hooks execute at four stages. They receive the chroot path as $1:
# --setup-hook: runs before any packages are installed
# Use for: adding apt sources, injecting keys, pre-creating directories
mmdebstrap --variant=minbase \
--setup-hook='mkdir -p "$1/etc/apt/sources.list.d"' \
--setup-hook='echo "deb http://deb.debian.org/debian bookworm-backports main" \
> "$1/etc/apt/sources.list.d/backports.list"' \
# --essential-hook: runs after Essential:yes installed, before remaining
# Use for: diverting files, adding dpkg overrides
--essential-hook='echo "path-exclude=/usr/share/doc/*" \
> "$1/etc/dpkg/dpkg.cfg.d/nodoc"' \
# --customize-hook: runs after all packages installed, before cleanup
# Use for: enabling services, writing config, removing cruft
--customize-hook='systemctl --root="$1" enable systemd-networkd' \
--customize-hook='rm -rf "$1/var/cache/apt/archives"/*.deb' \
bookworm rootfs/
Special hook operations for file transfer:
# Copy a local file tree into the chroot
--customize-hook='copy-in ./overlay/etc /etc'
# Extract an artifact from the chroot to the host
--customize-hook='copy-out /etc/apt/sources.list ./build-artifacts/'
# Tar a directory from inside the chroot
--customize-hook='tar-out /var/lib/dpkg/info ./dpkg-info.tar'
Output Formats¶
# Directory (default if target has no extension)
mmdebstrap --variant=minbase bookworm ./rootfs/
# Tarball
mmdebstrap --variant=minbase bookworm rootfs.tar
# SquashFS (zstd compressed, directly usable as read-only rootfs)
mmdebstrap --variant=minbase bookworm rootfs.squashfs
# ext4 filesystem image
mmdebstrap --variant=minbase bookworm rootfs.ext4
Working Script: Minimal Bootable Rootfs¶
#!/usr/bin/env bash
set -euo pipefail
SUITE=bookworm
MIRROR=http://deb.debian.org/debian
ROOTFS=./rootfs
PACKAGES=(
systemd systemd-sysv dbus
linux-image-amd64 linux-headers-amd64
grub-efi-amd64 grub-common
iproute2 openssh-server
)
mmdebstrap --variant=minbase \
--include="$(IFS=,; echo "${PACKAGES[*]}")" \
--customize-hook='echo "kiosk" > "$1/etc/hostname"' \
--customize-hook='echo "root:warmwind" | chroot "$1" chpasswd' \
--customize-hook='systemctl --root="$1" enable systemd-networkd' \
--customize-hook='systemctl --root="$1" enable systemd-resolved' \
--customize-hook='systemctl --root="$1" enable ssh' \
--customize-hook='cat > "$1/etc/systemd/network/20-wired.network" <<NETEOF
[Match]
Name=en*
[Network]
DHCP=yes
NETEOF' \
"${SUITE}" "${ROOTFS}" "${MIRROR}"
echo "Rootfs at ${ROOTFS} -- $(du -sh "${ROOTFS}" | cut -f1)"
2. Kernel Selection and Configuration¶
A kiosk OS does not need 6000+ compiled modules. You choose between two strategies: use Debian's stock kernel and strip it at install, or compile a custom minimal kernel.
Stock Kernel with Module Pruning¶
The fastest path. Install linux-image-amd64, then remove modules you will
never load:
--customize-hook='
# Remove wireless, bluetooth, sound (kiosk uses GPU + network only)
rm -rf "$1"/lib/modules/*/kernel/drivers/net/wireless
rm -rf "$1"/lib/modules/*/kernel/drivers/bluetooth
rm -rf "$1"/lib/modules/*/kernel/sound
rm -rf "$1"/lib/modules/*/kernel/drivers/media
# Rebuild module dependency index
chroot "$1" depmod -a "$(ls "$1/lib/modules/")"
'
Custom Kernel: CONFIG_ Options That Matter¶
Start from make tinyconfig or make defconfig, then enable what you need:
# === Required for any bootable system ===
CONFIG_64BIT=y
CONFIG_SMP=y
CONFIG_MODULES=y
CONFIG_PRINTK=y
CONFIG_BLK_DEV_INITRD=y
# === Filesystem ===
CONFIG_EXT4_FS=y
CONFIG_SQUASHFS=y
CONFIG_SQUASHFS_ZSTD=y
CONFIG_OVERLAY_FS=y
# === Block / Device-Mapper (dm-verity needs these) ===
CONFIG_BLK_DEV_DM=y
CONFIG_DM_VERITY=y
CONFIG_DM_VERITY_VERIFY_ROOTHASH_SIG=y # optional: kernel-level sig check
# === Container / cgroup v2 support ===
CONFIG_CGROUPS=y
CONFIG_CGROUP_V2=y # unified hierarchy
CONFIG_CGROUP_BPF=y
CONFIG_MEMCG=y
CONFIG_CGROUP_SCHED=y
CONFIG_CGROUP_PIDS=y
CONFIG_CGROUP_FREEZER=y
CONFIG_NAMESPACES=y
CONFIG_USER_NS=y
CONFIG_PID_NS=y
CONFIG_NET_NS=y
# === Networking (minimal) ===
CONFIG_NET=y
CONFIG_INET=y
CONFIG_NETFILTER=y # for container networking
CONFIG_VETH=y
CONFIG_BRIDGE=y
# === GPU (for Sway / Wayland compositing) ===
CONFIG_DRM=y
CONFIG_DRM_KMS_HELPER=y
# Pick ONE driver for your target hardware:
CONFIG_DRM_I915=m # Intel
CONFIG_DRM_AMDGPU=m # AMD
CONFIG_DRM_NOUVEAU=m # NVIDIA (open)
# === Low-latency I/O (Warmwind tuning) ===
CONFIG_PREEMPT=y # full kernel preemption
CONFIG_HZ_1000=y # 1ms tick granularity
CONFIG_NO_HZ_FULL=y # tickless for isolated CPUs
CONFIG_BLK_DEV_THROTTLING=y # I/O cgroup throttling
CONFIG_IOSCHED_BFQ=y # budget fair queuing
CONFIG_BFQ_GROUP_IOSCHED=y # per-cgroup I/O scheduling
Containers vs. Bare Metal¶
| CONFIG option | Container host | Bare-metal kiosk |
|---|---|---|
CONFIG_KVM |
Yes (if running nested VMs) | No |
CONFIG_DRM_* |
No (GPU passed through) | Yes (direct rendering) |
CONFIG_VETH |
Yes (container networking) | Optional |
CONFIG_OVERLAY_FS |
Yes (image layers) | Yes (rootfs overlay) |
CONFIG_USER_NS |
Yes (rootless containers) | Optional |
CONFIG_DM_VERITY |
Optional | Yes (rootfs integrity) |
Building¶
# Start from Debian's config, then strip
cp /boot/config-$(uname -r) .config
make olddefconfig
# Disable everything you don't need
scripts/config --disable CONFIG_SOUND
scripts/config --disable CONFIG_WLAN
scripts/config --disable CONFIG_BT
# Build
make -j$(nproc) bzImage modules
make INSTALL_MOD_PATH=./rootfs modules_install
3. Init System Setup¶
systemd in a Minimal Image¶
The minbase variant does not include systemd. You add it explicitly:
This gives you PID 1, journald, logind, networkd, resolved, and the full target/unit dependency system.
Custom Target: kiosk.target¶
Create a target that brings up Sway and nothing else beyond basic networking:
# /etc/systemd/system/kiosk.target
[Unit]
Description=Warmwind Kiosk Mode
Requires=basic.target
After=basic.target network-online.target
Wants=sway.service wayvnc.service
[Install]
# This replaces graphical.target as the default
Aliases=default.target
Set it as default:
systemctl --root="$1" set-default kiosk.target
# Or equivalently:
ln -sf /etc/systemd/system/kiosk.target \
"$1/etc/systemd/system/default.target"
Socket Activation for Sway¶
Sway does not natively support socket activation, but you can gate dependent
services on Sway's readiness using sway.service as a dependency and a
readiness notification script:
# /etc/systemd/system/sway.service
[Unit]
Description=Sway Wayland Compositor
After=systemd-logind.service
ConditionPathExists=/dev/dri/card0
[Service]
Type=simple
User=kiosk
Environment=WLR_BACKENDS=drm
Environment=WLR_LIBINPUT_NO_DEVICES=1
Environment=XDG_RUNTIME_DIR=/run/user/1000
ExecStartPre=/usr/bin/mkdir -p /run/user/1000
ExecStart=/usr/bin/sway
Restart=on-failure
RestartSec=3
[Install]
WantedBy=kiosk.target
# /etc/systemd/system/wayvnc.service
[Unit]
Description=WayVNC Remote Access
After=sway.service
Requires=sway.service
ConditionPathExists=/run/user/1000/wayland-1
[Service]
Type=simple
User=kiosk
Environment=WAYLAND_DISPLAY=wayland-1
Environment=XDG_RUNTIME_DIR=/run/user/1000
ExecStartPre=/usr/bin/bash -c 'until [ -e /run/user/1000/wayland-1 ]; do sleep 0.2; done'
ExecStart=/usr/bin/wayvnc 0.0.0.0 5900
Restart=on-failure
[Install]
WantedBy=kiosk.target
# /etc/systemd/system/chromium-kiosk.service
[Unit]
Description=Chromium Kiosk Browser
After=sway.service
Requires=sway.service
[Service]
Type=simple
User=kiosk
Environment=WAYLAND_DISPLAY=wayland-1
Environment=XDG_RUNTIME_DIR=/run/user/1000
ExecStart=/usr/bin/chromium --no-sandbox --kiosk \
--ozone-platform=wayland \
--disable-gpu-sandbox \
--enable-features=UseOzonePlatform \
https://agent.warmwind.local
Restart=on-failure
[Install]
WantedBy=kiosk.target
The Complete Boot Chain¶
graph LR
FW["UEFI<br/>firmware"] --> BL["systemd-boot<br/>(signed)"]
BL --> K["Linux kernel<br/>+ initramfs"]
K --> V["dm-verity<br/>verify rootfs"]
V --> O["OverlayFS<br/>mount merged"]
O --> SD["systemd<br/>kiosk.target"]
SD --> SW["Sway<br/>compositor"]
SW --> VNC["WayVNC<br/>:5900"]
SW --> CR["Chromium<br/>kiosk mode"]
Timing on a tuned system:
| Stage | Duration | What happens |
|---|---|---|
| UEFI + systemd-boot | ~1s | Firmware POST, verify bootloader signature |
| Kernel + initramfs | ~2s | Decompress kernel, load initramfs, verify dm-verity root hash |
| systemd to kiosk.target | ~3s | Mount overlay, start networkd, logind, dbus |
| Sway + WayVNC + Chromium | ~2s | GPU init, compositor up, VNC listening, browser loading |
| Total cold boot | ~8s | Ready to accept VNC connections |
4. Read-Only Rootfs with OverlayFS¶
Make the Rootfs Immutable¶
The rootfs image is a SquashFS file -- it is physically read-only. The kernel mounts it as the lower layer of an OverlayFS union:
# In the initramfs or an early systemd generator:
mount -t squashfs -o ro /dev/mapper/verified-root /sysroot/lower
mount -t tmpfs tmpfs /sysroot/upper-backing
mkdir -p /sysroot/upper-backing/upper /sysroot/upper-backing/work
mount -t overlay overlay \
-o lowerdir=/sysroot/lower,\
upperdir=/sysroot/upper-backing/upper,\
workdir=/sysroot/upper-backing/work \
/sysroot/merged
Writable /etc via tmpfs Overlay¶
Some services (networkd, resolved, machine-id) need to write to /etc at
runtime. Instead of making the whole root writable, overlay just /etc:
# /etc/systemd/system/etc-overlay.service
[Unit]
Description=Writable /etc overlay
DefaultDependencies=no
Before=local-fs.target
After=tmp.mount
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/bash -c '\
mkdir -p /tmp/.etc-upper /tmp/.etc-work && \
mount -t overlay overlay \
-o lowerdir=/etc,upperdir=/tmp/.etc-upper,workdir=/tmp/.etc-work \
/etc'
[Install]
WantedBy=local-fs.target
Persistent /var on a Separate Partition¶
Logs, container state, and application data survive reboots via a dedicated ext4 partition:
# /etc/systemd/system/var.mount
[Unit]
Description=Persistent /var
Before=local-fs.target
[Mount]
What=LABEL=data
Where=/var
Type=ext4
Options=defaults,noatime
[Install]
WantedBy=local-fs.target
SquashFS Compression¶
# zstd level 19: best ratio for rootfs images (~3:1 compression)
mksquashfs rootfs/ rootfs.squashfs \
-comp zstd \
-Xcompression-level 19 \
-noappend \
-no-exports \
-no-xattrs
# Typical results:
# rootfs directory: ~450 MB (minbase + sway + chromium)
# rootfs.squashfs: ~140 MB (zstd -19)
5. dm-verity for Integrity¶
dm-verity creates a Merkle hash tree over every block of the read-only rootfs. Any modification -- even a single flipped bit -- is detected at read time.
Create the Verity Hash Tree¶
# Format: creates hash tree and outputs the root hash
veritysetup format rootfs.squashfs verity-hash.img \
--data-block-size=4096 \
--hash-block-size=4096 \
--hash=sha256
# Output:
# Root hash: 7a3f8c...e42d
# Salt: a1b2c3...f0e9
# Save the root hash -- you embed this in the kernel command line
ROOT_HASH=$(veritysetup format rootfs.squashfs verity-hash.img \
| grep "Root hash" | awk '{print $NF}')
echo "${ROOT_HASH}" > root-hash.txt
Verify It Works¶
# Activate the verified device
veritysetup open rootfs.squashfs verified-root verity-hash.img "${ROOT_HASH}"
# Mount it -- reads are now integrity-checked
mount -o ro /dev/mapper/verified-root /mnt
# Test: tamper with the squashfs image
dd if=/dev/urandom of=rootfs.squashfs bs=1 count=1 seek=1000 conv=notrunc
# Now any read from /mnt returns -EIO
Integrate with Boot (Kernel cmdline)¶
The root hash is embedded in the kernel command line, which is itself protected by Secure Boot (signed UKI or signed bootloader config):
# systemd-boot entry
cat > /boot/loader/entries/warmwind.conf <<'EOF'
title Warmwind Kiosk
linux /vmlinuz
initrd /initrd.img
options root=/dev/mapper/verified-root ro \
systemd.verity=1 \
roothash=7a3f8c...e42d \
systemd.verity_root_data=LABEL=rootfs \
systemd.verity_root_hash=LABEL=verity \
systemd.verity_root_options=restart-on-corruption \
rd.shell=0 rd.emergency=reboot
EOF
Key kernel parameters:
| Parameter | Purpose |
|---|---|
roothash=<hex> |
The Merkle tree root hash |
systemd.verity_root_data= |
Block device containing the rootfs |
systemd.verity_root_hash= |
Block device containing the hash tree |
systemd.verity_root_options=restart-on-corruption |
Reboot on tamper instead of panic |
rd.shell=0 rd.emergency=reboot |
Prevent shell access if verity fails |
Detect Tampering¶
When dm-verity detects a corrupted block, it returns -EIO for that read. With
restart-on-corruption, systemd reboots into the alternate (known-good) slot.
Combined with A/B updates, this means a tampered system self-heals on next boot.
6. A/B Updates with RAUC¶
Partition Layout¶
GPT Partition Table:
┌─────────────────────────────────────────────────────────┐
│ ESP (EFI System Partition) 200 MB FAT32 LABEL=ESP │
├─────────────────────────────────────────────────────────┤
│ Root-A rootfs.squashfs 512 MB raw LABEL=rootA │
├─────────────────────────────────────────────────────────┤
│ Verity-A hash tree 64 MB raw LABEL=verA │
├─────────────────────────────────────────────────────────┤
│ Root-B (standby slot) 512 MB raw LABEL=rootB │
├─────────────────────────────────────────────────────────┤
│ Verity-B (standby hashes) 64 MB raw LABEL=verB │
├─────────────────────────────────────────────────────────┤
│ Data persistent /var 4 GB ext4 LABEL=data │
└─────────────────────────────────────────────────────────┘
Create it with sfdisk:
sfdisk /dev/sda <<'EOF'
label: gpt
name=ESP, size=200M, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B
name=rootA, size=512M, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4
name=verA, size=64M, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4
name=rootB, size=512M, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4
name=verB, size=64M, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4
name=data, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4
EOF
RAUC System Configuration¶
# /etc/rauc/system.conf
[system]
compatible=warmwind-kiosk
bootloader=efi
data-directory=/var/lib/rauc
[keyring]
path=/etc/rauc/ca.cert.pem
[slot.rootfs.0]
device=/dev/disk/by-partlabel/rootA
type=raw
bootname=A
[slot.rootfs.1]
device=/dev/disk/by-partlabel/rootB
type=raw
bootname=B
Bundle Creation¶
A RAUC bundle packages the rootfs image, a manifest, and an optional pre/post-install hook -- all signed:
# 1. Prepare the bundle directory
mkdir -p bundle-dir
cp rootfs.squashfs bundle-dir/rootfs.img
# 2. Write the manifest
cat > bundle-dir/manifest.raucm <<'EOF'
[update]
compatible=warmwind-kiosk
version=2.1.0
description=Chromium 130 + sway 1.9
[image.rootfs]
filename=rootfs.img
sha256sum=auto
EOF
# 3. Sign and create the bundle
rauc bundle \
--cert=release.cert.pem \
--key=release.key.pem \
bundle-dir/ \
warmwind-kiosk-2.1.0.raucb
Atomic Switchover¶
# Install the bundle (writes to inactive slot)
rauc install warmwind-kiosk-2.1.0.raucb
# RAUC automatically:
# 1. Identifies the inactive slot (B if A is active)
# 2. Writes the image to /dev/disk/by-partlabel/rootB
# 3. Verifies the written image against the manifest hash
# 4. Marks slot B as "good" in the bootloader environment
# 5. Sets B as the primary boot target
# Check status
rauc status
# Output:
# Booted from: rootfs.0 (A)
# rootfs.0: class=rootfs, device=/dev/sda2, bootname=A, state=active
# rootfs.1: class=rootfs, device=/dev/sda4, bootname=B, state=good
Automatic Rollback on Failed Boot¶
The rollback relies on a three-way handshake between RAUC, the bootloader, and a hardware watchdog:
- RAUC marks the new slot with
boot-attempts=3in the bootloader env. - Bootloader (GRUB/systemd-boot/U-Boot) decrements the counter each boot.
- On successful boot, a systemd service calls
rauc status mark-good, resetting the counter to "permanent." - If all attempts fail (kernel panic, watchdog timeout, service crash), the bootloader falls back to the previous slot automatically.
# /etc/systemd/system/rauc-mark-good.service
[Unit]
Description=Mark RAUC slot as good after successful boot
After=kiosk.target
Requires=kiosk.target
[Service]
Type=oneshot
ExecStart=/usr/bin/rauc status mark-good
[Install]
WantedBy=kiosk.target
7. bootc: The Docker-Native Path¶
If you already build OCI images with Dockerfiles and push them to registries, bootc lets you do the same thing for full OS images. No new toolchain -- your existing container skills transfer directly.
How bootc Works¶
graph LR
CF["Containerfile"] --> Build["podman build"]
Build --> OCI["OCI Image<br/>(registry)"]
OCI --> BIB["bootc-image-builder"]
BIB --> Disk["Bootable disk<br/>(qcow2/raw/iso)"]
OCI --> Live["bootc switch<br/>(live update)"]
A Containerfile That Builds a Kiosk OS¶
# Use a bootc-compatible base (Fedora, CentOS Stream, or Debian unofficial)
FROM quay.io/fedora/fedora-bootc:42
# Install the kiosk stack -- same dnf you use in any Containerfile
RUN dnf install -y \
sway wayvnc chromium-browser \
systemd-networkd \
openssh-server \
&& dnf clean all
# Drop in systemd units
COPY kiosk.target /etc/systemd/system/kiosk.target
COPY sway.service /etc/systemd/system/sway.service
COPY wayvnc.service /etc/systemd/system/wayvnc.service
COPY chromium-kiosk.service /etc/systemd/system/chromium-kiosk.service
# Set the default boot target
RUN systemctl set-default kiosk.target && \
systemctl enable sway.service wayvnc.service chromium-kiosk.service
# Create the kiosk user
RUN useradd -m -s /bin/bash kiosk
# bootc lint -- catches issues before deployment
RUN bootc container lint
Build and Deploy¶
# 1. Build the image (same as any container build)
podman build -t registry.warmwind.local/kiosk:2.1.0 .
# 2. Push to your OCI registry
podman push registry.warmwind.local/kiosk:2.1.0
# 3. Convert to a bootable disk image
sudo podman run --rm --privileged \
--pull=newer \
-v ./output:/output \
-v /var/lib/containers/storage:/var/lib/containers/storage \
quay.io/centos-bootc/bootc-image-builder:latest \
--type qcow2 \
registry.warmwind.local/kiosk:2.1.0
# Output: output/qcow2/disk.qcow2 -- boot it in QEMU or flash to hardware
# 4. Update a running system (atomic, in-place)
bootc switch registry.warmwind.local/kiosk:2.2.0
# On next reboot, the system runs v2.2.0. Rollback with:
bootc rollback
Supported Output Formats¶
| Format | Flag | Use case |
|---|---|---|
| qcow2 | --type qcow2 |
QEMU / libvirt VMs |
| raw | --type raw |
dd to physical disk |
| ISO | --type anaconda-iso |
USB installer |
| AMI | --type ami |
AWS EC2 |
| VMDK | --type vmdk |
VMware / vSphere |
How bootc Maps to Your Docker Skills¶
| Docker concept | bootc equivalent |
|---|---|
FROM base image |
FROM quay.io/fedora/fedora-bootc:42 |
RUN dnf install |
Same -- packages land in /usr |
COPY config /etc/ |
Same -- config baked into image |
docker push |
podman push to same OCI registry |
docker pull + run |
bootc switch <image> on live system |
| Tag-based rollback | bootc rollback (previous deployment) |
| Layer caching | OSTree content deduplication |
This is the path with the shortest distance from your current skills
You already build OCI images with bash frameworks. With bootc, the
same Containerfile that builds a Docker image can build a bootable OS.
The registry, the CI pipeline, the tagging workflow -- all identical.
The only addition is bootc-image-builder to convert the OCI image
to a disk image for bare-metal deployment.
8. Complete Build Pipeline Script¶
This script chains the entire flow: build a Debian rootfs with mmdebstrap, install the kiosk stack, configure systemd, create a SquashFS image, add dm-verity, and assemble a bootable GPT disk image.
#!/usr/bin/env bash
# build-kiosk-image.sh -- Produce a bootable Warmwind kiosk disk image
# Requires: mmdebstrap, mksquashfs, veritysetup, sfdisk, grub-install
set -euo pipefail
# --- Configuration ---
SUITE=bookworm
MIRROR=http://deb.debian.org/debian
WORK=$(mktemp -d)
trap 'rm -rf "${WORK}"' EXIT
ROOTFS="${WORK}/rootfs"
IMG="${1:-kiosk.img}"
PACKAGES=(
systemd systemd-sysv dbus linux-image-amd64
grub-efi-amd64-bin efibootmgr
sway wayvnc chromium
iproute2 openssh-server
)
echo "==> Building rootfs with mmdebstrap"
mmdebstrap --variant=minbase \
--include="$(IFS=,; echo "${PACKAGES[*]}")" \
--customize-hook='echo "kiosk" > "$1/etc/hostname"' \
--customize-hook='echo "root:kiosk" | chroot "$1" chpasswd' \
--customize-hook='systemctl --root="$1" set-default kiosk.target' \
--customize-hook='systemctl --root="$1" enable systemd-networkd ssh' \
"${SUITE}" "${ROOTFS}" "${MIRROR}"
echo "==> Installing custom systemd units"
# (In production, these come from --customize-hook='copy-in')
for unit in kiosk.target sway.service wayvnc.service chromium-kiosk.service; do
cp "units/${unit}" "${ROOTFS}/etc/systemd/system/"
done
echo "==> Creating SquashFS image"
mksquashfs "${ROOTFS}" "${WORK}/rootfs.squashfs" \
-comp zstd -Xcompression-level 19 -noappend -quiet
echo "==> Generating dm-verity hash tree"
ROOT_HASH=$(veritysetup format \
"${WORK}/rootfs.squashfs" "${WORK}/verity.img" \
--data-block-size=4096 --hash-block-size=4096 \
| awk '/Root hash/{print $NF}')
echo " Root hash: ${ROOT_HASH}"
echo "==> Assembling GPT disk image"
ROOTFS_SIZE=$(stat -c%s "${WORK}/rootfs.squashfs")
VERITY_SIZE=$(stat -c%s "${WORK}/verity.img")
# Round up to MiB boundaries
ROOTFS_MB=$(( (ROOTFS_SIZE + 1048575) / 1048576 ))
VERITY_MB=$(( (VERITY_SIZE + 1048575) / 1048576 ))
ESP_MB=200
DATA_MB=512
TOTAL_MB=$(( ESP_MB + ROOTFS_MB + VERITY_MB + ROOTFS_MB + VERITY_MB + DATA_MB + 2 ))
truncate -s "${TOTAL_MB}M" "${IMG}"
sfdisk "${IMG}" <<PARTS
label: gpt
name=ESP, size=${ESP_MB}M, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B
name=rootA, size=${ROOTFS_MB}M, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4
name=verA, size=${VERITY_MB}M, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4
name=rootB, size=${ROOTFS_MB}M, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4
name=verB, size=${VERITY_MB}M, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4
name=data, size=${DATA_MB}M, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4
PARTS
echo "==> Writing rootfs + verity to slot A"
LOOP=$(losetup --find --show --partscan "${IMG}")
dd if="${WORK}/rootfs.squashfs" of="${LOOP}p2" bs=1M status=progress
dd if="${WORK}/verity.img" of="${LOOP}p3" bs=1M status=progress
echo "==> Formatting ESP and installing bootloader"
mkfs.fat -F32 "${LOOP}p1"
mkdir -p "${WORK}/esp"
mount "${LOOP}p1" "${WORK}/esp"
grub-install --target=x86_64-efi \
--efi-directory="${WORK}/esp" \
--boot-directory="${WORK}/esp/boot" \
--removable --no-nvram
cat > "${WORK}/esp/boot/grub/grub.cfg" <<GRUB
set timeout=1
menuentry "Warmwind Kiosk" {
linux /boot/vmlinuz root=/dev/mapper/verified-root ro \
systemd.verity=1 roothash=${ROOT_HASH} \
systemd.verity_root_data=PARTLABEL=rootA \
systemd.verity_root_hash=PARTLABEL=verA \
systemd.verity_root_options=restart-on-corruption \
rd.shell=0 rd.emergency=reboot quiet
initrd /boot/initrd.img
}
GRUB
# Copy kernel and initramfs to ESP
cp "${ROOTFS}"/boot/vmlinuz-* "${WORK}/esp/boot/vmlinuz"
cp "${ROOTFS}"/boot/initrd.img-* "${WORK}/esp/boot/initrd.img"
echo "==> Formatting data partition"
mkfs.ext4 -L data "${LOOP}p6"
umount "${WORK}/esp"
losetup -d "${LOOP}"
echo "==> Done: ${IMG} ($(du -h "${IMG}" | cut -f1))"
echo " Boot with: qemu-system-x86_64 -bios /usr/share/ovmf/OVMF.fd -drive file=${IMG},format=raw -m 2G"
Running the Pipeline¶
# Install build dependencies
sudo apt install mmdebstrap squashfs-tools cryptsetup-bin \
dosfstools grub-efi-amd64-bin util-linux
# Run the build (requires root for loop devices)
sudo ./build-kiosk-image.sh kiosk-v2.1.0.img
# Test in QEMU
qemu-system-x86_64 \
-bios /usr/share/ovmf/OVMF.fd \
-drive file=kiosk-v2.1.0.img,format=raw \
-m 2G -smp 2 \
-device virtio-gpu-pci \
-display gtk
Choosing Your Path¶
| Approach | Best for | Complexity | Your skill match |
|---|---|---|---|
| mmdebstrap + manual pipeline | Full control, Debian-based, Warmwind's actual stack | High | Direct -- bash scripting |
| bootc + bootc-image-builder | Fastest time-to-bootable, OCI-native workflow | Low | Direct -- your OCI framework maps 1:1 |
| RAUC for OTA updates | Production fleet management, atomic rollback | Medium | New -- but config-driven |
| Yocto / Buildroot | Embedded hardware, cross-compilation | Very high | Overkill for x86_64 kiosks |
For Warmwind's use case (x86_64 kiosks running Sway + Chromium + WayVNC), the mmdebstrap pipeline gives maximum control, while bootc gives the fastest path to a working image using skills you already have. In production, you would likely wrap either approach with RAUC for over-the-air updates.
Glossary
- mmdebstrap
- Multi-mirror Debian bootstrap tool. Single-pass dependency resolution, hook system, multiple output formats. Replacement for debootstrap.
- RAUC
- Robust Auto-Update Controller. Manages A/B partition updates with cryptographic signature verification and automatic rollback.
- bootc
- Tool for deploying and updating bootable container images. Uses OCI registries for OS distribution. Atomic updates via OSTree.
- bootc-image-builder
- Containerized tool that converts bootc OCI images to bootable disk images (qcow2, raw, ISO, AMI, VMDK).
- dm-verity
- Device-mapper target that provides transparent block-level integrity checking using a Merkle hash tree. Read-only by design.
- veritysetup
- Userspace tool for creating and activating dm-verity devices. Part of cryptsetup.
- SquashFS
- Compressed read-only Linux filesystem. Supports zstd, xz, lzo, gzip. Random read access without full decompression.
- OverlayFS
- Union filesystem (mainline since Linux 3.18). Layers a writable upper directory over a read-only lower. Writes go to upper; whiteout files handle deletes.
- UKI (Unified Kernel Image)
- Single PE binary containing kernel, initramfs, kernel cmdline, and microcode. Signed as one artifact for Secure Boot, preventing cmdline tampering.
- initramfs
- Initial RAM filesystem loaded by the bootloader alongside the kernel. Runs early userspace: loads drivers, activates dm-verity, mounts real root, then pivots.
- A/B partitioning
- Dual-slot scheme where the inactive slot receives updates while the active slot runs. Boot failure triggers automatic rollback to the previous slot.
- kiosk.target
- Custom systemd target that replaces graphical.target. Pulls in only the units needed for the kiosk stack (Sway, WayVNC, Chromium).
CONFIG_PREEMPT- Kernel config enabling full preemption. Reduces worst-case latency for I/O-bound workloads at slight throughput cost. Used in low-latency / real-time tuning.
CONFIG_DM_VERITY_VERIFY_ROOTHASH_SIG- Kernel config option that requires the dm-verity root hash to be signed by a key in the kernel keyring. Prevents booting with a forged root hash.