Skip to content

ShyanJMC/alpine2sbc

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

82 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SPDX-License-Identifier: AGPL-3.0-or-later

alpine2sbc

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.

Supported boards

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).

Pipeline

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.

Host dependencies

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

Build

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.

Usage

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.

Which build to run after a code change

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.

Quick start

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/sdX

Hardening shipped by default

Every image gets:

  • root account locked (! in shadow). Admin user is in wheel; doas/sudo provides escalation.
  • /etc/ssh/sshd_config rewritten over Alpine's default: PermitRootLogin no, PasswordAuthentication no if the admin user has at least one ssh_key (else yes), 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 when nftables package installed): default-deny inet filter, port 22 open, ICMP allowed; service runs in the boot runlevel 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.
  • firstboot OpenRC 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.log and /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, and Authorization: headers don't reach logs/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 issues verify_rootfs's static walk can't catch.

Networking shipped by default

The image is configured for "plug a cable in and it just works" out of the box:

  • NetworkManager is the default network manager (/etc/init.d/networkmanager enabled in the default runlevel). On every wired NIC, NM auto-connects DHCP the moment a carrier appears — no /etc/network/interfaces edit required.
  • nmtui (curses UI) ships for interactive WiFi setup; wpa_supplicant, iw, pciutils, ethtool are installed for diagnostics.
  • ifupdown-ng is installed as an operator-reachable fallback (/etc/init.d/networking exists) but NOT enabled in any runlevel. Two managers on the same iface fight over carrier and IP; revert with doas rc-update del networkmanager default && doas rc-update add networking boot && doas reboot.
  • /etc/resolv.conf ships 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 options timeout:2 attempts:2 rotate single-request-reopen.
  • /etc/NetworkManager/conf.d/99-fallback-dns.conf puts the same four anycasts in [global-dns-domain-*] servers=.... NM's default dns=default plugin appends them to whatever DHCP supplies, so resolution survives a DHCP server that hands out no DNS.
  • ca-certificates + ca-certificates-bundle ship pre-configured (cert.pem rebuilt deterministically in chroot to dodge qemu-emulated post-install-hook silent failures); apk update over HTTPS works on first boot.

NIC + WiFi drivers + firmware (Orange Pi 5 Plus)

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_nic for rtl_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-alllinux-firmware-other (~30 MiB) covers operator-hotplug hardware (USB WiFi dongles, USB Ethernet adapters).

/usr/local/sbin/debug.sh — full system snapshot

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.txt

The 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.

Flash subcommand

alpine2sbc flash <IMAGE> <DEVICE> [--force-not-removable] [--no-verify]
  • Refuses to write to a non-removable disk unless --force-not-removable is passed.
  • Refuses to write to a device with mounted partitions.
  • Prompts the operator to type the device path back to confirm (no --yes shortcut).
  • Auto-escalates via doassudosu when the user can't write the device directly.
  • Post-flash verifies partition table (sfdisk -l) and U-Boot magic at offset 32 KiB (accepts RKSD/RKBO/RKNS/RK35).

Inspect effective config

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.toml

Audit installed packages against CVE feed

After a build, walk the staged rootfs's apk database and compare against Alpine's SecDB advisory feed:

alpine2sbc build
alpine2sbc check-cves -c config.toml

Emits 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.

Build-lock for reproducibility

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.toml used 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.

Image signing (optional)

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 build

If neither SBC_SIGNING_KEY nor ~/.config/alpine-sbc-builder/minisign.key exists, signing is silently skipped (dev builds work without ceremony).


Configuration reference

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.

Table of contents


[image]

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"

[[users]]

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.

[user] (legacy)

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"

[sudo]

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.conf

Field 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",
]

[packages]

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.


[services]

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)
]

[network]

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.

[kernel]

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.

mode = "build"

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.

mode = "apk"

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.

mode = "firmware"

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.

[uboot]

Override U-Boot source/binary configured in board.toml. Two modes: "prebuilt" (download binary) or "build" (compile from source).

mode = "prebuilt"

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.

mode = "build"

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.

[boot_firmware]

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.


[security]

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 echo

Default sysctl hardening applied:

  • kernel.dmesg_restrict = 1
  • kernel.kptr_restrict = 2
  • kernel.randomize_va_space = 2
  • net.ipv4.conf.all.rp_filter = 1 (reverse path filtering)
  • net.ipv4.conf.all.accept_redirects = 0
  • net.ipv4.tcp_syncookies = 1
  • net.ipv4.tcp_rfc1337 = 1
  • net.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" }

[firstboot]

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.

[signing]

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.key

Verify a signed image:

minisign -V -p ~/.config/alpine-sbc-builder/minisign.pub \
            -m alpine-orangepi5plus.img.xz

[cve]

Fetch 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.


[publish]

Upload build artifacts after the image is complete. One method is active at a time.

method = "github"

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})

method = "rsync"

Rsync artifacts to a remote server.

[publish]
method = "rsync"

[publish.rsync]
dest = "user@cdn.example.com:/srv/images/alpine/"

method = "s3"

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.

method = "local"

Copy artifacts to a local directory.

[publish]
method = "local"

[publish.local]
path = "/mnt/nas/images/alpine"

[hooks]

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
EOF

Config inheritance

A 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.


Board variants

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.toml

board.name and board.soc_family are immutable — variants cannot change the SoC family.


Output filename templates

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.img

If output is omitted, the filename defaults to alpine-{board}-{version}.img.


Complete examples

Minimal — fastest build, single SSH user

[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"]

Server — hardened, podman rootless, CVE check

[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 — inherits base, uses prebuilt firmware, passwordless deploy

# 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 = false

How it works

Phase 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.

Reproducibility

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.

Adding a board

  1. Create boards/{board_name}/board.toml — partition layout, DTB name, console, U-Boot offsets.
  2. Optionally add kernel config fragments (*.config) and boot files (boot/) in the same directory.
  3. For board variants, add boards/{board_name}/variants/{variant_name}.toml.
  4. No Rust changes needed for standard Rockchip/extlinux boards.

See boards/orangepi5plus/board.toml for a complete example.

Current status

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.

License

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.

About

Alpine Linux to any Single Board Computer

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Sponsor this project

Packages

 
 
 

Contributors