A template for building custom HaLOS images by layering your own pi-gen stages on top of the HaLOS base image. Fork this repo, adjust a config file, drop your own stage in, and let CI build a flashable .img.xz for you.
HaLOS images are built with pi-gen, the same tool that produces Raspberry Pi OS. Customizing them cleanly means layering your changes on top of the upstream stages instead of forking. This template demonstrates the pattern with a small, working example.
The build assembles three layers, in order:
pi-gen (upstream Raspberry Pi OS builder)
└── halos-pi-gen (HaLOS base + halpi2 + marine + desktop stages)
└── this template (your custom stages)
Each layer adds stages on top of the previous one. Your stages run last and have full access to the rootfs that the lower layers built.
- pi-gen — provides
stage0–stage4and the build orchestration. - halos-pi-gen — adds HaLOS-specific stages: base packaging, HALPI2 hardware support, marine apps (Signal K, InfluxDB, Grafana), the desktop, headless variants, and so on.
- This template — adds whatever you want on top: extra packages, pre-configured plugins, branding, additional services.
The build is driven by a config file (e.g. config.halos-desktop-marine-halpi2-ap-ais), which lists the stages to run and the image identity, and one or more stage-custom-* directories containing the work to do.
halos-pi-gen ships several pre-defined image variants — desktop marine, headless marine, HALPI2 base, etc. — each represented by its own config file in that repo. Your custom image does not have to start from the marine desktop variant used in the example here. To base your build on a different halos-pi-gen variant, copy the STAGE_LIST from the corresponding config.* file in halos-pi-gen into your config, then append your stage-custom-* entry just before stage-export. Everything else in the template (your custom stage, the run script, CI workflow) works the same regardless of which base you choose.
The repository ships with two working examples:
| Config | Stage | What it does |
|---|---|---|
config.halos-desktop-marine-halpi2-ap-ais |
stage-custom-ais |
Builds a HALPI2 marine image with the ais-forwarder Signal K plugin pre-installed. |
config.halos-marine-halpi2-waveshare-can |
stage-custom-waveshare-can |
Adds CH0 of the Waveshare 2-Channel Isolated CAN HAT as can1 alongside the HALPI2 onboard can0. Demonstrates a hardware/device-tree customization. |
The stage-custom-ais stage downloads the plugin tarball from npm at build time and extracts it into the Signal K data directory inside the image rootfs — the smallest meaningful customization that demonstrates the pattern.
The stage-custom-waveshare-can stage appends an mcp2515-can0 device-tree overlay line to /boot/firmware/config.txt and installs a udev rule that pins interface names by SPI bus path so the onboard controller is always can0 and the HAT is always can1. CH1 of the HAT cannot be enabled alongside the onboard controller: SPI0's two chip-select lines are already taken by CH0 (CS0) and the HALPI2 onboard MCP251xFD (CS1, remapped to GPIO 6). Enabling a second HAT channel would require moving it to SPI1 with a custom device-tree overlay. Bit rate, queue length, and systemd-networkd setup are inherited from stage-halpi2-common's can* matchers, so no per-interface network config is needed.
Use either as a working reference, then replace it with your own stage when forking.
After forking this repo, push to main. The workflow at .github/workflows/build.yml will build the image on a GitHub-hosted ARM64 runner and create a draft release with the .img.xz attached.
gh repo fork halos-org/halos-pi-gen-template
# edit, commit, push to your fork's main
# wait for the CI workflow run to finish
gh release list
You need:
- Docker (the build runs inside a Docker container that pi-gen manages)
git- A local checkout of
halos-org/halos-pi-genat../halos-pi-gen(a sibling directory next to this one)
git clone https://github.com/halos-org/halos-pi-gen ../halos-pi-gen
./run build config.halos-desktop-marine-halpi2-ap-ais
The first build clones a shallow copy of upstream pi-gen into pi-gen/ (gitignored). The output .img.xz will be in pi-gen/deploy/.
To clean up between builds:
./run clean
Note that ARM64 emulation under qemu on x86 hosts works but is slow. For practical local development use an ARM64 host (e.g. an Apple Silicon Mac, a Raspberry Pi, or an ARM64 Linux server).
The full lifecycle of forking and adapting this template:
-
Fork the repo on GitHub (or use it as a template via "Use this template").
-
Decide on a name for your variant. This is the suffix you will use throughout. Pick something short and descriptive:
mybrand,weather,ham. The example usesais. -
Copy the config file. From
config.halos-desktop-marine-halpi2-ap-aistoconfig.<your-variant>. Edit:IMG_NAMEandPI_GEN_RELEASE— your image's display name.PI_GEN_REPO— point at your fork's URL.CONTAINER_NAME— must be unique if you build multiple variants on the same host.STAGE_LIST— replacestage-custom-aiswith the name of your new stage directory. If you want a different base image (e.g. headless marine, plain HALPI2, …), replace the rest of the list with theSTAGE_LISTfrom the correspondingconfig.*file inhalos-org/halos-pi-gen. Yourstage-custom-*entry goes just beforestage-export.- Anything else (hostname, WiFi country, default password, …) you want to change. See inline comments in the config file for what each variable does.
-
Copy the stage directory. From
stage-custom-ais/tostage-custom-<your-variant>/. The structure is:stage-custom-<your-variant>/ prerun.sh # boilerplate, usually no edits needed NN-<step-name>/ 00-run.sh # script that runs inside the build containerSee "How pi-gen stages work" below for the conventions.
-
Replace the stage's contents with whatever your image needs. The
00-run.shscript runs inside the chroot of the rootfs being built, with${ROOTFS_DIR}pointing at the rootfs. You have full access — install packages, drop in config files, pre-seed databases, whatever. -
Update the CI matrix. In
.github/workflows/build.yml, replace the matrix entry'snameandconfigwith your new variant. Or add a second entry alongside the existing one to build both. -
Push and watch the build run. Each push to
maintriggers a build and creates a draft release.
When you have the basics working, that draft release becomes a public release: edit it in the GitHub UI and click "Publish".
Each stage is a directory with a numeric or named prefix. Stages run in the order listed in STAGE_LIST. Within a stage, scripts run in lexicographic order.
stage-custom-ais/
prerun.sh # runs once at the start of the stage
00-install-sk-plugins/
00-run.sh # runs inside the chroot
00-run-chroot.sh # (alt) runs inside the chroot via systemd-nspawn
files/ # (optional) files copied into the rootfs
00-packages # (optional) one package name per line, apt-get installed
Key conventions:
${ROOTFS_DIR}— path to the rootfs being built, on the host. Write into it to modify the image.prerun.sh— sets up the stage. The standard boilerplate callscopy_previousif${ROOTFS_DIR}does not yet exist, which forks the previous stage's rootfs as a starting point.SKIPmarker file in a stage directory — skips the stage entirely.SKIP_IMAGESmarker — runs the stage but does not produce an intermediate image.
For the full reference, see the upstream pi-gen README.
Every variable is documented inline in the config file. A quick map:
| Variable | Purpose |
|---|---|
IMG_NAME, PI_GEN_RELEASE |
Image display name and release string. |
PI_GEN_REPO |
URL embedded in image metadata. |
IMG_FILENAME, ARCHIVE_FILENAME |
Final output filenames. |
CONTAINER_NAME |
Docker container name for the build. |
STAGE_LIST |
Ordered list of stages to run. |
DEPLOY_COMPRESSION, COMPRESSION_LEVEL |
Final image compression. |
FIRST_USER_NAME, FIRST_USER_PASS |
First-boot login. Change FIRST_USER_PASS for production. |
DISABLE_FIRST_BOOT_USER_RENAME |
Whether the first-run flow may rename the user. |
TARGET_HOSTNAME |
First-boot hostname. |
WPA_COUNTRY |
WiFi regulatory domain (ISO two-letter code). |
ENABLE_SSH |
Whether SSH is enabled out of the box. |
Add any pi-gen variable you need; this list is just the ones the template sets.
"halos-pi-gen not found at ../halos-pi-gen" — clone it as a sibling: git clone https://github.com/halos-org/halos-pi-gen ../halos-pi-gen.
Out of disk space during local build — pi-gen builds keep an intermediate rootfs per stage and the final image alongside the compressed output. The CI workflow includes an aggressive disk-cleanup step you can adapt for local use, but the simplest fix is to build on a host with more free space.
Build hangs or fails partway — ./run clean removes the pi-gen/ working directory and any leftover pigen_work* containers. Re-run the build from a clean state.
dpkg conffile prompts during build — already worked around by the run script, which patches pi-gen's apt invocation to use --force-confold.
"docker: Cannot connect to the Docker daemon" — start Docker Desktop or sudo systemctl start docker.
ARM64 build is extremely slow on x86_64 — qemu user-mode emulation is correct but slow. Use an ARM64 host or rely on the GitHub Actions workflow's ubuntu-24.04-arm runners.
- HaLOS docs — https://docs.halos.fi
- HaLOS website — https://halos.fi
halos-org/halos-pi-gen(the HaLOS base layer) — https://github.com/halos-org/halos-pi-gen- Upstream pi-gen — https://github.com/RPi-Distro/pi-gen
ais-forwarderSignal K plugin (used in the example) — https://www.npmjs.com/package/ais-forwarder- Signal K — https://signalk.org
BSD 3-Clause. See LICENSE.