Skip to content

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:

--include=systemd,systemd-sysv,dbus

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:

# fstab entry (or systemd .mount unit)
LABEL=data  /var  ext4  defaults,noatime  0  2
# /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:

  1. RAUC marks the new slot with boot-attempts=3 in the bootloader env.
  2. Bootloader (GRUB/systemd-boot/U-Boot) decrements the counter each boot.
  3. On successful boot, a systemd service calls rauc status mark-good, resetting the counter to "permanent."
  4. 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.