Build flashable Alpine Linux .img files for ARM single-board computers — no root required.
Handles kernel compilation (in a QEMU-emulated aarch64 chroot), U-Boot integration, Alpine rootfs assembly, user/package configuration, image partitioning, image signing, and optional publishing — entirely as a normal user.
| Board | SoC | Status |
|---|---|---|
| Orange Pi 5 Plus | RK3588 | End-to-end build verified; hardware-tested on real silicon (with the touchscreen / Mali / DMC / PCIe boot fixes documented in CLAUDE.md). |
| Phase | What happens |
|---|---|
| 1 — validate | Parse config.toml, deep-validate board.toml (validate_board_static: bad SoC string, slashes/extension in dts, baud rate not in standard list, unsupported arch, reserved tokens in extra_cmdline, root_part_label not matching any partition label), check host tools. |
| 2 — prepare | Download Alpine minirootfs (edge: resolves filename + sha256 from latest-releases.yaml; pinned: fetches alpine-minirootfs-X.Y.Z-aarch64.tar.gz + .sha256). |
| 3 — kernel | git clone --depth 1 --branch <branch> <source> on the host; make ARCH=arm64 <defconfig> in chroot; merge fragments via merge_config.sh -m then scripts/config --disable for every =n line; purge stale .ko; make Image modules dtbs + modules_install INSTALL_MOD_STRIP=1; tar modules. Podman kernel-feature audit fires here on the resulting .config. |
| 4 — uboot | RK3588: clone U-Boot v2024.10 + rkbin; truncate scripts/dtc/pylibfdt/Makefile (twice — defconfig regenerates it); pip install --break-system-packages pyelftools; write pkg_resources shim for binman; make with BL31=$(rkbin/bin/rk35/rk3588_bl31_v*.elf) + ROCKCHIP_TPL=$(rkbin/bin/rk35/rk3588_ddr_*v*.bin ! eyescan). Produces idbloader.img + u-boot.itb. |
| 5 — rootfs | bwrap chroot, apk update + apk add baseline (alpine-base openrc busybox-openrc mkinitfs util-linux sfdisk gptfdisk parted e2fsprogs e2fsprogs-extra dosfstools dbus openssh tzdata ifupdown-ng wireless-regdb) + user packages, write hostname/hosts/localtime/repos/fstab/interfaces/inittab/securetty, reapply baselayout files (apk stomps several), open-code user creation (single bwrap UID), lock root, write /etc/subuid + /etc/subgid for podman, drop /etc/sysctl.d/99-sbc-builder-{security,containers}.conf, write /etc/login.defs + /etc/issue + hardened /etc/ssh/sshd_config + /etc/nftables.nft + /etc/conf.d/nftables, install firstboot OpenRC service + script, write custom mkinitfs.conf + features.d/rockchip-pcie.modules, run mkinitfs, wipe machine-id + dbus uuid + random-seed (per-clone collision prevention), verify_rootfs (static walk: vmlinuz/initramfs/extlinux/DTB presence, ARM64 magic, FDT magic), then runtime healthcheck inside the chroot: sshd -t, nft -c, getent passwd <user>, getent group wheel, authorized_keys non-empty, kernel-module probes, podman info as the admin user. |
| 6 — image | truncate -s raw image, sfdisk GPT layout, mkfs.vfat -F 32 -s 2 -n <BOOT> boot.img, mcopy kernel + initramfs + extlinux + board DTB into FAT (hard-fails on missing files), mke2fs -t ext4 -b 1024 -L <root> rootfs.img with on-the-fly mode patching for unreadable Alpine files (bbsuid) + debugfs set_inode_field mode restore + sif uid 0/gid 0 whole-tree chown to root + /home/<user> chown back via /etc/passwd lookup, dd partitions into main image, dd idbloader.img at sector 64 + u-boot.itb at sector 16384, verify_image (sfdisk verify, U-Boot magic accepts RKSD/RKBO/RKNS/RK35, FAT BPB, ext4 superblock at right offset), xz -9 -T0, sha256, optional minisign, emit <image>.xz.md summary. |
| 7 — publish | Placeholder. |
| Tool | Purpose | Arch / Manjaro |
|---|---|---|
qemu-aarch64-static |
aarch64 emulation in chroot | qemu-user-static |
bwrap |
Rootless chroot | bubblewrap |
sfdisk |
Partition raw image | util-linux |
mke2fs, debugfs |
Create ext4 from directory; fix inode modes | e2fsprogs |
mkfs.vfat |
Create boot partition | dosfstools |
mcopy |
Copy files into vfat image | mtools |
dd, truncate, sha256sum |
Image ops + checksums | coreutils |
xz or zstd |
Compress output | xz / zstd |
git |
Clone kernel source | git |
minisign |
Sign images (optional) | minisign |
sudo pacman -S qemu-user-static bubblewrap util-linux e2fsprogs dosfstools mtools xz git
TMPDIR=/home/<you>/tmpdir CARGO_TARGET_DIR=/home/<you>/tmpdir/a2sbc-target cargo build --release
Binary: <CARGO_TARGET_DIR>/release/alpine2sbc
Both env vars are required for every cargo invocation in this project (rebuilds, cargo check, cargo test). Pointing them at a writable, non-/tmp location keeps the target tree off tmpfs and survives reboots.
alpine2sbc [OPTIONS] <COMMAND>
COMMANDS:
build Build a complete .img from config.toml (all phases)
kernel Build/fetch only the kernel (phases 1-3)
publish Publish built artifacts to a remote (placeholder)
list-boards List available board definitions
validate Validate config.toml + board.toml without building
flash Flash a built .img.xz to a removable block device
show-effective-config Resolve extends= chain, print merged TOML annotated with source file
check-cves Compare staged rootfs's installed packages against Alpine SecDB
TOP-LEVEL OPTIONS (apply to every subcommand):
-c, --config <PATH> Path to config.toml [default: ./config.toml]
-w, --workdir <PATH> Working directory [default: ./build]
-v Verbose (-vv for debug)
--clean Remove workdir before starting
-d, --dependencies List all required external tools
BUILD / KERNEL SUBCOMMAND OPTIONS:
-d, --delete-chroot Delete build/chroot/ before phase 1 (full bwrap rebuild).
NOTE: this -d is local to the subcommand and is distinct
from the top-level -d/--dependencies.
--resume Skip phases already marked done in .state.toml.
Mutually exclusive with --clean. Bails if config.toml
hash changed since the last run.
--cve-fail-on <SEV> build only — none|low|medium|high|critical. Default: none.
After editing the source, pick the right invocation so the fix actually lands in the next image:
| You changed | Run | Why |
|---|---|---|
| Phase 5 (rootfs) / phase 6 (image) code, or anything consumed only by them | alpine2sbc build |
Chroot is reused (kernel + U-Boot already built — big time saver); rootfs and image regenerate. Do not pass --resume — phases 5/6 are marked done in .state.toml and resume would skip the very phase you fixed. |
| Kernel source / config fragment / defconfig | alpine2sbc build -d |
Stale .config and object files inside build/chroot/build/kernel-src will silently mask the change. -d nukes the chroot so the kernel rebuilds clean. |
| U-Boot source / version pin / rkbin URL | alpine2sbc build -d |
Same reason as kernel — U-Boot tree lives inside the chroot. |
| Host-side only (validation, CLI, logging, hooks, docgen) AND a previous build was interrupted mid-phase | alpine2sbc build --resume |
Resumes from the last incomplete phase; no rework. Only safe when the change does NOT alter phase output. |
Defconfig fragment OR board.toml partition layout |
alpine2sbc build -d --clean |
Touches everything downstream — kernel, U-Boot offsets, rootfs paths. |
Rule of thumb: when in doubt, build -d. --resume is narrow — it's not a default.
cp config.toml.example config.toml # edit as needed
alpine2sbc validate # check config + host deps
alpine2sbc build # build the image
alpine2sbc flash build/output/alpine-orangepi5plus.img.xz /dev/sdXEvery image gets:
rootaccount locked (!in shadow). Admin user is inwheel;doas/sudoprovides escalation./etc/ssh/sshd_configrewritten over Alpine's default:PermitRootLogin no,PasswordAuthentication noif the admin user has at least one ssh_key (elseyes),MaxAuthTries 3,LoginGraceTime 30,ClientAliveInterval 60,ClientAliveCountMax 3,X11Forwarding no,AllowTcpForwarding no,PermitTunnel no,PermitEmptyPasswords no,ChallengeResponseAuthentication no./etc/sysctl.d/99-sbc-builder-security.conf:kernel.dmesg_restrict=1,kptr_restrict=2, full ASLR, rp_filter, ICMP redirect blocking, syncookies./etc/sysctl.d/99-sbc-builder-containers.conf(only when podman is installed):ip_forward,max_user_namespaces=15000, inotify limits./etc/nftables.nft(only whennftablespackage installed): default-deny inet filter, port 22 open, ICMP allowed; service runs in thebootrunlevel before sshd starts./etc/login.defs:UMASK 027,PASS_MAX_DAYS 90./etc/issue: legal "authorized use only" banner./etc/subuid+/etc/subgid(only when podman installed): one 65536-ID range per user starting at 100000 — required for rootless podman.firstbootOpenRC service: resizes rootfs to fill the medium, regenerates SSH host keys + machine-id (so cloned SD cards don't share), mirrors its log to both/var/log/firstboot.logand/boot/firstboot.log(the FAT path is readable from any OS via SD reader for headless triage).wireless-regdb(regulatory.db) shipped — silences cfg80211 noise and gives any future USB WiFi dongle a valid regdomain.- Build logs run through
secret::redact_log_line— shadow hashes, OpenSSH/RSA private blocks, GitHub/AWS tokens, andAuthorization:headers don't reachlogs/info.log/logs/error.log. - SIGINT / SIGTERM caught — between-phase guard (
bail_if_aborted) bails the build with a clean error when Ctrl+C arrives between phase 3 (kernel) → 5 (rootfs) → 6 (image), instead of leaving the chroot in an indeterminate state. - Post-rootfs runtime healthcheck —
sshd -t,nft -c,getent,podman info, and kernel-module probes run inside the chroot. Build aborts on a syntactically broken sshd config, missing wheel group, empty authorized_keys, or unparseable nftables ruleset — class of issuesverify_rootfs's static walk can't catch.
The image is configured for "plug a cable in and it just works" out of the box:
NetworkManageris the default network manager (/etc/init.d/networkmanagerenabled in thedefaultrunlevel). On every wired NIC, NM auto-connects DHCP the moment a carrier appears — no/etc/network/interfacesedit required.nmtui(curses UI) ships for interactive WiFi setup;wpa_supplicant,iw,pciutils,ethtoolare installed for diagnostics.ifupdown-ngis installed as an operator-reachable fallback (/etc/init.d/networkingexists) but NOT enabled in any runlevel. Two managers on the same iface fight over carrier and IP; revert withdoas rc-update del networkmanager default && doas rc-update add networking boot && doas reboot./etc/resolv.confships with operator-configured DNS first (from[network.dns]if any), then four public anycast fallbacks: 1.1.1.1 (Cloudflare), 1.1.1.2 (Cloudflare malware-blocking), 8.8.8.8, 8.8.4.4 (Google). Tail optionstimeout:2 attempts:2 rotate single-request-reopen./etc/NetworkManager/conf.d/99-fallback-dns.confputs the same four anycasts in[global-dns-domain-*] servers=.... NM's defaultdns=defaultplugin appends them to whatever DHCP supplies, so resolution survives a DHCP server that hands out no DNS.ca-certificates+ca-certificates-bundleship pre-configured (cert.pem rebuilt deterministically in chroot to dodge qemu-emulated post-install-hook silent failures);apk updateover HTTPS works on first boot.
The board family fragment + BASELINE pin every driver and firmware blob the on-board radios need. No "you have to install firmware-X" step post-deploy:
- 2.5GbE x2 (RTL8125B over PCIe) —
CONFIG_R8169=m,linux-firmware-rtl_nicforrtl_nic/rtl8125b-2.fw. - WiFi 6 (RTL8852BE over PCIe) —
CONFIG_RTW89_PCI=m,CONFIG_RTW89_8852BE=m,linux-firmware-rtw89. - WiFi/BT M.2 E-key (AP6275P / Cypress over SDIO) —
CONFIG_BRCMFMAC=m+CONFIG_BRCMFMAC_SDIO=y,linux-firmware-brcm. - Catch-all —
linux-firmware-other(~30 MiB) covers operator-hotplug hardware (USB WiFi dongles, USB Ethernet adapters).
Every image ships a 80+-section diagnostic dumper at /usr/local/sbin/debug.sh. Auto-elevates via doas/sudo. Captures everything an offline reviewer needs to triage any class of bug — kernel config + load-bearing-symbol table, dmesg, mounts, full network state (NM + NICs + firmware load events + WiFi link), OpenRC + boot trace, modules + driver triage + modprobe attempts + PCI/USB→driver alias resolution, DT status, deferred probes, /var/log tails, eMMC/NVMe wear, cpufreq + thermal + regulators, memory pressure (PSI/slab/OOM), pstore (prior-boot crash dumps), containers + podman, capabilities + file-mode audit, initramfs cpio listing, U-Boot env, boot timing reconstruction, RAID/LVM/crypt, image-build self-check (which alpine2sbc-shipped files are missing). Output to /info_debug.txt, ~3-8 MiB.
doas /usr/local/sbin/debug.sh # writes /info_debug.txtThe script is the canonical "paste this when anything is misbehaving" artifact: every confirmed fix in this builder adds a corresponding regression-check section so future operators (and future Claude instances) catch the same class of issue without an ad-hoc command sequence.
alpine2sbc flash <IMAGE> <DEVICE> [--force-not-removable] [--no-verify]
- Refuses to write to a non-removable disk unless
--force-not-removableis passed. - Refuses to write to a device with mounted partitions.
- Prompts the operator to type the device path back to confirm (no
--yesshortcut). - Auto-escalates via
doas→sudo→suwhen the user can't write the device directly. - Post-flash verifies partition table (
sfdisk -l) and U-Boot magic at offset 32 KiB (acceptsRKSD/RKBO/RKNS/RK35).
config.toml supports extends = "<path>" to layer on top of a base. After build-time merge it can be hard to know which file set what; show-effective-config prints the resolved TOML annotated with the source file per field:
alpine2sbc show-effective-config -c config.tomlAfter a build, walk the staged rootfs's apk database and compare against Alpine's SecDB advisory feed:
alpine2sbc build
alpine2sbc check-cves -c config.tomlEmits a Markdown report. Exits non-zero if any matched advisory is at or above the configured fail-on threshold (defaults from [cve] config section, currently severity-based). Useful in CI to gate releases.
A successful build writes build/build.lock capturing:
- Alpine version + (future) APKINDEX hashes
- Kernel mode, source URL, branch, version, (future) commit SHA + config hash
- U-Boot mode + URL + sha256 (when prebuilt)
- Final image SHA-256 + size in bytes
- Hash of the
config.tomlused for the build
This is the audit trail: a CI rebuild can re-derive the same .img.xz by feeding the same lockfile back. Currently rendered fields are advisory; the full reproducibility round-trip lands when --locked / --update-lock flags are wired.
Build emits the compressed image, its .sha256, and a Markdown summary <image>.xz.md next to the binary.
Set the signing key path to also produce a .minisig:
export SBC_SIGNING_KEY=~/keys/sbc-release.key
export SBC_SIGNING_PASSWORD='…' # via env, never on command line
alpine2sbc buildIf neither SBC_SIGNING_KEY nor ~/.config/alpine-sbc-builder/minisign.key exists, signing is silently skipped (dev builds work without ceremony).
config.toml is the single source of truth for every build. All sections are documented below with every field, its type, default value, and an example.
- [image] — board, version, hostname, size, filesystem
- [[users]] — user accounts (preferred format)
- [user] (legacy) — single-user shorthand
- [sudo] — doas / privilege escalation
- [packages] — APK package lists and repositories
- [services] — OpenRC services to enable
- [network] — interface and DNS configuration
- [kernel] — kernel mode: build / apk / firmware
- [uboot] — U-Boot mode: prebuilt / build
- [boot_firmware] — self-hosted firmware bundles
- [security] — SSH hardening, sysctl, nftables firewall
- [firstboot] — first-boot actions (rootfs expand, SSH key regen, …)
- [signing] — minisign image signing
- [cve] — Alpine SecDB CVE checking
- [publish] — upload artifacts (GitHub Releases, rsync, S3, local)
- [hooks] — custom shell scripts at build phases
- Config inheritance —
extendsfor base configs - Board variants — per-variant overlays
- Output filename templates
- Complete examples
Controls the target board, Alpine version, filesystem layout, and image metadata.
[image]
board = "orangepi5plus" # required — must match a directory under boards/
alpine_version = "edge" # required — "edge", "3.21", "3.20", etc.
hostname = "opi5plus" # required — hostname written into /etc/hostname
timezone = "America/New_York" # required — written into /etc/timezone
image_size_mib = 2048 # required — total raw image size in MiB (minimum 512)
output = "alpine-{board}-{version}-{date}.img" # optional — see template syntax below
rootfs_type = "ext4" # optional — "ext4" (default), "f2fs", or "btrfs"
locale = "C.UTF-8" # optional — default "C.UTF-8"Field details:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
board |
string | yes | — | Directory name under boards/. Run alpine2sbc list-boards to see options. |
alpine_version |
string | yes | — | "edge" fetches the latest tarball from CDN; "3.21" pins to a release. |
hostname |
string | yes | — | Written to /etc/hostname in the rootfs. |
timezone |
string | yes | — | Any tz database name, e.g. "UTC", "Europe/Berlin". |
image_size_mib |
integer | yes | — | Total .img size. Must be at least 512 MiB. Leave ~200 MiB headroom above your package set. |
output |
string | no | alpine-{board}-{version}.img |
Output filename. Supports {board}, {version}, {date} tokens. |
rootfs_type |
string | no | "ext4" |
Filesystem for the rootfs partition. "ext4" is safest; "f2fs" reduces writes on eMMC. |
locale |
string | no | "C.UTF-8" |
Written to /etc/locale.conf. |
Examples:
# Minimal — edge Alpine, 2 GiB image
[image]
board = "orangepi5plus"
alpine_version = "edge"
hostname = "myboard"
timezone = "UTC"
image_size_mib = 2048
# Pinned release, dated output filename, f2fs for eMMC
[image]
board = "orangepi5plus"
alpine_version = "3.21"
hostname = "server01"
timezone = "Europe/Berlin"
image_size_mib = 4096
output = "alpine-{board}-{version}-{date}.img"
rootfs_type = "f2fs"
locale = "de_DE.UTF-8"Preferred multi-user format. Each [[users]] block creates one Linux user account. At most one user may have primary = true.
[[users]]
name = "admin" # required — Linux username
password_hash = "$6$rounds=10000$salt$hash..." # recommended — sha-512 hash
# password_plain = "insecure" # alternative — stored in plaintext, avoid
shell = "/bin/ash" # optional — default "/bin/ash"
groups = ["wheel", "audio", "video", "netdev"] # optional — supplementary groups
ssh_keys = ["ssh-ed25519 AAAA... user@host"] # optional — written to ~/.ssh/authorized_keys
primary = true # optional — marks the primary user
[[users]]
name = "deploy"
password_hash = "$6$rounds=10000$salt2$hash2..."
groups = ["netdev"]
ssh_keys = ["ssh-ed25519 BBBB... ci@server"]Generate a password hash:
The password_hash field expects a SHA-512 crypt hash — the $6$... format produced by the commands below. Copy the entire output string (starting with $6$) and paste it as the field value.
# openssl — available everywhere, no extra packages needed
openssl passwd -6 'yourpassword'
# Interactive form — password never appears in shell history (recommended)
openssl passwd -6
# Python 3 — interactive, no extra packages needed
python3 -c "import crypt, getpass; print(crypt.crypt(getpass.getpass(), crypt.mksalt(crypt.METHOD_SHA512)))"
# mkpasswd — same format; requires the 'whois' package (Debian/Ubuntu)
# or 'mkpasswd' package (Arch/Manjaro)
mkpasswd -m sha-512 'yourpassword'Example output (do not use this — generate your own):
$6$rounds=10000$somesalt$abc123...longstring...
Paste the full $6$... string as the password_hash value in config.toml. The password is never stored in plain text in the image — only this hash is written to /etc/shadow.
Field details:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
name |
string | yes | — | Must be a valid Linux username (no : or /). Must be unique across all [[users]] blocks. |
password_hash |
string | one of these | — | SHA-512 hash from openssl passwd -6. |
password_plain |
string | one of these | — | Stored in plaintext in config — only use for throwaway dev images. |
shell |
string | no | "/bin/ash" |
Any shell available after package install (e.g. "/bin/bash" if bash is installed). |
groups |
array of strings | no | [] |
Supplementary groups. "wheel" grants sudo/doas access. |
ssh_keys |
array of strings | no | [] |
Each entry is one full public key line. Auto-disables SSH password auth when non-empty. |
primary |
bool | no | false |
Marks primary user for doas and shell defaults. At most one may be true. |
Single-user shorthand. Equivalent to one [[users]] block with primary = true. Triggers a deprecation warning; prefer [[users]].
[user]
name = "admin"
password_hash = "$6$rounds=10000$salt$hash..."
groups = ["wheel", "audio", "video"]
ssh_keys = ["ssh-ed25519 AAAA... user@host"]
shell = "/bin/ash"Controls privilege escalation via doas (Alpine's default) or sudo.
[sudo]
use_doas = true # optional — default true; false installs sudo instead
require_password = true # optional — default true; false = NOPASSWD
extra_rules = [] # optional — raw doas rule lines appended to /etc/doas.d/user.confField details:
| Field | Type | Default | Notes |
|---|---|---|---|
use_doas |
bool | true |
true installs and configures doas. false uses sudo. |
require_password |
bool | true |
false generates a NOPASSWD rule — useful for CI images. |
extra_rules |
array of strings | [] |
Each string is a raw doas.conf rule, e.g. "permit nopass :wheel cmd reboot". |
Example — passwordless doas for wheel with one extra rule:
[sudo]
use_doas = false # use sudo
require_password = false
extra_rules = [
"permit nopass :wheel cmd shutdown",
]APK packages to install in the rootfs. Packages are split into semantic groups (all merged and deduped at build time) plus a flat install list for simple configs.
[packages]
# Semantic groups — install whatever applies to your use case
base = [
"alpine-base", "openrc", "busybox", "mkinitfs",
"e2fsprogs", "util-linux", "blkid",
]
network = [
"ifupdown-ng", "dhcpcd", "openssh",
"iptables", "nftables", "curl",
]
system = [
"doas", "htop", "chrony", "logrotate", "eudev",
]
extra = [
"git", "tmux", "podman", "fuse-overlayfs", "slirp4netns",
]
# Flat list — alternative / addition to the groups above
install = ["micro", "jq"]
# Custom APK repositories (name = url)
[packages.repositories]
self_hosted = "https://pkg.example.com/alpine/packages"Package groups:
| Key | Purpose |
|---|---|
base |
Core system — init, busybox, filesystem tools. Required for a bootable image. |
network |
Network stack — interfaces, DHCP, SSH, firewall. |
system |
Admin tools — privilege escalation, timekeeping, logging. |
extra |
User-defined extras — anything the groups above don't cover. |
install |
Flat list merged with all groups. Use for one-liners or inherited overrides. |
All groups are union-merged and deduplicated before apk add. You can use any combination.
Custom repositories:
[packages.repositories]
# Key is a human label (any string), value is the repository URL.
armbian_pkgs = "https://repo.armbian.com/alpine/v3.21/main"
local_ci = "https://ci.internal/alpine/packages"The URL is added to /etc/apk/repositories before apk add runs.
OpenRC services to enable at boot. Services must be available after packages are installed.
[services]
enable = ["sshd", "chronyd", "nftables", "crond", "docker"]| Field | Type | Default | Notes |
|---|---|---|---|
enable |
array of strings | [] |
Each name is passed to rc-update add <name> default. |
Common services:
[services]
enable = [
"sshd", # SSH daemon
"chronyd", # NTP time sync (requires chrony package)
"nftables", # Firewall (requires nftables package)
"crond", # Cron daemon (requires dcron or similar)
"acpid", # ACPI events (requires acpid package)
"udev", # Device manager (requires eudev package)
]Static network interface and DNS configuration written to /etc/network/interfaces and /etc/resolv.conf.
[network]
[network.dns]
nameservers = ["1.1.1.1", "9.9.9.9", "2606:4700:4700::1111"]
[network.interfaces]
eth0 = { method = "dhcp" }
eth1 = { method = "static", address = "10.0.0.2", netmask = "255.255.255.0", gateway = "10.0.0.1" }Field details:
| Field | Type | Notes |
|---|---|---|
network.dns.nameservers |
array of strings | Written to /etc/resolv.conf. Supports IPv4 and IPv6. |
network.interfaces |
table | Keys are interface names. Values are ifupdown-ng stanza fields. |
Override the kernel source configured in board.toml. Three modes: "build" (compile from source), "apk" (install pre-built APK), "firmware" (fetch from firmware bundle URL).
If [kernel] is omitted, the board's board.toml settings are used.
Compile the kernel from a git repository inside the aarch64 bwrap chroot.
[kernel]
mode = "build"
[kernel.build]
source = "https://github.com/armbian/linux-rockchip.git"
branch = "rk-6.1-rkr4.1"
defconfig = "rockchip_linux_defconfig"
config_fragments = [
"boards/orangepi5plus/rk3588-server.config", # paths relative to config.toml
]| Field | Type | Required | Notes |
|---|---|---|---|
source |
string | yes | Git URL. Cloned on the host (x86_64) to avoid qemu pread bug on large pack files. |
branch |
string | yes | Branch or tag to check out. |
defconfig |
string | yes | Base defconfig target, e.g. "rockchip_linux_defconfig". |
config_fragments |
array of strings | no | .config fragment files merged via scripts/kconfig/merge_config.sh. |
Install a pre-built kernel APK from a custom repository.
[kernel]
mode = "apk"
[kernel.apk]
package = "linux-orangepi5plus"
repository = "https://pkg.example.com/alpine/packages"| Field | Type | Required | Notes |
|---|---|---|---|
package |
string | yes | APK package name. |
repository |
string | yes | Repository URL added to /etc/apk/repositories before install. |
Fetch a pre-built kernel + DTB bundle from a URL (e.g. your CI artifact server).
[kernel]
mode = "firmware"
[kernel.firmware]
url = "https://ci.example.com/firmware/orangepi5plus" # fetch from URL
# path = "/mnt/ci/firmware/orangepi5plus" # or use local path| Field | Type | Notes |
|---|---|---|
url |
string | Base URL of the firmware bundle. Mutually exclusive with path. |
path |
string | Local directory path. Mutually exclusive with url. |
Override U-Boot source/binary configured in board.toml. Two modes: "prebuilt" (download binary) or "build" (compile from source).
Download a pre-built U-Boot binary.
[uboot]
mode = "prebuilt"
url = "https://github.com/example/uboot-builds/releases/download/v2024.10/uboot-orangepi5plus.bin"
sha256 = "abc123..." # optional but strongly recommended| Field | Type | Required | Notes |
|---|---|---|---|
url |
string | yes | Download URL for the binary. |
sha256 |
string | no | Expected SHA-256 hex. Build aborts if mismatch. |
Compile U-Boot from source.
[uboot]
mode = "build"
[uboot.build]
source = "https://github.com/u-boot/u-boot.git"
tag = "v2024.10"
defconfig = "orangepi-5-plus-rk3588_defconfig"
rkbin_source = "https://github.com/rockchip-linux/rkbin.git" # optional — Rockchip BL31/TPL blobs| Field | Type | Required | Notes |
|---|---|---|---|
source |
string | yes | U-Boot git URL. |
tag |
string | yes | Tag or branch to build. |
defconfig |
string | yes | Board defconfig target. |
rkbin_source |
string | no | For Rockchip boards — git URL of the rkbin blob repo. |
Fetch pre-built boot artifacts (DTBs, U-Boot, extlinux.conf) from a self-hosted server instead of building them. Useful for CI pipelines: build kernel once, publish to a server, then reference that server in all subsequent image builds.
[boot_firmware]
base_url = "https://ci.example.com/firmware/orangepi5plus"When base_url is set, phases 3 (kernel) and 4 (U-Boot) are skipped and artifacts are downloaded from the URL.
Generates hardened SSH config, sysctl tweaks, and nftables firewall rules written into the rootfs.
[security.ssh]
permit_root_login = false # default false
disable_password_auth = false # default: auto-true when any user has ssh_keys
allowed_users = [] # default: auto-populated from users with ssh_keys
max_auth_tries = 3 # default 3
login_grace_time_secs = 30 # default 30
client_alive_interval_secs = 300 # default 300
client_alive_count_max = 2 # default 2
allow_tcp_forwarding = false # default false
allow_agent_forwarding = false # default false
x11_forwarding = false # default false
[security.sysctl]
enabled = true # default true — writes /etc/sysctl.d/99-sbc-builder-security.conf
disable = [] # list of sysctl keys to skip, e.g. ["kernel.dmesg_restrict"]
extra = {} # additional key = value pairs merged into the sysctl file
[security.firewall]
enabled = true # default true — writes /etc/nftables.conf
ssh_port = 22 # default 22
additional_tcp_ports = [] # extra TCP ports to open, e.g. [80, 443]
additional_udp_ports = [] # extra UDP ports to open
allow_ping = true # default true — ICMP echoDefault sysctl hardening applied:
kernel.dmesg_restrict = 1kernel.kptr_restrict = 2kernel.randomize_va_space = 2net.ipv4.conf.all.rp_filter = 1(reverse path filtering)net.ipv4.conf.all.accept_redirects = 0net.ipv4.tcp_syncookies = 1net.ipv4.tcp_rfc1337 = 1net.ipv4.icmp_echo_ignore_broadcasts = 1
Example — web server with SSH key-only access:
[security.ssh]
permit_root_login = false
max_auth_tries = 2
[security.firewall]
enabled = true
ssh_port = 2222
additional_tcp_ports = [80, 443]
allow_ping = true
[security.sysctl]
enabled = true
disable = []
extra = { "net.core.somaxconn" = "1024" }Generates an OpenRC service that runs once on the first boot to perform post-flash initialization.
[firstboot]
enabled = true # default true
expand_rootfs = true # default true — grow rootfs partition to fill the medium
regenerate_ssh_host_keys = true # default true — new unique host keys per device
regenerate_machine_id = true # default true — unique /etc/machine-id per device
hostname_from_mac = false # default false — set hostname to alpine-<mac_suffix>
run_hooks = [] # optional — shell scripts run inside chroot on first boot| Field | Type | Default | Notes |
|---|---|---|---|
enabled |
bool | true |
false skips generating the firstboot service entirely. |
expand_rootfs |
bool | true |
Calls resize2fs (ext4) or btrfs filesystem resize to fill the storage device. |
regenerate_ssh_host_keys |
bool | true |
Runs ssh-keygen -A. Without this, all flashed devices share the same host key. |
regenerate_machine_id |
bool | true |
Writes a fresh /etc/machine-id. Required for correct systemd/journald behavior. |
hostname_from_mac |
bool | false |
Sets hostname to alpine-XXXX where XXXX is the last 4 hex digits of eth0 MAC. |
run_hooks |
array of strings | [] |
Paths to shell scripts executed inside the chroot on first boot. |
Sign the compressed output image with minisign. Produces output.img.xz.minisig alongside the image.
[signing]
enabled = true
key_path = "~/.config/alpine-sbc-builder/minisign.key" # path to private key
# key_env = "SBC_SIGNING_KEY" # env var containing key path (takes priority over key_path)
# password_env = "SBC_SIGNING_PASSWORD" # env var containing key password| Field | Type | Notes |
|---|---|---|
enabled |
bool | false skips signing entirely. |
key_path |
string | Path to minisign private key. ~ is expanded. |
key_env |
string | Name of env var that holds the key path. Takes priority over key_path. |
password_env |
string | Name of env var that holds the key password. Passed via stdin to minisign. |
Generate a signing key:
minisign -G -p ~/.config/alpine-sbc-builder/minisign.pub \
-s ~/.config/alpine-sbc-builder/minisign.keyVerify a signed image:
minisign -V -p ~/.config/alpine-sbc-builder/minisign.pub \
-m alpine-orangepi5plus.img.xzFetch Alpine SecDB CVE feeds at build time and report vulnerabilities in installed packages.
[cve]
enabled = true # default true
fail_on_critical = false # default false — set true to abort build on unfixed critical CVEs| Field | Type | Default | Notes |
|---|---|---|---|
enabled |
bool | true |
false skips CVE fetching. Useful for air-gapped or offline builds. |
fail_on_critical |
bool | false |
true causes the build to fail if any installed package has an unfixed critical CVE. |
The CVE report is written to build/cve-report.md.
Upload build artifacts after the image is complete. One method is active at a time.
Publish to GitHub Releases.
[publish]
method = "github"
[publish.github]
repo = "youruser/your-repo" # GitHub owner/repo
token_env = "GITHUB_TOKEN" # env var containing a PAT with releases:write
tag_pattern = "alpine-{board}-{date}" # release tag (supports {board}, {date}, {version})Rsync artifacts to a remote server.
[publish]
method = "rsync"
[publish.rsync]
dest = "user@cdn.example.com:/srv/images/alpine/"Upload to an S3-compatible bucket.
[publish]
method = "s3"
[publish.s3]
bucket = "my-alpine-images"
prefix = "sbc/orangepi5plus/" # optional key prefix
region = "us-east-1"Credentials are read from the standard AWS environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) or ~/.aws/credentials.
Copy artifacts to a local directory.
[publish]
method = "local"
[publish.local]
path = "/mnt/nas/images/alpine"Run custom shell scripts at specific points in the build pipeline. Host hooks run on the build machine; chroot hooks run inside the aarch64 bwrap chroot.
[hooks]
pre_build = ["./hooks/check-env.sh"]
post_validate = []
post_kernel = ["./hooks/copy-dtb-overlays.sh"]
post_uboot = []
post_rootfs = ["./hooks/customize-rootfs.sh", "./hooks/set-wallpaper.sh"]
pre_image = ["./hooks/final-check.sh"]
post_image = ["./hooks/compress-extra.sh"]
pre_publish = ["./hooks/sign-checksums.sh"]
post_publish = ["./hooks/notify-slack.sh"]| Hook | Runs when |
|---|---|
pre_build |
Before phase 1 (validate). Good for environment checks. |
post_validate |
After config + host dep validation passes. |
post_kernel |
After kernel artifacts are ready. |
post_uboot |
After U-Boot binary is ready. |
post_rootfs |
After the rootfs directory is fully populated. Most customization goes here. |
pre_image |
Before the raw .img is assembled from the rootfs. |
post_image |
After the image is assembled and compressed. |
pre_publish |
Before publish phase starts. |
post_publish |
After all publish methods complete. |
Hook scripts receive environment variables: BUILD_DIR, BOARD, ALPINE_VERSION, OUTPUT_IMAGE.
Example hook — add a custom motd:
#!/bin/sh
# hooks/customize-rootfs.sh
cat > "$BUILD_DIR/chroot/etc/motd" <<'EOF'
Welcome to Alpine Linux on Orange Pi 5 Plus
EOFA config can extend a base config with extends. The child overrides scalar fields and union-merges list fields.
# base.toml — shared across all boards
[image]
alpine_version = "edge"
timezone = "UTC"
image_size_mib = 2048
[[users]]
name = "admin"
password_hash = "$6$..."
groups = ["wheel"]
ssh_keys = ["ssh-ed25519 AAAA... ci@build"]
[packages]
install = ["alpine-base", "openrc", "openssh", "doas"]
[services]
enable = ["sshd"]# orangepi5plus.toml — board-specific config
extends = "../base.toml"
[image]
board = "orangepi5plus" # adds/overrides board
hostname = "opi5plus"
[packages]
install = ["podman"] # union-merged with base install list
[services]
enable = ["chronyd"] # union-merged: final list = ["sshd", "chronyd"]Merge rules:
| Field type | Behavior |
|---|---|
packages.install, packages.repositories, services.enable |
Union-merged (child + parent, deduped) |
| All other scalars and tables | Child value overrides parent |
[[users]] array |
Child replaces parent entirely |
Inheritance is single-level only. A child config cannot chain through multiple parents.
A board variant overlays a secondary .toml file on top of board.toml for hardware with the same SoC but different peripherals (e.g. Orange Pi 5 vs Orange Pi 5 Plus).
[image]
board = "orangepi5"
variant = "plus" # loads boards/orangepi5/variants/plus.tomlboard.name and board.soc_family are immutable — variants cannot change the SoC family.
The image.output field supports three tokens:
| Token | Expands to |
|---|---|
{board} |
Value of image.board |
{version} |
Value of image.alpine_version |
{date} |
Build date in YYYYMMDD format |
Example:
[image]
output = "alpine-{board}-{version}-{date}.img"
# Result: alpine-orangepi5plus-edge-20250117.imgIf output is omitted, the filename defaults to alpine-{board}-{version}.img.
[image]
board = "orangepi5plus"
alpine_version = "edge"
hostname = "myboard"
timezone = "UTC"
image_size_mib = 2048
[[users]]
name = "admin"
password_hash = "$6$rounds=10000$randomsalt$hashgoeshere"
groups = ["wheel"]
ssh_keys = ["ssh-ed25519 AAAA... user@laptop"]
primary = true
[packages]
install = [
"alpine-base", "openrc", "busybox",
"openssh", "doas", "ifupdown-ng", "dhcpcd",
]
[services]
enable = ["sshd"][image]
board = "orangepi5plus"
alpine_version = "3.21"
hostname = "srv01"
timezone = "Europe/Berlin"
image_size_mib = 8192
output = "alpine-server-{board}-{version}-{date}.img"
rootfs_type = "ext4"
[[users]]
name = "ops"
password_hash = "$6$rounds=10000$saltsalt$hash..."
groups = ["wheel", "netdev", "video"]
ssh_keys = [
"ssh-ed25519 AAAA... ops@workstation",
"ssh-ed25519 BBBB... ops@laptop",
]
primary = true
[[users]]
name = "deploy"
password_hash = "$6$rounds=10000$saltsalt2$hash2..."
groups = ["netdev"]
ssh_keys = ["ssh-ed25519 CCCC... ci@pipeline"]
[sudo]
use_doas = true
require_password = true
[packages]
base = ["alpine-base", "openrc", "busybox", "mkinitfs", "e2fsprogs", "util-linux"]
network = ["ifupdown-ng", "dhcpcd", "openssh", "nftables", "curl", "wireguard-tools"]
system = ["doas", "htop", "chrony", "logrotate", "eudev", "dcron"]
extra = ["podman", "fuse-overlayfs", "slirp4netns", "git", "tmux"]
[packages.repositories]
self_hosted = "https://pkg.example.com/alpine/v3.21/main"
[services]
enable = ["sshd", "chronyd", "nftables", "crond", "udev"]
[security.ssh]
permit_root_login = false
max_auth_tries = 3
login_grace_time_secs = 30
client_alive_interval_secs = 120
client_alive_count_max = 3
allow_tcp_forwarding = false
[security.sysctl]
enabled = true
extra = { "vm.swappiness" = "10" }
[security.firewall]
enabled = true
ssh_port = 22
additional_tcp_ports = [443]
allow_ping = true
[firstboot]
enabled = true
expand_rootfs = true
regenerate_ssh_host_keys = true
regenerate_machine_id = true
hostname_from_mac = false
[cve]
enabled = true
fail_on_critical = true
[signing]
enabled = true
key_path = "~/.config/alpine-sbc-builder/minisign.key"
password_env = "SBC_SIGNING_PASSWORD"
[kernel]
mode = "build"
[kernel.build]
source = "https://github.com/armbian/linux-rockchip.git"
branch = "rk-6.1-rkr4.1"
defconfig = "rockchip_linux_defconfig"
config_fragments = ["boards/orangepi5plus/rk3588-server.config"]
[uboot]
mode = "prebuilt"
url = "https://github.com/example/uboot-builds/releases/latest/download/uboot-opi5plus.bin"
sha256 = ""
[publish]
method = "rsync"
[publish.rsync]
dest = "user@cdn.example.com:/srv/images/"
[hooks]
post_rootfs = ["./hooks/add-motd.sh"]
post_publish = ["./hooks/notify.sh"]# ci.toml — layered on top of base.toml
extends = "base.toml"
[image]
board = "orangepi5plus"
hostname = "ci-runner-01"
image_size_mib = 4096
[[users]]
name = "runner"
password_plain = "ci-only-throwaway"
groups = ["wheel", "netdev"]
ssh_keys = ["ssh-ed25519 DDDD... ci@jenkins"]
[sudo]
use_doas = true
require_password = false
[boot_firmware]
base_url = "https://ci-artifacts.internal/firmware/orangepi5plus/latest"
[firstboot]
expand_rootfs = true
regenerate_ssh_host_keys = true
regenerate_machine_id = true
[cve]
enabled = false
[signing]
enabled = falsePhase 1 VALIDATE — check config + host tools + board.toml deep validation
Phase 2 PREPARE — download Alpine minirootfs (cached, sha256 verified); pin APKINDEX
Phase 3 KERNEL — build kernel in aarch64 bwrap chroot (make ARCH=arm64, no root)
Phase 4 UBOOT — download prebuilt or build U-Boot
Phase 5 ROOTFS — assemble rootfs: apk install, users, services, hardening, firstboot
Phase 6 IMAGE — partition + populate raw .img (no root, no loop devices)
Phase 7 PUBLISH — upload to GitHub Releases / rsync / S3 / local (optional)
Post-build: verify image, sign with minisign, generate build documentation, CVE report
All phases run without root. Chroot is done with bwrap (bubblewrap) + qemu-aarch64-static. The ext4 rootfs partition is populated using mke2fs -d which creates a filesystem from a directory without mounting it.
alpine2sbc writes a build.lock file recording:
- Alpine minirootfs SHA-256
- Kernel source commit hash and config hash
- All installed APK package versions
- U-Boot binary SHA-256
- Image SHA-256
Re-run with --locked to verify all inputs match the lock file. Use --update-lock to refresh it.
- Create
boards/{board_name}/board.toml— partition layout, DTB name, console, U-Boot offsets. - Optionally add kernel config fragments (
*.config) and boot files (boot/) in the same directory. - For board variants, add
boards/{board_name}/variants/{variant_name}.toml. - No Rust changes needed for standard Rockchip/extlinux boards.
See boards/orangepi5plus/board.toml for a complete example.
| Phase | Status |
|---|---|
| 1 — validate | Works |
| 2 — prepare | Works |
| 3 — kernel build | Works — kernel compiles fully |
| 4 — U-Boot | In progress — build reaches compilation stage |
| 5 — rootfs | Untested |
| 6 — image | In progress — partition + vfat boot works; ext4 rootfs population fix applied |
| 7 — publish | Untested |
All supporting modules (signing, CVE check, flash, health checks, firstboot, config inheritance, variants) are implemented and unit-tested. End-to-end wiring pending.
See CLAUDE.md for implementation notes, architectural decisions, and known issue fixes.
This project is licensed under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). You may use, modify, and distribute it under the terms of that license. Network use of a modified version requires that you make the corresponding source available to users interacting with it over a network.