Skip to content

System Image Build

How the flashable SD artifact (.img) is constructed end-to-end: pipeline choice, base OS pinning, stage layout, partition table, build-host setup, and CI integration. Read this if you are about to add a new system service, change a device-tree overlay, or reshape the SD partitions; engineers tweaking a single systemd unit should also skim this for stage placement.


The system image is built with pi-gen (the Raspberry Pi Foundation’s official Raspberry Pi OS image generator), targeting the Lite arm64 flavor. We do not use debootstrap directly. Reasons:

  • Stage management. pi-gen ships a stage-numbered overlay system (stage0/stage1/stage2/…) that maps cleanly onto our base/runtime/firmware layering. Each stage is a directory of shell hooks + a prerun.sh/run.sh/run-chroot.sh triplet; adding “the KN-86 nOSh runtime” is one new stage directory.
  • Pi-firmware bundling. pi-gen embeds the vendor-supplied VideoCore GPU bootloader (bootcode.bin, start*.elf, fixup*.dat) and the Pi-flavored kernel into the image with the right partition layout. Doing this from raw debootstrap means re-implementing the work pi-gen already did upstream.
  • tryboot support. pi-gen produces images that work with the Pi firmware’s native A/B tryboot flow, which is the path ADR-0011 commits to. No bootloader replacement, no GRUB, no u-boot.
  • Read-only-root option. pi-gen’s stage2/stage3 hooks include built-in support for overlayroot/overlayfs configuration (kiosk-mode.md). We don’t have to roll our own.

Fallback: if pi-gen ever blocks us (e.g., a pi-gen regression that drags in a package we can’t take, or a base-image bug), debootstrap + manual partition assembly is the escape hatch — but it is a ~1-week setback, not a parallel maintained pipeline.

The base OS is Raspberry Pi OS Lite (arm64) built on Debian 13 “trixie” (released March 2026), per ../../adr/ADR-0026-pi-os-trixie-base-pin.md. Exact pinning — including the snapshot.debian.org timestamp and the pi-gen tag — is TBD pending bring-up. Once Stage 0 (Bring-up) of ../hardware/build-specification.md §4 lands a known-good kernel + Raspberry Pi OS Lite (arm64) trixie combination that survives the 50-cycle USB-MSC regression test in ADR-0011 §Risks #1, that triplet (kernel version, pi-gen tag, snapshot.debian.org timestamp) becomes the v1 pin and is committed to tools/sd-provision/pi-gen-pin.env. Do not bump the pin without re-running the 50-cycle test.

The KN-86 image extends pi-gen with three project-specific stages on top of the upstream base stages:

StageSourcePurpose
stage0pi-gen upstreamMinimal Debian bootstrap (debootstrap, locale, base packages).
stage1pi-gen upstreamFirst-boot init, fstab, raspberrypi-bootloader, kernel install.
stage2-litepi-gen upstreamPi OS Lite — no desktop.
stage-kn86-basethis repoHardening, package pruning, kiosk-user creation (nosh:nosh), polkit lockdown, journald → volatile, getty disable on tty2–6. See kiosk-mode.md.
stage-kn86-runtimethis reponOSh binary + assets installed under /opt/nosh/. systemd units copied into /etc/systemd/system/. udev rules (cartridge USB-MSC subscriber per update-system.md §SD partition layout). See boot-and-systemd.md.
stage-kn86-firmwarethis repoDevice-tree overlays for SSD1322 OLED, USB hub topology, UART0 to Pico 2 (device-tree-overlays.md). config.txt snippets. Pico 2 firmware UF2 deposited at /lib/firmware/kn86-pico.uf2. CIPHER-LINE bezel/tty assets.

Each stage-kn86-* lives at tools/sd-provision/pi-gen-stages/<stage>/ in this repo and is symlinked into the pi-gen working tree at build time by the provisioning script.

The output of pi-gen + our stage overlays is a single .img written with the six-partition A/B layout from ADR-0011 §SD partition layout. Do not invent alternative partition tables — that table is the source of truth. Quick reference:

