Recently Written · git

waybase

Bootable installer: KISS, Gentoo, Arch or Alpine + Nix + a Wayland compositor (sway, river, labwc, dwl)

git clone https://github.com/equwal/waybase

Log | Files | Refs


commit 8e20868e60c9efa2c664034409dd9bb8c7e2c541
equwal <13551856+equwal@users.noreply.github.com>
2026-10-04 01:40:42 -0700

Add waybase: multi-base Wayland + Nix installer image

One POSIX sh core with backends for KISS, Gentoo, Arch and Alpine; Nix
multi-user daemon; sway, river-classic, labwc or dwl; LUKS2; UEFI+BIOS GRUB.
QEMU harness installs unattended and checks login, nix and headless start.

 .gitattributes           |   1 +
 .github/workflows/ci.yml |  26 ++++
 .shellcheckrc            |   5 +
 LICENSE                  |  21 ++++
 README.md                | 109 +++++++++++++++++
 answers.example          |  15 +++
 backends/alpine.sh       |  39 ++++++
 backends/arch.sh         |  44 +++++++
 backends/gentoo.sh       |  51 ++++++++
 backends/kiss.sh         |  67 +++++++++++
 build-image.sh           |  34 ++++++
 install.sh               | 307 +++++++++++++++++++++++++++++++++++++++++++++++
 test-qemu.py             | 157 ++++++++++++++++++++++++
 test.sh                  |   9 ++
 14 files changed, 885 insertions(+)
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..6313b56
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto eol=lf
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..cbfa033
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,26 @@
+name: ci
+on: [push, pull_request, workflow_dispatch]
+jobs:
+  shellcheck:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - run: shellcheck install.sh build-image.sh test.sh backends/*.sh
+      - run: python3 -m py_compile test-qemu.py
+  qemu-smoke:
+    # Alpine + sway, LUKS: build the image, install, boot, check (about 20 min).
+    runs-on: ubuntu-latest
+    timeout-minutes: 120
+    steps:
+      - uses: actions/checkout@v4
+      - name: enable KVM
+        run: |
+          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm.rules
+          sudo udevadm control --reload-rules && sudo udevadm trigger --name-match=kvm
+      - run: mkdir -p out && sh build-image.sh out
+      - run: sh test.sh out alpine sway encrypt
+      - if: always()
+        uses: actions/upload-artifact@v4
+        with:
+          name: qemu-log
+          path: out/*.log
diff --git a/.shellcheckrc b/.shellcheckrc
new file mode 100644
index 0000000..cd209c3
--- /dev/null
+++ b/.shellcheckrc
@@ -0,0 +1,5 @@
+# $M is the fixed mount point /mnt/waybase; package lists split on purpose.
+disable=SC2086,SC2012,SC2016,SC2153
+# variables are shared with the sourced backends
+disable=SC2034
+shell=sh
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..160e1db
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 equwal
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..dfa5950
--- /dev/null
+++ b/README.md
@@ -0,0 +1,109 @@
+# waybase
+
+A bootable USB/SD installer that puts a fresh Linux on a blank disk: your pick
+of **KISS, Gentoo, Arch or Alpine**, plus the **Nix** package manager and a
+**Wayland-only** desktop (no X11) with the compositor you choose.
+
+One POSIX `sh` installer core (`install.sh`, about 300 lines) and one short
+backend per base (`backends/*.sh`). It asks a few questions from plain text
+menus, or reads an answers file for unattended installs.
+
+## Quickstart
+
+```sh
+git clone https://github.com/equwal/waybase && cd waybase
+sh build-image.sh out             # needs docker; writes out/waybase.img
+dd if=out/waybase.img of=/dev/sdX bs=4M conv=fsync   # sdX = your USB stick or SD card
+```
+
+Boot the stick (UEFI or BIOS), log in as `root` (no password), connect
+Ethernet, and run:
+
+```sh
+sh /media/*/waybase/install.sh                 # interactive
+sh /media/*/waybase/install.sh answers.txt     # unattended, see answers.example
+```
+
+Reboot, log in on tty1, and the compositor starts. Super+Return opens foot,
+Super+d opens fuzzel (dwl: Alt+Shift+Return and Alt+p, its built-in keys).
+
+## What you get
+
+| | KISS | Gentoo | Arch | Alpine |
+|---|---|---|---|---|
+| Bootstrap | official chroot tarball + `baseinit` | desktop-openrc stage3 + binary packages only (`getbinpkg`, `usepkgonly`) | official bootstrap tarball + pacman | `apk --root` |
+| Init | busybox init + runit | OpenRC | systemd | OpenRC |
+| Seat | seatd (Nix) | elogind | systemd-logind | seatd |
+| Kernel | Alpine linux-lts + mkinitfs | gentoo-kernel-bin + dracut | linux + mkinitcpio | linux-lts + mkinitfs |
+| Nix | release tarball, multi-user daemon (runit) | release tarball, multi-user daemon (OpenRC) | release tarball, multi-user daemon (systemd socket) | Alpine `nix` package, daemon (OpenRC) |
+| Desktop packages | all from Nix | binpkg when one exists, else Nix | native | native (dwl from Nix) |
+
+Every install also gets PipeWire + WirePlumber, the foot terminal, the fuzzel
+launcher, optional LUKS2 encryption of the root partition, GRUB for both UEFI
+(removable path, no NVRAM writes) and BIOS, and one user account in the
+`wheel`, `video`, `input` and `audio` groups.
+
+Disk layout (GPT): 1 MiB BIOS boot, 512 MiB ESP, 1 GiB `/boot` (ext4,
+unencrypted), the rest is `/` (ext4, on LUKS2 if you choose encryption).
+
+Compositors: **sway, river (river-classic 0.3; river 0.4+ needs a separate
+window manager), labwc, dwl**. Hyprland is left out: it has no headless
+software-rendering mode to test and needs a GPU. When a base has no native
+package, the installer takes it from Nix into the system profile
+`/nix/var/nix/profiles/waybase` and links Mesa to `/run/opengl-driver`, so Nix
+GL programs find drivers. `/etc/waybase-from-nix` lists what came from Nix.
+
+## Tested matrix
+
+`test.sh` boots the image in QEMU (KVM, `-cpu Nehalem` so nothing may need
+AVX), installs unattended, boots the result by BIOS and by UEFI, unlocks LUKS,
+logs in as the new user, and checks `nix --version`, the Nix daemon, and that
+each compositor starts headless (`WLR_BACKENDS=headless`) and opens a Wayland
+socket.
+
+MATRIX
+
+Run one yourself: `sh build-image.sh out && sh test.sh out arch sway encrypt`.
+
+## Security notes
+
+- The installer erases the whole target disk. Interactive mode makes you type
+  the disk name twice; `YES=1` in an answers file skips that.
+- An answers file is shell code, sourced as root, and holds passwords in plain
+  text. Keep it off shared media and delete it after the install.
+- `/boot` is not encrypted (kernel and initramfs are readable and could be
+  replaced by someone with physical access). Only `/` is on LUKS2.
+- Live image: `root` has no password, as on the stock Alpine ISO. Do not boot
+  it on an untrusted network longer than the install takes.
+- Downloads come over HTTPS from the official mirrors (Alpine, Arch, Gentoo,
+  GitHub for KISS, releases.nixos.org). Arch and Gentoo verify package
+  signatures with their own keyrings; the KISS tarball and the Nix tarball are
+  trusted on HTTPS alone.
+- On KISS, the root-run `seatd` and `udevd` come from the root-owned Nix
+  profile, which users cannot modify.
+
+## Licences and why no image is released
+
+waybase itself is MIT. The image is the official Alpine ISO with these
+scripts added; it contains GPL software, so redistributing it would oblige us
+to also ship the matching sources. The bases (KISS, Gentoo, Arch), Nix (LGPL
+2.1) and linux-firmware (mixed, redistributable) are downloaded from their
+official mirrors at install time and never redistributed by this project. So
+this repo ships the build script, not a built image: `build-image.sh` takes
+about a minute.
+
+## Prior art
+
+archinstall (Arch only), setup-alpine (Alpine only), Gentoo has no official
+installer, nixos-anywhere (NixOS only), KISS has a manual install guide. None
+of them offers a choice of base, Nix on that base, and a ready Wayland
+session from one image. waybase does, with an unattended mode tested in CI.
+
+## Known gaps
+
+- GPU, audio and input on real hardware are not covered by the QEMU tests.
+- Nix-supplied compositors on KISS and Gentoo use Nix's Mesa; NVIDIA needs
+  extra work.
+- Kernel updates on Gentoo produce new file names; `/boot/grub/grub.cfg` then
+  needs the new names by hand.
+- No Wi-Fi setup: install over Ethernet.
diff --git a/answers.example b/answers.example
new file mode 100644
index 0000000..e8856f3
--- /dev/null
+++ b/answers.example
@@ -0,0 +1,15 @@
+# waybase answers file: sh install.sh answers.example
+# Any variable left out is asked for interactively.
+BASE=alpine               # kiss | gentoo | arch | alpine
+COMPOSITOR=sway           # sway | river | labwc | dwl (a list installs several)
+DISK=/dev/vda             # ALL DATA ON IT IS ERASED
+YES=1                     # skip the "type the disk name again" check
+ENCRYPT=yes               # LUKS2 on the root partition
+LUKS_PASS=change-me
+USERNAME=user
+USER_PASS=change-me
+ROOT_PASS=change-me
+HOSTNAME_NEW=waybase
+#FIRMWARE=no              # skip linux-firmware (VMs)
+#SERIAL=1                 # console and login on ttyS0 (tests)
+#NIXPKGS=github:NixOS/nixpkgs/nixos-unstable
diff --git a/backends/alpine.sh b/backends/alpine.sh
new file mode 100644
index 0000000..78be09b
--- /dev/null
+++ b/backends/alpine.sh
@@ -0,0 +1,39 @@
+# Alpine: apk --root from the live system. OpenRC, eudev, seatd, dbus.
+# shellcheck shell=sh disable=SC2034
+bootstrap() {
+    mkdir -p $M/etc/apk/keys $M/etc/mkinitfs
+    cp /etc/apk/keys/* $M/etc/apk/keys/
+    grep '^http' /etc/apk/repositories > $M/etc/apk/repositories
+    apk add -q --root $M --initdb alpine-base
+    chroot_mounts
+    echo 'features="ata base cryptsetup ext4 keymap mmc nvme scsi usb virtio"' > $M/etc/mkinitfs/mkinitfs.conf
+    fw=linux-firmware; [ "${FIRMWARE:-}" = no ] && fw=linux-firmware-none
+    ch "apk add -q linux-lts $fw mkinitfs cryptsetup e2fsprogs eudev seatd dbus shadow \
+        mesa-dri-gallium font-dejavu fontconfig"
+    for s in devfs dmesg udev udev-trigger udev-settle; do ch "rc-update -q add $s sysinit"; done
+    for s in modules sysctl hostname bootmisc syslog hwclock; do ch "rc-update -q add $s boot"; done
+    for s in networking seatd dbus; do ch "rc-update -q add $s default"; done
+    for s in killprocs savecache mount-ro; do ch "rc-update -q add $s shutdown"; done
+    printf 'auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n' > $M/etc/network/interfaces
+}
+pkg() { case $1 in pipewire) echo "pipewire pipewire-pulse" ;; river) echo river-classic ;; *) echo "$1" ;; esac; }
+pkg_install() { ch "apk add -q $*"; }
+extra_pkgs() { :; }
+user_add() {
+    ch "adduser -D -s /bin/sh $1"
+    for g in wheel video input audio seat; do ch "addgroup $1 $g" 2>/dev/null || true; done
+}
+serial_getty() { echo 'ttyS0::respawn:/sbin/getty -L 115200 ttyS0 vt100' >> $M/etc/inittab; }
+nix_native() { ch "apk add -q nix"; }
+nix_service() { ch "rc-update -q add nix-daemon default"; }
+boot_cmd() {
+    printf '#!/bin/sh\n%s\n' "$1" >> $M/etc/local.d/waybase.start
+    chmod 755 $M/etc/local.d/waybase.start
+    ch "rc-update -q add local default"
+}
+kernel() {
+    ch "mkinitfs \$(ls /lib/modules | head -1)" >/dev/null
+    KERNEL=vmlinuz-lts INITRD=initramfs-lts
+    KARGS="modules=sd-mod,usb-storage,ext4"
+    CRYPTARGS="cryptroot=UUID=$UL cryptdm=root"
+}
diff --git a/backends/arch.sh b/backends/arch.sh
new file mode 100644
index 0000000..0394e26
--- /dev/null
+++ b/backends/arch.sh
@@ -0,0 +1,44 @@
+# Arch: the official bootstrap tarball as the new root, then pacman inside it.
+# systemd, systemd-logind (no seatd needed), systemd-networkd.
+# shellcheck shell=sh disable=SC2034
+ARCH_MIRROR=${ARCH_MIRROR:-https://geo.mirror.pkgbuild.com}
+bootstrap() {
+    curl -fsSL "$ARCH_MIRROR/iso/latest/archlinux-bootstrap-x86_64.tar.zst" |
+        zstd -dc | tar -x -C $M --strip-components=1 --numeric-owner
+    echo "Server = $ARCH_MIRROR/\$repo/os/\$arch" > $M/etc/pacman.d/mirrorlist
+    sed -i 's/^CheckSpace/#CheckSpace/' $M/etc/pacman.conf   # / is not a mount point in the chroot
+    chroot_mounts
+    ch "pacman-key --init && pacman-key --populate archlinux" >/dev/null 2>&1
+    fw=linux-firmware; [ "${FIRMWARE:-}" = no ] && fw=""
+    ch "pacman -Syu --noconfirm --needed base linux $fw mkinitcpio cryptsetup e2fsprogs sudo \
+        mesa ttf-dejavu" >/dev/null
+    echo LANG=C.UTF-8 > $M/etc/locale.conf
+    printf '[Match]\nName=en* eth*\n\n[Network]\nDHCP=yes\n' > $M/etc/systemd/network/20-wired.network
+    ch "systemctl enable systemd-networkd systemd-resolved" >/dev/null 2>&1
+}
+pkg() { case $1 in pipewire) echo "pipewire pipewire-pulse" ;; river) echo river-classic ;; *) echo "$1" ;; esac; }
+pkg_install() { ch "pacman -S --noconfirm --needed $*" >/dev/null; }
+extra_pkgs() { :; }
+user_add() { ch "useradd -m -G wheel,video,input,audio -s /bin/bash $1"; }
+serial_getty() { ch "systemctl enable serial-getty@ttyS0" >/dev/null 2>&1; }
+nix_native() { return 1; }
+nix_service() {
+    for u in nix-daemon.socket nix-daemon.service; do
+        ln -sf /nix/var/nix/profiles/default/lib/systemd/system/$u $M/etc/systemd/system/$u
+    done
+    ch "systemctl enable nix-daemon.socket" >/dev/null 2>&1
+}
+boot_cmd() {
+    printf '[Unit]\nDescription=waybase boot command\n[Service]\nType=oneshot\nExecStart=/bin/sh -c "%s"\n[Install]\nWantedBy=multi-user.target\n' \
+        "$1" > $M/etc/systemd/system/waybase-boot.service
+    ch "systemctl enable waybase-boot" >/dev/null 2>&1
+}
+kernel() {
+    # No autodetect hook: the disk may move to other hardware.
+    sed -i 's/^HOOKS=.*/HOOKS=(base udev modconf kms keyboard keymap block encrypt filesystems fsck)/' $M/etc/mkinitcpio.conf
+    ch "mkinitcpio -P" >/dev/null 2>&1
+    # last step that needs DNS in the chroot is done; hand resolv.conf to resolved
+    ln -sf /run/systemd/resolve/stub-resolv.conf $M/etc/resolv.conf
+    KERNEL=vmlinuz-linux INITRD=initramfs-linux.img KARGS=""
+    CRYPTARGS="cryptdevice=UUID=$UL:root"
+}
diff --git a/backends/gentoo.sh b/backends/gentoo.sh
new file mode 100644
index 0000000..4d49eb9
--- /dev/null
+++ b/backends/gentoo.sh
@@ -0,0 +1,51 @@
+# Gentoo: desktop OpenRC stage3, then binary packages only (getbinpkg,
+# usepkgonly), so nothing compiles. A package without a binary comes from Nix.
+# OpenRC, elogind, dbus, dhcpcd, gentoo-kernel-bin + dracut.
+# shellcheck shell=sh disable=SC2034
+GENTOO_MIRROR=${GENTOO_MIRROR:-https://distfiles.gentoo.org}
+E="emerge --quiet --getbinpkgonly --usepkgonly --noreplace"
+bootstrap() {
+    b=$GENTOO_MIRROR/releases/amd64/autobuilds
+    f=$(curl -fsSL "$b/latest-stage3-amd64-desktop-openrc.txt" | grep -o '^[^# ]*stage3[^ ]*\.tar\.xz' | head -1)
+    curl -fsSL "$b/$f" | tar -xJp -C $M --xattrs-include='*.*' --numeric-owner
+    chroot_mounts
+    printf 'FEATURES="getbinpkg binpkg-request-signature"\nACCEPT_LICENSE="*"\n' >> $M/etc/portage/make.conf
+    mkdir -p $M/etc/portage/package.use $M/etc/dracut.conf.d
+    echo 'sys-kernel/installkernel dracut' > $M/etc/portage/package.use/waybase
+    printf 'hostonly="no"\nadd_dracutmodules+=" crypt "\n' > $M/etc/dracut.conf.d/waybase.conf
+    ch "emerge-webrsync -q" >/dev/null 2>&1
+    ch "getuto" >/dev/null 2>&1 || true
+    ch "$E sys-fs/cryptsetup net-misc/dhcpcd sys-auth/elogind sys-apps/dbus app-admin/sudo"
+    ch "rc-update -q add elogind boot; rc-update -q add dbus default; rc-update -q add dhcpcd default"
+}
+pkg() {
+    case $1 in
+    sway | labwc | dwl) echo "gui-wm/$1" ;; # river: 0.4+ is not monolithic; Nix has river-classic
+    foot | fuzzel | wmenu) echo "gui-apps/$1" ;;
+    pipewire | wireplumber) echo "media-video/$1" ;;
+    esac
+}
+pkg_install() { ch "$E $*"; }
+extra_pkgs() { :; }
+user_add() { ch "useradd -m -G wheel,video,input,audio,usb -s /bin/bash $1"; }
+serial_getty() { echo 's0:12345:respawn:/sbin/agetty -L 115200 ttyS0 vt100' >> $M/etc/inittab; }
+nix_native() { return 1; }
+nix_service() {
+    printf '#!/sbin/openrc-run\ncommand=/nix/var/nix/profiles/default/bin/nix-daemon\ncommand_background=yes\npidfile=/run/nix-daemon.pid\n' \
+        > $M/etc/init.d/nix-daemon
+    chmod 755 $M/etc/init.d/nix-daemon
+    ch "rc-update -q add nix-daemon default"
+}
+boot_cmd() {
+    printf '#!/bin/sh\n%s\n' "$1" >> $M/etc/local.d/waybase.start
+    chmod 755 $M/etc/local.d/waybase.start
+    ch "rc-update -q add local default"
+}
+kernel() {
+    fw=sys-kernel/linux-firmware; [ "${FIRMWARE:-}" = no ] && fw=""
+    ch "$E sys-kernel/installkernel $fw sys-kernel/gentoo-kernel-bin" >/dev/null
+    KERNEL=$(cd $M/boot && ls vmlinuz-* | tail -1)
+    INITRD=$(cd $M/boot && ls initramfs-* | tail -1)
+    KARGS=""
+    CRYPTARGS="rd.luks.uuid=$UL rd.luks.name=$UL=root"
+}
diff --git a/backends/kiss.sh b/backends/kiss.sh
new file mode 100644
index 0000000..4aac9b6
--- /dev/null
+++ b/backends/kiss.sh
@@ -0,0 +1,67 @@
+# KISS: the official chroot tarball plus baseinit (busybox init + runit).
+# KISS builds every package from source, so the desktop (compositor, foot,
+# PipeWire, seatd, udev) comes from Nix and the kernel is Alpine's linux-lts
+# with an Alpine mkinitfs initramfs. Nothing compiles.
+# shellcheck shell=sh disable=SC2034
+bootstrap() {
+    v=$(curl -fsSL https://api.github.com/repos/kisslinux/repo/releases/latest |
+        sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')
+    curl -fsSL "https://github.com/kisslinux/repo/releases/download/$v/kiss-chroot-$v.tar.xz" |
+        tar -xJ -C $M --numeric-owner
+    chroot_mounts
+    ch "git clone -q https://github.com/kisslinux/repo /var/db/kiss/repo"
+    ch "KISS_PATH=/var/db/kiss/repo/core KISS_PROMPT=0 kiss build baseinit" >/dev/null
+    mkdir -p $M/etc/sv $M/var/service
+    sv() { # sv NAME COMMAND: runit service
+        mkdir -p $M/etc/sv/$1
+        printf '#!/bin/sh\nexec %s\n' "$2" > $M/etc/sv/$1/run
+        chmod 755 $M/etc/sv/$1/run
+        ln -sfn /etc/sv/$1 $M/var/service/$1
+    }
+    p=/nix/var/nix/profiles/waybase/bin
+    sv udevd "$p/udevd"
+    sv seatd "$p/seatd -g video"
+    # udev database for libinput, then DHCP on the first wired interface
+    printf '%s\n' "$p/udevadm trigger --action=add 2>/dev/null; $p/udevadm settle 2>/dev/null" \
+        'for i in /sys/class/net/e*; do [ -e "$i" ] && udhcpc -b -i "${i##*/}" -s /etc/udhcpc.script >/dev/null 2>&1 && break; done' \
+        > $M/etc/rc.d/waybase.boot
+    cat > $M/etc/udhcpc.script <<'EOF'
+#!/bin/sh
+[ "$1" = bound ] || [ "$1" = renew ] || exit 0
+ip addr flush dev "$interface"; ip addr add "$ip/${mask:-24}" dev "$interface"
+[ -n "$router" ] && ip route add default via "${router%% *}" dev "$interface"
+: > /etc/resolv.conf; for d in $dns; do echo "nameserver $d" >> /etc/resolv.conf; done
+EOF
+    chmod 755 $M/etc/udhcpc.script
+}
+pkg() { :; }
+pkg_install() { return 1; }
+extra_pkgs() { echo seatd eudev; }
+user_add() {
+    ch "adduser -D -s /bin/sh $1"
+    for g in wheel video input audio; do ch "addgroup $1 $g" 2>/dev/null || true; done
+}
+serial_getty() { echo 'ttyS0::respawn:/usr/bin/getty -L 115200 ttyS0 vt100' >> $M/etc/inittab; }
+nix_native() { return 1; }
+nix_service() {
+    mkdir -p $M/etc/sv/nix-daemon
+    printf '#!/bin/sh\nexec /nix/var/nix/profiles/default/bin/nix-daemon\n' > $M/etc/sv/nix-daemon/run
+    chmod 755 $M/etc/sv/nix-daemon/run
+    ln -sfn /etc/sv/nix-daemon $M/var/service/nix-daemon
+}
+boot_cmd() { echo "$1" >> $M/etc/rc.d/waybase.boot; }
+kernel() {
+    k=/tmp/waybase-kernel; rm -rf $k; mkdir -p $k
+    apk fetch -q -o $k linux-lts
+    [ "${FIRMWARE:-}" = no ] || apk fetch -q -R -o $k linux-firmware
+    for f in "$k"/*.apk; do tar -xzf "$f" -C $M --keep-directory-symlink --exclude='.[A-Z]*' 2>/dev/null; done
+    rm -rf $k
+    kv=$(ls $M/lib/modules | head -1)
+    # Alpine's mkinitfs (live system) with the target's modules
+    mount --bind $M/lib/modules /lib/modules
+    mkinitfs -F "ata base cryptsetup ext4 keymap mmc nvme scsi usb virtio" -o $M/boot/initramfs-lts "$kv" >/dev/null
+    umount /lib/modules
+    KERNEL=vmlinuz-lts INITRD=initramfs-lts
+    KARGS="modules=sd-mod,usb-storage,ext4"
+    CRYPTARGS="cryptroot=UUID=$UL cryptdm=root"
+}
diff --git a/build-image.sh b/build-image.sh
new file mode 100644
index 0000000..04fa43c
--- /dev/null
+++ b/build-image.sh
@@ -0,0 +1,34 @@
+#!/bin/sh
+# build-image.sh - build waybase.img: the official Alpine "standard" ISO with
+# the installer added under /waybase. The result is a hybrid image (BIOS and
+# UEFI, CD or USB/SD). Needs docker, or run it on Alpine as root with DOCKER=0.
+#   sh build-image.sh [OUT_DIR]        (default: ./out)
+set -eu
+here=$(cd "$(dirname "$0")" && pwd)
+mkdir -p "${1:-$here/out}"; OUT=$(cd "${1:-$here/out}" && pwd)
+build() {
+    set -eu
+    apk add -q xorriso
+    V=$(cut -d. -f1,2 /etc/alpine-release) R=$(cat /etc/alpine-release)
+    ISO=$OUT/alpine-standard-$R-x86_64.iso
+    [ -s "$ISO" ] || wget -q -O "$ISO" "https://dl-cdn.alpinelinux.org/alpine/v$V/releases/x86_64/${ISO##*/}"
+    W=$(mktemp -d); mkdir -p "$W/waybase"
+    cp -r "$SRC/install.sh" "$SRC/answers.example" "$SRC/backends" "$W/waybase/"
+    # serial console as well as the screen, so the installer runs headless too
+    xorriso -osirrox on -indev "$ISO" -extract /boot/grub/grub.cfg "$W/grub.cfg" \
+        -extract /boot/syslinux/syslinux.cfg "$W/syslinux.cfg" 2>/dev/null
+    chmod u+w "$W"/*.cfg
+    sed -i '/modules=/s/$/ console=ttyS0,115200 console=tty0/' "$W/grub.cfg" "$W/syslinux.cfg"
+    rm -f "$OUT/waybase.img"
+    xorriso -indev "$ISO" -outdev "$OUT/waybase.img" -map "$W/waybase" /waybase \
+        -map "$W/grub.cfg" /boot/grub/grub.cfg -map "$W/syslinux.cfg" /boot/syslinux/syslinux.cfg \
+        -boot_image any replay 2>&1 | grep -E 'Written to|FAILURE' || true
+    rm -rf "$W"
+    ls -l "$OUT/waybase.img"
+}
+if [ "${DOCKER:-1}" = 0 ]; then
+    SRC=$here; build
+else
+    docker run --rm -i -v "$here:/src:ro" -v "$OUT:/out" alpine:latest sh -c \
+        "$(sed -n '/^build() {/,/^}/p' "$0"); SRC=/src OUT=/out build"
+fi
diff --git a/install.sh b/install.sh
new file mode 100644
index 0000000..65ffae8
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,307 @@
+#!/bin/sh
+# waybase installer: install KISS, Gentoo, Arch or Alpine with Nix and a
+# Wayland compositor onto a blank disk. Runs as root from the waybase live
+# image (Alpine), with network:   sh /media/*/waybase/install.sh [ANSWERS]
+#
+# ANSWERS is an optional shell file that presets any of the variables below
+# (see answers.example). Unset variables are asked for interactively.
+#   BASE        kiss | gentoo | arch | alpine
+#   COMPOSITOR  sway | river (river-classic 0.3) | labwc | dwl  (a list installs several; the
+#               first one starts on tty1 login)
+#   DISK        target disk, for example /dev/sda. ALL DATA ON IT IS ERASED.
+#   ENCRYPT     yes | no (LUKS2 on the root partition)
+#   LUKS_PASS, USERNAME, USER_PASS, ROOT_PASS, HOSTNAME_NEW
+#   YES=1       skip the "type the disk name again" check
+#   SERIAL=1    login getty on ttyS0 and kernel console there (for tests)
+#   NIXPKGS     flake ref for Nix-supplied packages (default: nixpkgs)
+#   FIRMWARE=no skip linux-firmware (VMs; real hardware usually needs it)
+set -eu
+D=$(cd "$(dirname "$0")" && pwd)
+M=/mnt/waybase
+BASES="kiss gentoo arch alpine"
+COMPOSITORS="sway river labwc dwl"
+# shellcheck disable=SC1090
+[ -n "${1:-}" ] && . "$(cd "$(dirname "$1")" && pwd)/${1##*/}"
+
+log() { printf '\n=== %s\n' "$*"; }
+die() { echo "ERROR: $*" >&2; exit 1; }
+ask() { # ask VAR "prompt" [secret]: read VAR unless the answers file set it
+    eval "[ -n \"\${$1:-}\" ]" && return 0
+    printf '%s: ' "$2"
+    [ -n "${3:-}" ] && stty -echo
+    read -r v
+    [ -n "${3:-}" ] && { stty echo; echo; }
+    eval "$1=\$v"
+}
+menu() { # menu VAR "prompt" choice...: numbered menu unless VAR is set
+    eval "[ -n \"\${$1:-}\" ]" && return 0
+    var=$1 prompt=$2; shift 2
+    i=0; for o; do i=$((i + 1)); echo "  $i) $o"; done
+    while :; do
+        printf '%s [1-%d]: ' "$prompt" $#; read -r n
+        case $n in '' | *[!0-9]*) continue ;; esac
+        [ "$n" -ge 1 ] && [ "$n" -le $# ] && break
+    done
+    eval "$var=\${$n}"
+}
+in_list() { case " $2 " in *" $1 "*) return 0 ;; esac; return 1; }
+ch() { chroot $M /bin/sh -c "$*"; }
+
+# --- questions -----------------------------------------------------------
+menu BASE "base system" $BASES
+in_list "$BASE" "$BASES" || die "unknown BASE: $BASE"
+menu COMPOSITOR "compositor" $COMPOSITORS
+for c in $COMPOSITOR; do in_list "$c" "$COMPOSITORS" || die "unknown compositor: $c"; done
+lsblk -dno NAME,SIZE,MODEL,TRAN | grep -v '^loop' || true
+ask DISK "target disk (for example /dev/sda) - ALL DATA ON IT IS ERASED"
+[ -b "$DISK" ] || die "not a block device: $DISK"
+grep -q "^$DISK" /proc/mounts && die "$DISK is mounted (is it the boot media?)"
+if [ "${YES:-}" != 1 ]; then
+    printf 'Type %s again to erase it: ' "$DISK"; read -r again
+    [ "$again" = "$DISK" ] || die aborted
+fi
+menu ENCRYPT "encrypt the root partition with LUKS2" yes no
+if [ "$ENCRYPT" = yes ]; then ask LUKS_PASS "LUKS passphrase" s; fi
+ask USERNAME "user name"
+echo "$USERNAME" | grep -Eq '^[a-z_][a-z0-9_-]{0,31}$' || die "bad user name: $USERNAME"
+ask USER_PASS "password for $USERNAME" s
+ask ROOT_PASS "password for root" s
+HOSTNAME_NEW=${HOSTNAME_NEW:-waybase}
+NIXPKGS=${NIXPKGS:-nixpkgs}
+if [ "${SERIAL:-}" = 1 ]; then CONSOLE="console=tty0 console=ttyS0,115200"; else CONSOLE=""; fi
+
+# --- live tools ----------------------------------------------------------
+log "live tools"
+if ! ip route | grep -q '^default'; then
+    for i in /sys/class/net/e*; do
+        i=${i##*/}; ip link set "$i" up && timeout 30 udhcpc -q -n -i "$i" >/dev/null 2>&1 && break
+    done
+fi
+ip route | grep -q '^default' || die "no network (plug in Ethernet, or run setup-interfaces)"
+V=$(cut -d. -f1,2 /etc/alpine-release)
+grep -q '^http' /etc/apk/repositories ||
+    printf 'https://dl-cdn.alpinelinux.org/alpine/v%s/%s\n' "$V" main "$V" community >> /etc/apk/repositories
+apk update -q
+apk add -q sfdisk wipefs e2fsprogs cryptsetup dosfstools lsblk blkid grub grub-efi grub-bios \
+    curl tar xz zstd mkinitfs util-linux-misc ca-certificates
+modprobe -a ext4 vfat nls_cp437 nls_iso8859-1 dm_crypt 2>/dev/null || true
+
+# --- disk ----------------------------------------------------------------
+# GPT: BIOS boot, ESP, /boot (ext4, unencrypted so GRUB needs no LUKS
+# support), root (ext4, optionally on LUKS2).
+log "partition $DISK"
+case $DISK in *[0-9]) P=${DISK}p ;; *) P=$DISK ;; esac
+wipefs -aq "$DISK"
+sfdisk -q "$DISK" <<EOF
+label: gpt
+size=1MiB, type=21686148-6449-6E6F-744E-656564454649, name=bios
+size=512MiB, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, name=esp
+size=1GiB, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, name=boot
+type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, name=root
+EOF
+mdev -s 2>/dev/null || true; sleep 1
+mkfs.vfat -F32 -n ESP "${P}2" >/dev/null
+mkfs.ext4 -qF -L boot "${P}3"
+ROOTDEV=${P}4
+if [ "$ENCRYPT" = yes ]; then
+    printf %s "$LUKS_PASS" | cryptsetup -q luksFormat --type luks2 "${P}4" -
+    printf %s "$LUKS_PASS" | cryptsetup open "${P}4" root -
+    ROOTDEV=/dev/mapper/root
+fi
+mkfs.ext4 -qF -L root "$ROOTDEV"
+mkdir -p $M && mount "$ROOTDEV" $M
+mkdir -p $M/boot && mount "${P}3" $M/boot
+mkdir -p $M/boot/efi && mount "${P}2" $M/boot/efi
+UB=$(blkid -s UUID -o value "${P}3") UE=$(blkid -s UUID -o value "${P}2")
+UL=$(blkid -s UUID -o value "${P}4") UR=$(blkid -s UUID -o value "$ROOTDEV")
+
+chroot_mounts() {
+    mkdir -p $M/proc $M/sys $M/dev $M/run $M/tmp
+    for f in proc sys dev; do mount --rbind /$f $M/$f; done
+    mount -t tmpfs tmpfs $M/run
+    rm -f $M/etc/resolv.conf; cp /etc/resolv.conf $M/etc/resolv.conf
+}
+
+# --- base system ---------------------------------------------------------
+# Each backend defines:
+#   bootstrap       base system in $M (calls chroot_mounts), init, seat, network
+#   pkg NAME        native package(s) for a logical name; empty = take it from Nix
+#   pkg_install P.. install native packages; nonzero = fall back to Nix
+#   extra_pkgs      logical names this base also needs (from Nix when not native)
+#   user_add USER, serial_getty, nix_service, boot_cmd CMD (run CMD at boot)
+#   nix_native      install Nix from the base's own packages; nonzero = use the
+#                   release tarball
+#   kernel          kernel + initramfs; sets KERNEL, INITRD (relative to /boot),
+#                   KARGS, CRYPTARGS
+# shellcheck disable=SC1090
+. "$D/backends/$BASE.sh"
+log "bootstrap $BASE"
+bootstrap
+echo "$HOSTNAME_NEW" > $M/etc/hostname
+cat > $M/etc/fstab <<EOF
+UUID=$UR / ext4 defaults,noatime 0 1
+UUID=$UB /boot ext4 defaults,noatime 0 2
+UUID=$UE /boot/efi vfat defaults 0 2
+EOF
+
+log "user $USERNAME"
+user_add "$USERNAME"
+printf 'root:%s\n%s:%s\n' "$ROOT_PASS" "$USERNAME" "$USER_PASS" | ch chpasswd
+[ "${SERIAL:-}" = 1 ] && serial_getty
+
+# --- Nix -----------------------------------------------------------------
+log "Nix (multi-user daemon)"
+mkdir -p $M/etc/nix
+printf 'experimental-features = nix-command flakes\nbuild-users-group = nixbld\n' > $M/etc/nix/nix.conf
+if ! nix_native; then
+    # Official release tarball, registered by hand (what its installer does,
+    # without needing bash or useradd, which KISS lacks).
+    NV=$(curl -fsSL https://nixos.org/nix/install | grep -o 'releases.nixos.org/nix/nix-[0-9.]*[0-9]' | head -1)
+    NV=${NV##*/nix-}
+    T=/tmp/nix-$NV-x86_64-linux
+    curl -fsSL "https://releases.nixos.org/nix/nix-$NV/nix-$NV-x86_64-linux.tar.xz" | tar -xJ -C $M/tmp
+    mkdir -p $M/nix/store $M/nix/var/nix/profiles/per-user $M/nix/var/nix/gcroots
+    mv $M$T/store/* $M/nix/store/
+    NIX=$(cd $M/nix/store && ls -d ./*-nix-"$NV" | head -1); NIX=/nix/store/${NIX#./}
+    CA=$(cd $M/nix/store && ls -d ./*-nss-cacert-* | head -1); CA=/nix/store/${CA#./}
+    ch "$NIX/bin/nix-store --load-db < $T/.reginfo && $NIX/bin/nix-env -p /nix/var/nix/profiles/default -i $NIX $CA" >/dev/null
+    rm -rf "$M$T"
+    if ! grep -q '^nixbld:' $M/etc/group; then
+        members=""
+        i=1; while [ $i -le 32 ]; do
+            members="$members${members:+,}nixbld$i"
+            echo "nixbld$i:x:$((30000 + i)):30000:Nix build user $i:/var/empty:/sbin/nologin" >> $M/etc/passwd
+            echo "nixbld$i:!:19000:0:99999:7:::" >> $M/etc/shadow
+            i=$((i + 1))
+        done
+        echo "nixbld:x:30000:$members" >> $M/etc/group
+    fi
+    ch "chgrp nixbld /nix/store && chmod 1775 /nix/store"
+fi
+nix_service
+NIXP=/nix/var/nix/profiles/waybase
+cat > $M/etc/profile.d/waybase.sh <<EOF
+# waybase: Nix in PATH, Nix-supplied programs, compositor on tty1 login.
+for f in /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh /etc/profile.d/nix-daemon.sh.nix; do
+    [ -r "\$f" ] && . "\$f" && break
+done
+export PATH="\$HOME/.nix-profile/bin:$NIXP/bin:\$PATH"
+export XDG_DATA_DIRS="$NIXP/share:\${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
+if [ "\$(id -u)" -ne 0 ] && [ -z "\${WAYLAND_DISPLAY:-}" ] && [ "\$(tty)" = /dev/tty1 ]; then
+    exec waybase-session
+fi
+EOF
+
+# --- compositor, terminal, launcher, audio ------------------------------
+log "Wayland session: $COMPOSITOR"
+want="$COMPOSITOR foot fuzzel pipewire wireplumber"
+in_list dwl "$COMPOSITOR" && want="$want wmenu"
+fromnix=""
+for p in $want $(extra_pkgs); do
+    n=$(pkg "$p")
+    if [ -n "$n" ] && pkg_install "$n"; then echo "native: $p ($n)"; else fromnix="$fromnix $p"; fi
+done
+[ -e $M/etc/fonts/fonts.conf ] || fromnix="$fromnix dejavu_fonts fontconfig"
+if [ -n "$fromnix" ]; then
+    # Nix GL apps on a foreign distro find Mesa through /run/opengl-driver.
+    fromnix="$fromnix mesa"
+    echo "from Nix:$fromnix"
+    refs=""
+    for p in $fromnix; do
+        case $p in river) p=river-classic ;; esac  # river 0.4+ needs a separate WM
+        refs="$refs $NIXPKGS#$p"
+    done
+    ch "f=/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh; [ -r \$f ] && . \$f; nix profile install --profile $NIXP $refs"
+    [ -e $M/etc/fonts/fonts.conf ] || { mkdir -p $M/etc/fonts && cat > $M/etc/fonts/fonts.conf <<EOF
+<?xml version="1.0"?>
+<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
+<fontconfig><dir>$NIXP/share/fonts</dir><cachedir>/var/cache/fontconfig</cachedir></fontconfig>
+EOF
+    }
+    boot_cmd "ln -sfn $NIXP /run/opengl-driver"
+fi
+echo "$fromnix" > $M/etc/waybase-from-nix
+set -- $COMPOSITOR
+cat > $M/usr/local/bin/waybase-session <<EOF
+#!/bin/sh
+# Start the default compositor ($1) with PipeWire.
+: "\${XDG_RUNTIME_DIR:=/tmp/runtime-\$(id -u)}"; export XDG_RUNTIME_DIR
+mkdir -p -m 700 "\$XDG_RUNTIME_DIR"
+if [ ! -d /run/systemd/system ]; then  # systemd user units start PipeWire on Arch
+    pipewire & sleep 1; wireplumber & pipewire-pulse &
+fi
+command -v dbus-run-session >/dev/null && exec dbus-run-session -- "\${1:-$1}"
+exec "\${1:-$1}"
+EOF
+chmod 755 $M/usr/local/bin/waybase-session
+
+# Small per-compositor configs: Super+Return = foot, Super+d = fuzzel.
+H=$M/home/$USERNAME/.config
+for c in $COMPOSITOR; do case $c in
+sway) mkdir -p $H/sway && cat > $H/sway/config <<'EOF'
+set $mod Mod4
+bindsym $mod+Return exec foot
+bindsym $mod+d exec fuzzel
+bindsym $mod+Shift+q kill
+bindsym $mod+Shift+e exit
+bindsym $mod+h focus left
+bindsym $mod+l focus right
+bindsym $mod+1 workspace number 1
+bindsym $mod+2 workspace number 2
+bindsym $mod+Shift+1 move container to workspace number 1
+bindsym $mod+Shift+2 move container to workspace number 2
+EOF
+;;
+river) mkdir -p $H/river && cat > $H/river/init <<'EOF'
+#!/bin/sh
+riverctl map normal Super Return spawn foot
+riverctl map normal Super D spawn fuzzel
+riverctl map normal Super+Shift Q close
+riverctl map normal Super+Shift E exit
+riverctl map normal Super J focus-view next
+riverctl map normal Super K focus-view previous
+riverctl default-layout rivertile
+rivertile -view-padding 4 -outer-padding 4 &
+EOF
+chmod 755 $H/river/init ;;
+labwc) mkdir -p $H/labwc && cat > $H/labwc/rc.xml <<'EOF'
+<?xml version="1.0"?>
+<labwc_config>
+  <keyboard>
+    <default />
+    <keybind key="W-Return"><action name="Execute" command="foot" /></keybind>
+    <keybind key="W-d"><action name="Execute" command="fuzzel" /></keybind>
+  </keyboard>
+</labwc_config>
+EOF
+;;
+dwl) ;; # dwl is configured at build time: Alt+Shift+Return = foot, Alt+p = wmenu
+esac; done
+ch "chown -R $USERNAME:\$(id -g $USERNAME) /home/$USERNAME"
+
+# --- kernel, initramfs, bootloader --------------------------------------
+log "kernel + initramfs"
+kernel
+if [ "$ENCRYPT" = yes ]; then ROOTARG="root=/dev/mapper/root $CRYPTARGS"; else ROOTARG="root=UUID=$UR"; fi
+log "GRUB (UEFI removable path + BIOS)"
+grub-install --target=x86_64-efi --efi-directory=$M/boot/efi --boot-directory=$M/boot --removable --no-nvram >/dev/null
+grub-install --target=i386-pc --boot-directory=$M/boot "$DISK" >/dev/null
+cat > $M/boot/grub/grub.cfg <<EOF
+set timeout=3
+menuentry "waybase: $BASE" {
+    search --no-floppy --fs-uuid --set=root $UB
+    linux /$KERNEL $ROOTARG rootfstype=ext4 rw $KARGS $CONSOLE
+    initrd /$INITRD
+}
+EOF
+
+log "unmount"
+sync
+for p in /proc/[0-9]*; do  # daemons left by package scripts (gpg-agent, ...)
+    [ "$(readlink "$p/root" 2>/dev/null)" = $M ] && kill "${p#/proc/}" 2>/dev/null || true
+done
+sleep 2
+umount -l $M/proc $M/sys $M/dev $M/run 2>/dev/null || true
+umount $M/boot/efi $M/boot $M
+[ "$ENCRYPT" = yes ] && cryptsetup close root
+log "DONE: $BASE with $COMPOSITOR on $DISK. Remove the install media and reboot."
diff --git a/test-qemu.py b/test-qemu.py
new file mode 100644
index 0000000..3d7d12d
--- /dev/null
+++ b/test-qemu.py
@@ -0,0 +1,157 @@
+#!/usr/bin/env python3
+"""Unattended QEMU test of waybase.img for one base.
+
+Usage: test-qemu.py IMG WORKDIR BASE "COMPOSITORS" [encrypt|plain]
+Stdlib only. Env: QEMU, QEMU_IMG, OVMF (UEFI firmware), MEM (MiB, 2048),
+ACCEL (kvm|tcg), CPU (default Nehalem = x86-64-v2, so no binary may need AVX).
+
+1. UEFI boot of the live image as a USB stick; install.sh with an answers file.
+2. BIOS boot of the installed disk to the passphrase or login prompt.
+3. UEFI boot of the installed disk: unlock, log in as the user, then check
+   `nix --version`, the nix daemon, and that each compositor starts headless
+   and creates a Wayland socket.
+Exit 0 only if every check passes. The serial log goes to WORKDIR/BASE.log.
+"""
+import os, re, socket, subprocess, sys, time
+
+img, work, base, comps = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
+encrypt = (sys.argv[5] if len(sys.argv) > 5 else "encrypt") == "encrypt"
+disk = os.path.join(work, f"{base}.qcow2")
+QEMU = os.environ.get("QEMU", "qemu-system-x86_64")
+OVMF = os.environ.get("OVMF", "/usr/share/ovmf/bios.bin")
+ACCEL, CPU = os.environ.get("ACCEL", "kvm"), os.environ.get("CPU", "Nehalem")
+LOG = open(os.path.join(work, f"{base}.log"), "a", encoding="utf-8", errors="replace")
+PW = {"luks": "test-luks", "user": "test-user", "root": "test-root"}
+
+
+def note(s):
+    LOG.write(f"\n##### {s}\n"); LOG.flush(); print(f"##### {base}: {s}", flush=True)
+
+
+class VM:
+    def __init__(self, uefi, live, port=45560):
+        args = [QEMU, "-accel", ACCEL, "-cpu", CPU, "-m", os.environ.get("MEM", "2048"), "-smp", "2",
+                "-display", "none", "-monitor", "none",
+                "-chardev", f"socket,id=s0,host=127.0.0.1,port={port},server=on,wait=on",
+                "-serial", "chardev:s0", "-nic", "user,model=virtio-net-pci",
+                "-drive", f"file={disk},if=virtio,format=qcow2,cache=unsafe"]
+        if uefi:
+            args += ["-bios", OVMF]
+        if live:
+            args += ["-device", "qemu-xhci", "-drive", f"if=none,id=usb,file={img},format=raw,readonly=on",
+                     "-device", "usb-storage,drive=usb,bootindex=0"]
+        self.p = subprocess.Popen(args)
+        for _ in range(150):
+            try:
+                self.s = socket.create_connection(("127.0.0.1", port)); break
+            except OSError:
+                time.sleep(0.2)
+        else:
+            sys.exit("FAIL: no QEMU serial socket")
+        self.buf = ""
+
+    def expect(self, pat, timeout):
+        end = time.time() + timeout
+        while True:
+            m = re.search(pat, self.buf, re.S)
+            if m:
+                self.buf = self.buf[m.end():]
+                return m
+            if time.time() > end or self.p.poll() is not None:
+                self.kill()
+                sys.exit(f"FAIL: {base}: timeout or QEMU exit waiting for {pat!r}")
+            self.s.settimeout(5)
+            try:
+                d = self.s.recv(65536)
+            except socket.timeout:
+                continue
+            if not d:
+                sys.exit(f"FAIL: {base}: serial closed waiting for {pat!r}")
+            t = d.decode("utf-8", "replace")
+            if "\x1b[6n" in t:  # busybox ash asks for the cursor position
+                self.s.sendall(b"\x1b[50;200R")
+            LOG.write(t); LOG.flush()
+            self.buf = (self.buf + t)[-200000:]
+
+    def send(self, line):
+        self.s.sendall((line + "\r").encode())
+
+    def run(self, script, timeout=600):
+        """Run script with sh and return the output between markers."""
+        self.buf = ""
+        self.send("sh <<'EOS'\recho @@BEG\"\"@@\r" + script.strip().replace("\n", "\r") + "\recho @@END\"\"@@\rEOS")
+        return self.expect(r"@@BEG@@(.*?)@@END@@", timeout).group(1)
+
+    def kill(self):
+        self.p.kill(); self.p.wait()
+
+
+def login(vm, user, pw):
+    vm.expect(r"login: ", 1800)
+    vm.send(user)
+    vm.expect("Password:", 60)
+    vm.send(pw)
+    time.sleep(10)
+
+
+fails = []
+def check(name, ok, detail=""):
+    note(f"CHECK {name}: {'ok' if ok else 'FAIL'} {detail}")
+    ok or fails.append(name)
+
+
+if os.path.exists(disk):
+    os.remove(disk)
+subprocess.run([os.environ.get("QEMU_IMG", "qemu-img"), "create", "-q", "-f", "qcow2", disk, "32G"], check=True)
+note(f"compositors={comps} encrypt={encrypt} accel={ACCEL} cpu={CPU}")
+
+note("1/3 UEFI live boot + unattended install")
+t0 = time.time()
+vm = VM(uefi=True, live=True)
+vm.expect("login:", 900); vm.send("root"); time.sleep(5)
+answers = (f"BASE={base}\nCOMPOSITOR='{comps}'\nDISK=/dev/vda\nYES=1\nSERIAL=1\nFIRMWARE=no\n"
+           f"ENCRYPT={'yes' if encrypt else 'no'}\nLUKS_PASS={PW['luks']}\nUSERNAME=tester\n"
+           f"USER_PASS={PW['user']}\nROOT_PASS={PW['root']}\n")
+vm.run(f"cat > /tmp/answers <<'EOA'\n{answers}EOA")
+vm.send("sh /media/*/waybase/install.sh /tmp/answers; echo INSTALL_RC=$?; sleep 2; poweroff")
+m = vm.expect(r"INSTALL_RC=(\d+)", 4 * 3600)
+vm.p.wait(300)
+note(f"install took {int(time.time() - t0)} s")
+if int(m.group(1)):
+    sys.exit(f"FAIL: {base}: install.sh rc={m.group(1)}")
+
+prompt = "Enter passphrase|Passphrase|passphrase for" if encrypt else "login: "
+note("2/3 BIOS boot of the installed disk")
+vm = VM(uefi=False, live=False); vm.expect(prompt, 900); vm.kill()
+check("BIOS boot reaches " + ("passphrase" if encrypt else "login"), True)
+
+note("3/3 UEFI boot of the installed disk + checks")
+vm = VM(uefi=True, live=False)
+if encrypt:
+    vm.expect(prompt, 900); time.sleep(2); vm.send(PW["luks"])
+login(vm, "tester", PW["user"])
+r = vm.run(r"""
+. /etc/profile >/dev/null 2>&1
+nix --version 2>&1 | head -1 | sed 's/^/NIXVER /'
+nix store info --store daemon >/dev/null 2>&1 || nix store ping --store daemon >/dev/null 2>&1 && echo NIXDAEMON_OK
+for b in foot fuzzel pipewire wireplumber waybase-session; do command -v $b >/dev/null || echo MISSING:$b; done
+echo FROMNIX $(cat /etc/waybase-from-nix)
+for c in %s; do
+  d=$(mktemp -d); chmod 700 $d
+  XDG_RUNTIME_DIR=$d WLR_BACKENDS=headless WLR_RENDERER=pixman WLR_LIBINPUT_NO_DEVICES=1 $c >/tmp/$c.log 2>&1 &
+  pid=$!; i=0
+  while [ $i -lt 30 ] && ! ls $d | grep -q '^wayland-[0-9]*$'; do sleep 1; i=$((i+1)); done
+  if ls $d | grep -q '^wayland-[0-9]*$'; then echo UP:$c; else echo DOWN:$c; tail -5 /tmp/$c.log; fi
+  kill $pid 2>/dev/null; sleep 1
+done
+""" % comps, 900)
+check("nix --version", "NIXVER nix (Nix)" in r, (re.search(r"NIXVER (.*)", r) or [None, ""])[1].strip())
+check("nix daemon reachable", "NIXDAEMON_OK" in r)
+check("session programs", "MISSING:" not in r, " ".join(re.findall(r"MISSING:\S+", r)))
+note("supplied by Nix: " + (re.search(r"FROMNIX(.*)", r) or [None, ""])[1].strip())
+for c in comps.split():
+    check(f"{c} starts headless", f"UP:{c}" in r)
+vm.send("exit"); time.sleep(3)
+vm.kill()
+note(f"QEMU TEST {base} " + ("PASSED" if not fails else "FAILED: " + ", ".join(fails)))
+sys.exit(1 if fails else 0)
diff --git a/test.sh b/test.sh
new file mode 100644
index 0000000..7dc16ba
--- /dev/null
+++ b/test.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+# test.sh WORKDIR BASE "COMPOSITORS" [encrypt|plain]
+# Runs test-qemu.py on WORKDIR/waybase.img inside an Alpine container with KVM.
+set -eu
+here=$(cd "$(dirname "$0")" && pwd)
+W=$(cd "$1" && pwd); shift
+docker run --rm -i --device /dev/kvm -v "$here:/src:ro" -v "$W:/work" alpine:latest sh -c \
+    'apk add -q qemu-system-x86_64 qemu-img ovmf python3 >/dev/null 2>&1 || command -v qemu-img >/dev/null
+     exec python3 -u /src/test-qemu.py /work/waybase.img /work "$@"' sh "$@"