#DeviceFSSizePurpose
p1/dev/mmcblk0p1FAT32~64 MBCommon boot region (autoboot.txt, bootcode, shared stage-1)
p2/dev/mmcblk0p2FAT32~256 MBBootfs slot A (kernel, cmdline.txt, config.txt, initramfs, dtbs)
p3/dev/mmcblk0p3FAT32~256 MBBootfs slot B
p4/dev/mmcblk0p4ext4(rest)/2Rootfs slot A
p5/dev/mmcblk0p5ext4(rest)/2Rootfs slot B
p6/dev/mmcblk0p6ext4~512 MB/home/shared — Universal Deck State, cart save passthrough cache. Never written by the image build or the flasher.

A first-boot install populates slot A only (p2 + p4). Slot B (p3 + p5) is left empty until the first successful field update. p6 is created empty and is owned by nOSh’s deck-state writer at runtime.

pi-gen is Linux x86_64 only. From an Apple Silicon Mac, run the build inside a --platform=linux/amd64 Docker container; without the platform flag, docker run silently uses aarch64 and the build will fail in subtle places.

Terminal window
# From repo root, on a Linux x86_64 host (or in an amd64 container):
cd tools/sd-provision
./build-image.sh \
--pi-gen-tag $(grep '^PI_GEN_TAG=' pi-gen-pin.env | cut -d= -f2) \
--output build/kn86-v$(git describe --tags).img

Cross-building from macOS:

Terminal window
docker run --rm -it --platform=linux/amd64 \
-v "$PWD":/work -w /work \
debian:trixie \
/work/tools/sd-provision/build-image.sh

Build duration: ~30–45 min on a fresh Docker layer cache, ~10 min warm. Output is a single .img of the full SD layout, plus a .kn86fw payload built from the slot-A bootfs+rootfs (update-system.md).

SSH authorized_keys provisioning (GWP-351)

Section titled “SSH authorized_keys provisioning (GWP-351)”

Dev-mode SSH is key-onlyPasswordAuthentication no, PermitRootLogin no, ChallengeResponseAuthentication no (see kiosk-mode.md “Recovery / dev-mode toggle”). The kn86 user’s authorized_keys file is baked into the image at build time, controlled by an env var:

Terminal window
# Wrapper contract: caller sets KN86_AUTHORIZED_KEYS_FILE to a path on
# the build host containing one or more public keys (concatenated in
# OpenSSH authorized_keys format).
export KN86_AUTHORIZED_KEYS_FILE=~/.ssh/kn86-deckline-fleet.pub
./tools/sd-provision/build-image.sh ...

stage-kn86-base/00-kn86-base/02-run.sh reads that env var. If set, it installs the file to /home/kn86/.ssh/authorized_keys (mode 0600, owner 1000:1000 to match the cloud-init kn86 user). If unset, the image ships with no authorized keys and dev-mode SSH is locked out until somebody mounts the SD on a host machine and writes a key in directly.

CI release pipelines should treat the public-key file as a secret artifact (it isn’t sensitive, but mistakes are easier to fix when the path is treated formally). The matching private key is held by the operator running the converger over SSH.

The build is reproducible to the level pi-gen offers — any two runs against the same pi-gen-pin.env + the same source tree produce byte-identical .img files modulo a build timestamp embedded in /etc/kn86-build-id. We do not currently strip the timestamp; if reproducibility-by-hash becomes a release-CI requirement, that’s a one-line patch to the stage-kn86-runtime/run.sh hook.

On the very first boot of a freshly flashed SD card, a one-time provisioning job runs and then permanently disables itself.

Pi-gen’s stage1 normally installs init_resize.sh and wires it into cmdline.txt via init= so the rootfs expands to fill the SD card on first boot. This mechanism is disabled for the KN-86. The six-partition layout from ADR-0011 is fixed-size; partition resizing would destroy it.

The stage-kn86-runtime/00-kn86-runtime/01-run.sh hook:

  • Strips any init=/usr/lib/raspi-config/init_resize.sh param from cmdline.txt.
  • Replaces the resize script body with a no-op stub so that a stale reference (from a pi-gen stage we do not control) is harmless.

Target SD size: 16 GB. On a larger card the extra space is unused (available as unallocated); on a smaller card the image will not flash because the partition sizes are hard-coded. Minimum SD size is determined by the sum of all six partition sizes in the ADR-0011 layout.

A systemd oneshot unit (kn86-firstboot.service) is installed and enabled by stage-kn86-runtime. It runs early in the sysinit.target graph (before network.target), gated by:

ConditionPathExists=!/var/lib/kn86/firstboot.done

If the sentinel file is present the unit exits immediately — this is the normal case on every boot after the first.

On first boot the unit invokes /opt/nosh/bin/kn86-firstrun.sh, which:

  1. Regenerates /etc/machine-id using systemd-firstboot --machine-id. The SD image ships with a fixed machine-id baked in; each physical device must have a unique one so that journald and sd_id128_get_machine() calls return device-specific values.

  2. Writes /etc/kn86-build-id (if absent). The authoritative value is embedded in the image by stage-kn86-runtime/01-run.sh from the KN86_BUILD_ID environment variable at build time. A timestamp-based fallback (local-YYYYMMDDTHHMMSSZ) is written only when the build omitted the env var (local test builds).

  3. Touches /var/lib/kn86/firstboot.done to set the idempotency sentinel.

  4. Disables kn86-firstboot.service via systemctl disable so the unit is removed from the sysinit.target wants graph for all subsequent boots.

VariableDefaultEffect
KN86_BUILD_IDlocal-<timestamp>Embedded in /etc/kn86-build-id at image-build time. Set by CI to v<version>-<git-sha>.
KN86_BUILD_MODEproddev drops quiet from cmdline.txt (see boot-and-systemd.md “Cmdline.txt baseline”).
KN86_OVERLAYROOT01 prepends init=/init-overlay to cmdline.txt.

System locale is pinned to en_US.UTF-8 explicitly in stage-kn86-base/00-kn86-base/01-run-chroot.sh:

Terminal window
sed -i 's/^# *en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen
locale-gen en_US.UTF-8
update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8

Pi OS Lite ships with a C default; nOSh’s UTF-8 strings, journald, and any future apt prompts work better against an explicit en_US.UTF-8. The locales package is preinstalled via stage-kn86-base/00-kn86-base/00-packages.

Console keymap is intentionally NOT configured. Pi OS Lite’s keyboard-setup.service is masked by kiosk-mode.md “Disabling unused services” and nOSh owns evdev directly — the Linux console keymap is dead code for production-mode users. The custom QMK keymap that ships on the KB2040 (ADR-0024) is the operative keymap; the Linux side just receives raw HID scancodes and hands them to nOSh.

Hostnames are per-device, derived from the BCM2837 SoC serial:

kn86-XXXXXX where XXXXXX = lowercase hex, last 6 chars of /proc/cpuinfo Serial

Example: kn86-9a3f12. The full SoC serial is 16 hex chars; the last 6 give us ~16M-device uniqueness, which is more than enough across any plausible KN-86 fleet, without leaking the full serial in mDNS / ARP / dhcp-client-id.

Implementation:

  • /opt/nosh/bin/kn86-hostname-set — idempotent shell script. Reads /proc/cpuinfo, takes the last 6 chars, calls hostnamectl set-hostname kn86-XXXXXX. Bails early if the current hostname already starts with kn86-.
  • /etc/systemd/system/kn86-hostname.service — oneshot, guarded by ConditionFirstBoot=yes, runs the script before network-pre.target. Enabled at image-build time (stage-kn86-runtime/00-kn86-runtime/02-run-chroot.sh).

The unit fires once on the very first boot of a freshly-flashed image and is a no-op on every subsequent boot. If a parallel first-boot orchestrator (firstrun.sh from GWP-356) wants to call /opt/nosh/bin/kn86-hostname-set directly, both call paths are idempotent and safe to invoke in either order.

The cloud-init seed user (kn86, set by Pi Imager) creates the host with whatever hostname the imager dialog asked for — typically deckline or raspberrypi. The kn86-hostname unit overwrites that on first boot.

Today the release CI in release-setup.md builds emulator binaries only. System-image CI will graft onto the same release.yml workflow as a new job (Build (kn86-image)) that runs the tools/sd-provision/build-image.sh script in an ubuntu-latest runner with --platform=linux/amd64 (GitHub Actions runners are already x86_64), uploads the .img and .kn86fw as release assets to the private monorepo only (no public mirror — system images carry kernel + closed-source firmware blobs), and runs the SHA-256 verification step that the existing kn86fw builder performs.

Trigger model: same tag schema as the emulator (v0.2.0, v0.2.0-rc1). The image-build job is gated on the tools/sd-provision/pi-gen-pin.env file existing (a missing pin is a release blocker — see “Base Debian release pinning” above).