Every variant JSON is validated against
config/schema.json. This doc describes each section
with defaults and examples. Source of truth is always the schema.
| Key | Type | Required | Purpose |
|---|---|---|---|
$schema |
string | optional | IDE hint — stripped before validation. |
extends |
string | optional | Relative path to parent variant JSON. See variants.md. |
variant |
object | ✅ | Variant metadata (name, description, version). |
base_image |
object | ✅ | Upstream .img.xz URL + SHA-256 + arch. Must be a Raspberry Pi OS Lite image — the schema rejects a raspios_arm64 / raspios_full_arm64 URL, because the Desktop edition costs ~3 GiB of rootfs and overflows an 8 GB CM4 eMMC. Bump url and sha256 together or the build aborts on the checksum check. |
targets |
array[enum] | ✅ | Hardware targets: rpi4, rpi5, cm4, cm5. (Pi Zero 2 W dropped — 512 MB RAM is insufficient for Podman + Portainer.) |
hostname |
string | ✅ | DNS-compatible hostname (lowercase, [a-z0-9-]). |
locale |
object | — | Timezone, keyboard, locale. |
users |
array | ✅ | One or more accounts. |
remove_users |
array | — | Users to delete (e.g. ["pi"]). |
root |
object | — | SSH and su policy. |
ssh |
object | — | { enabled: true } — enables ssh.service. |
banner |
object | — | Pre- and post-login banners (see below). |
packages |
array | — | APT packages to install. Concatenated with parent. |
network |
object | — | ethernet + wifi configuration. |
boot_config |
object | — | /boot/firmware/config.txt low-level toggles and raw dtoverlays. |
camera |
object | — | CSI camera: autodetect + explicit sensor overlays. See hardware.md. |
hdmi |
object | — | Per-output HDMI config: group, mode, rotation, audio forcing. |
display |
object | — | Console + DSI-LCD rotation. |
audio |
object | — | Onboard audio toggle + default ALSA sink. |
gpio |
object | — | one_wire (DS18B20 etc.). |
rtc |
object | — | I2C RTC HAT (DS3231 / PCF8523 / PCF85063) + fake_hwclock fallback. |
fan |
object | — | Active cooling (gpio / pwm / emc2301). |
leds |
object | — | Power + activity LED trigger (on/off/heartbeat/mmc0). |
overclock |
object | — | arm_freq, gpu_freq, over_voltage, sdram_freq. Requires accept_warranty_void: true. |
memory |
object | — | gpu_mem split + cma size. |
pcie |
object | — | Pi5 / CM4/5 PCIe slot (gen, NVMe boot). |
usb |
object | — | max_usb_current (Pi4 USB-C 3A). |
bootloader |
object | — | Pi5/CM5 EEPROM (boot order, wake-on-GPIO, power-off-on-halt). |
watchdog |
object | — | Hardware watchdog (bcm2835-wdt) with systemd kick. |
can |
object | — | SocketCAN interfaces (when a CAN HAT is present). |
podman |
object | — | Podman runtime, Docker CLI emulation, container networking (default). |
docker |
object | — | Docker CE install + daemon.json (supported, non-default). |
portainer |
object | — | Portainer CE, deployed as Quadlet units under Podman or compose under Docker. |
unattended_upgrades |
object | — | Auto-updates with maintenance + reboot windows. |
Details, example snippets and caveats for all hardware blocks live in
hardware.md.
Any string value may contain ${VAR} or ${VAR:-default}:
"password": "${ADMIN_PASSWORD:-12345678}",
"psk": "${WIFI_PSK}"Resolution is single-pass, case-sensitive, and happens before schema validation (so env values must satisfy the schema's constraints).
| Form | Behavior |
|---|---|
${VAR} |
Required. Missing value → KeyError, build fails. |
${VAR:-default} |
Optional. Uses default when VAR is unset or empty. |
Two identifiers are not resolved — they pass through verbatim because downstream tools use the same syntax:
${distro_id},${distro_codename}— substituted by unattended-upgrades / APT at runtime.
The resolver's skip list lives at the top of scripts/generate.py.
In GitHub Actions the secrets ADMIN_PASSWORD and WIFI_PSK are injected
as env vars; they override the ${VAR:-default} fallback when set.
{
"name": "admin",
"password": "${ADMIN_PASSWORD:-12345678}",
"groups": ["sudo", "docker"],
"shell": "/bin/bash",
"sudo_nopasswd": true,
"ssh_authorized_keys": ["ssh-ed25519 AAAA... admin@workstation"]
}nameacts as the merge key whenextendscombinesusersarrays.sudo_nopasswd: truewrites/etc/sudoers.d/010-bgrpiimage-<name>withNOPASSWD:ALL.ssh_authorized_keysis optional; listed keys go into/home/<name>/.ssh/authorized_keyswith mode 600. Without it the image ships no~/.sshat all.- If the resolved password is a known default (
12345678), the account is created withchage -d 0— it ships expired, and the first console or SSH login forces a change before granting a session. Raspberry Pi OS's own first-boot wizard is disabled at build time, so the console really does show a login prompt and this is the only thing that asks for a new password. - The build also writes
/etc/bgrpiimage-default-password-active, recording<user>:<first 12 chars of the crypt hash>after the accounts are created. The MOTD compares the live hash against it, so the warning clears itself the moment the credential is actually rotated. (It used to testsp_lstchg == 0, which could never be true by the timepam_motdruns — PAM's account phase has already forced the change.) generate.pyalso prints aSECURITY:warning at render time. SetADMIN_PASSWORDto ship a real credential with no forced rotation.remove_usersaccounts are deleted before the new ones are created, so the first declared user takes UID 1000 rather than 1001.
{
"su_nopasswd_users": ["admin"],
"ssh_password_auth": true,
"ssh_permit_root_login": false
}su_nopasswd_users→ added to thewheelgroup andpam_wheel.so trustis installed so listed users cansu/sudo su -without a password.ssh_password_authandssh_permit_root_loginare written to/etc/ssh/sshd_config.d/10-bgrpiimage.conf.
Each interface entry:
{
"interface": "eth0",
"mode": "dhcp", // or "static" / "disabled"
"ipv6": true,
"address": "192.168.1.10", // static only
"prefix": 24,
"gateway": "192.168.1.1",
"dns": ["1.1.1.1", "2606:4700:4700::1111"]
}wifi additionally takes:
{
"country": "DE",
"networks": [
{ "ssid": "MyNetwork", "psk": "${WIFI_PSK}",
"priority": 10, "hidden": false }
]
}WiFi ships disabled (
network.wifi.mode: "disabled"), and no image carries a default PSK. Withmode: "disabled"neither20-wlan.networknor awpa_supplicantconfig is generated, sowlan0stays unmanaged by networkd and cannot hold upsystemd-networkd-wait-online. Setmodetodhcp/staticand addnetworks[]to bake WiFi into an image, or enable it per device withsudo bgrpiimage-setup wifi enable <SSID>.
countryis still used even when WiFi is off: it becomes theieee80211_regdompinned in/etc/modprobe.d/zz-bgrpiimage-rfkill.conf.
systemd-networkd replaces NetworkManager / dhcpcd at build time — one
unit per interface. wpa_supplicant@<iface> is enabled for each generated
config.
Every generated .network also gets a [Link] RequiredForOnline=: degraded
(systemd's own default) for wired interfaces, and no for wireless and for
CAN. There is no config key for it. A link that cannot come up must never
gate network-online.target. Under Podman (the default), Quadlet itself
injects After=/Wants=network-online.target into every root-context unit
it generates — including Portainer's portainer.service — unless a
[Quadlet] section sets DefaultDependencies=false, which this project does
not emit (podman-systemd.unit(5)). So that dependency exists under Podman
too, even though nothing in this repo writes it, and the RequiredForOnline= no safeguard matters exactly as much as it did under Docker — do not
remove it on the assumption that Podman is exempt. Under the non-default
Docker runtime, docker.service also waits on that target and the Portainer
first-boot install waits on Docker in turn, so both queue behind it as well.
Either way, a CAN interface with no bus attached, or a wireless link that
cannot associate, is a normal state, not a fault — so it must not hold up
boot.
Everything written ends up between fenced markers in
/boot/firmware/config.txt:
# >>> bgrpiimage AUTO-GENERATED >>>
[all]
core_freq_fixed=1
dtparam=spi=on
dtoverlay=mcp2515-can1,oscillator=16000000,interrupt=25,spimaxfrequency=8000000
dtoverlay=mcp2515-can0,oscillator=16000000,interrupt=23,spimaxfrequency=8000000
# <<< bgrpiimage AUTO-GENERATED <<<
| Key | Effect |
|---|---|
core_freq_fixed: true |
core_freq_fixed=1 — stops the core clock scaling (see below) |
enable_i2c: true |
dtparam=i2c_arm=on |
enable_spi: true |
dtparam=spi=on |
enable_i2s: true |
dtparam=i2s=on |
enable_uart: true |
enable_uart=1 |
disable_wifi |
dtoverlay=disable-wifi |
dtoverlays[] |
dtoverlay=<name>[,k=v,k=v] per entry |
extra_lines[] |
Raw lines appended verbatim |
dtoverlays is an array of {name, id?, params} objects — merged by id when
present, otherwise by name.
A boolean param renders as a bare token, not
k=true. The firmware treats overlay booleans as present-is-true / absent-is-false, which is why upstream writesdtoverlay=mcp251xfd,spi0-0,interrupt=25. So"spi0-0": trueemitsspi0-0, andfalseomits the key entirely — writingk=falsewould still make the parameter present.
The id key is what makes two entries of the same overlay survive the merge:
idexists for overlays loaded more than once.mcp2515ships one overlay per channel (mcp2515-can0,mcp2515-can1), sonameis a unique key. The MCP251XFD family ships one overlay for every channel and picks the chip with aspi<n>-<m>parameter — so a two-channel CAN FD HAT needs two entries both namedmcp251xfd. Merged by name those collapse into a single overlay carrying both chip selects, withinterruptresolved last-wins: one CAN interface instead of two, bound to the other channel's INT GPIO.idis a merge key only and is never rendered.
{
"interfaces": [
{ "name": "can0", "bitrate": 500000, "auto_up": true, "txqueuelen": 1024, "restart_ms": 100 },
{ "name": "can1", "bitrate": 500000, "auto_up": true, "txqueuelen": 1024, "restart_ms": 100 }
]
}Writes two files per interface, because systemd splits ownership of them:
| File | Read by | Carries |
|---|---|---|
/etc/systemd/network/40-can<N>.network |
systemd-networkd |
[CAN] BitRate=, SamplePoint=, FDMode=, DataBitRate=, RestartSec=, RequiredForOnline= |
/etc/systemd/network/70-can<N>.link |
systemd-udevd |
[Link] TransmitQueueLength= |
can-utils is added to the package list automatically.
Adding dbitrate turns the interface into a CAN FD interface — the arbitration
phase keeps running at bitrate, the payload is sent at dbitrate:
{ "name": "can0", "bitrate": 500000, "dbitrate": 2000000, "restart_ms": 100 }[CAN]
BitRate=500000
FDMode=yes
DataBitRate=2000000
RestartSec=100ms
FDMode=yesandDataBitRate=are emitted together or not at all — and that is correctness, not tidiness.can_validate()indrivers/net/can/dev/netlink.crefusesIFLA_CAN_DATA_BITTIMINGwithoutCAN_CTRLMODE_FD(and the reverse) with-EOPNOTSUPP, and that error rejects the wholeRTM_NEWLINKmessage. So aDataBitRate=written withoutFDMode=yesdoes not merely skip the data phase —BitRate=,SamplePoint=andRestartSec=travel in the same message and are lost with it, leaving the link on whatever the driver powered up with. systemd will not catch it either: it has no coupling between the two keys, so the only trace is a netlink error in the journal.
The controller has to be able to do it, too:
dbitrateneeds an FD controller. The MCP2515 is Classic-CAN only, sodbitrateon amcp2515-*board is refused at build time rather than producing an interface that fails to configure at boot.
data_sample_point is available but should normally stay unset, for the same
reason sample_point does: it describes the bus, which every node has to agree
on, not the board. Unset, can_update_sample_point() picks per bitrate — 75%
above 800 kbit/s, 80% above 500 kbit/s, 87.5% at or below. A 500 kbit/s
arbitration phase with a 2 Mbit/s data phase therefore lands on 87.5% / 75% with
nothing configured at all.
fd_non_iso selects the pre-standard Bosch frame format and exists only for
first-generation FD silicon. The two formats do not interoperate — a non-ISO
node on an ISO bus produces CRC errors, not silence.
TransmitQueueLengthis not a.networkkey. Both file types have a section literally named[Link], but with disjoint key sets — networkd parses the key, logs it as unknown, discards it and carries on, so the interface keeps the CAN core default of10while everything else in the file works. The[Match]keys differ too:.networkmatches onName=,.linkhas noName=and spells itOriginalName=.udev applies
.linkfiles only on a netdev add event, sosystemctl restart systemd-networkdwill not pick up a change — reboot, or usebgrpiimage-setup can txqueuelen can0 <N>, which writes the file and sets the live link. Check the result withbgrpiimage-setup can status(thetxqcolumn) orip -d link show can0.
restart_msis bus-off auto-recovery, and its unit is mandatory. It renders as[CAN] RestartSec=<n>ms. systemd parsesRestartSec=in seconds by default, so a bare100would mean 100 s — the generator always writes themssuffix. Both the kernel and systemd default this to off, which is whatip -details link showreports asrestart-ms 0: a controller that goes bus-off then stays there until someone cycles the link by hand. On the MCP2515 it is worse than a stalled recovery — the driver puts the chip to sleep, so it is physically off the bus. Setrestart_ms: 0only if the application does its own bus-off handling; the key is then omitted entirely rather than written asRestartSec=0, which systemd would read as "leave the current value alone" rather than "off".
sample_point is a property of the physical bus, not of one board, and every
node has to agree on it — see
hardware.md for why there is deliberately no
bgrpiimage-setup can sample-point command and why the kernel-computed default
of 87.5% at 500 kbit/s is almost always the right answer.
⚠️ The JSON field is in percent (87.5), and does not match whatipprints.ip -details link showreportssample-point 0.875— the same value as a fraction. Writing0.875into the config is refused by schema validation, and that lower bound of50earns its place:0.875would otherwise render asSamplePoint=0.9%, which systemd accepts as 9 permille and the kernel then refuses outright, so the unit would boot with a CAN link that never came up. Failing inmake validatebeats failing in the field.
The renderer cross-checks this block against boot_config.dtoverlays. For
MCP2515 boards: every can<N> needs a matching mcp2515-can<N> overlay, and
each overlay needs its own params.interrupt — both defaults would otherwise
land on GPIO 25. See
hardware.md for why the
emitted overlay order is reversed.
For MCP251XFD boards the overlay carries no interface name, so the checks are
structural instead: one mcp251xfd overlay per declared interface, exactly one
spi<n>-<m> chip select each (and a real one — SPI0 has no third chip select),
a distinct params.interrupt each, and an spi1-<N>cs/spi2-<N>cs overlay
listed before any entry that uses that bus. A speed above the clamp the
driver applies for the given oscillator is noted rather than refused — the
driver's min() has already made it harmless, and upstream's own overlay
default exceeds it. Writing the MCP2515 spelling
spimaxfrequency is refused outright — dtoverlay drops parameter names it does
not recognise, so it would cost the setting and say nothing. See
hardware.md.
Default container runtime — daemonless, rootful system service. Docker CLI
emulation means docker ... keeps working unchanged for anyone used to it;
under the hood every call goes to Podman.
{
"enabled": true,
"docker_emulation": true,
"sysctl": { "vm.max_map_count": 4194304 },
"network": {
"default_subnet": "10.10.0.0/17",
"default_subnet_pools": [
{ "base": "10.10.128.0/17", "size": 24 }
],
"ipv6": true,
"subnet_v6": "fdff:0::/64",
"firewall_driver": "nftables"
},
"journald": {
"system_max_use": "200M",
"system_max_file_size": "20M"
},
"auto_update": {
"enabled": true,
"schedule": {
"start": "05:30",
"randomized_delay_minutes": 30,
"persistent": true
}
}
}enabled— installspodman,podman-docker,golang-github-containers-common(Debian's name for the containers-common config package — there is nocontainers-commonin Debian),netavark,aardvark-dns,nftables,uidmap,catatonit, and enablespodman.socket+podman-restart.service(the latter brings back operator-created--restart=alwayscontainers after a reboot; Portainer's own Quadlet unit doesn't need it — it'sWantedBy=multi-user.targetand systemd starts it directly). Mutually exclusive withdocker.enabled— a variant that sets both fails validation, because the two runtimes both own/usr/bin/dockerand the container storage.docker_emulation— writes/etc/containers/nodocker, which is presence-only: it suppresses the "Emulate Docker CLI using podman" noticepodman-dockerotherwise prints on everydocker(1)call. It does not toggle emulation itself off — thedockershim is always installed once Podman isenabled.sysctl— written to/etc/sysctl.d/98-podman.conf. Carriesvm.max_map_count, which moved here fromdocker.sysctlwhen Podman became the default — it's a runtime-agnostic setting (any container workload that mmaps heavily needs it), not a Docker-specific one.network— feeds two generated files:default_subnet/default_subnet_pools→ the[network]section of/etc/containers/containers.conf(the IPv4 half of the address plan).ipv6/subnet_v6→/etc/containers/networks/podman.json, netavark's definition of the built-in default network. IPv6 can't be declared incontainers.conf— the network definition file is the only place it's settable.firewall_driver— one ofiptables,nftables,none,firewalld. Netavark's own compiled-in default isnftables, which is why this image installs thenftablespackage explicitly rather than leaving it an implicitRecommends— every install in this project runs with--no-install-recommends.
journald—system_max_use/system_max_file_size, written to/etc/systemd/journald.conf.d/99-bgrpiimage-containers.conf. Podman logs containers to journald (Docker'sjson-filedriver capped this image's container logs at 10m × 3 instead), so this cap now bounds the SD card's write/wear budget in place of a per-container log file limit.auto_update—enabledturns onpodman-auto-update.timer(the service itself is left disabled; it'sWantedBy=default.targetand would otherwise fire on every boot).schedule.start/randomized_delay_minutesbecome a[Timer]drop-in that clears and replaces the stockOnCalendar=, so the daily default doesn't also fire alongside it.schedule.persistentcatches up a run that was missed while the device was off. Only containers carrying theio.containers.autoupdate=registrylabel are affected — seeportainer.auto_updatebelow.
Cross-field guards (enforced by scripts/generate.py, not just the JSON
Schema):
docker.enabledandpodman.enabledcannot both betrue.portainer.auto_update: truerequirespodman.enabled: true— the mechanism does nothing under Docker.portainer.auto_update: truerequirespodman.auto_update.enabled: true— without the timer, theAutoUpdate=registrylabel is inert.portainer.auto_update: truerefuses a digest-pinnedportainer.image(an@sha256:...reference can never resolve to a newer tag).podman.auto_update.schedulemust not overlap theunattended_upgradesmaintenance window (02:00-04:00 by default) or theauto_rebootwindow (03:00-05:00) — a scripted reboot mid-Portainer-database-migration is unrecoverable.
Supported, non-default container runtime — set docker.enabled: true
(and leave podman.enabled: false) to use it instead of Podman. Also set
portainer.auto_update: false: base.json ships it true, and the
cross-field guard refuses that combination outright — AutoUpdate=registry
is a Podman/Quadlet label and does nothing under Docker, so a config that
switches to Docker must drop it too, or validation aborts.
{
"enabled": false,
"daemon": { "bip": "10.10.0.1/17", "ipv6": true, ... },
"sysctl": { "vm.max_map_count": 4194304 },
"networks": [ ... ] // optional; docker network create on first boot
}The daemon object is written verbatim to /etc/docker/daemon.json, so any
Docker daemon setting is allowed.
networks[] entries are materialised via a bgrpiimage-docker-networks.service
that runs once on first boot and marks itself done via a sentinel file.
{
"enabled": true,
"edition": "ce", // "ce" | "ee"
"bind": "0.0.0.0", // or 127.0.0.1 for loopback-only
"image": "docker.io/portainer/portainer-ce:lts",
"ports": { "edge": 8000, "http": 9000, "https": 9443 },
"auto_start": true,
"auto_update": true,
"backup_before_update": { "enabled": true, "keep": 5 }
}Deployment shape depends on the runtime:
- Podman (default): two Quadlet files at
/etc/containers/systemd/—portainer.image(pullsimage) andportainer.container(the unit itself;Requires=/After=podman.socket). systemd's Quadlet generator turns these into a realportainer.serviceat boot and applies its[Install]section itself — there is nosystemctl enablestep, no compose file, no first-boot oneshot, and no sentinel file. - Docker (non-default): installed Docker-native with
restart: unless-stopped. We ship/etc/bgrpiimage/portainer/docker-compose.yml(declarative config) andbgrpiimage-portainer-install.service(oneshot, first-boot only), which runsdocker compose up -donce, drops a sentinel in/var/lib/bgrpiimage/portainer.installed, and stays out of the way. After that, Docker itself handles the lifecycle.
Operator workflow under Podman:
systemctl status portainer.service # quadlet-generated unit
sudo systemctl restart portainer.service
sudo podman auto-update --dry-run # what would change, no action
sudo podman auto-update # apply now, all labeled containersauto_update(boolean) — labels the Portainer containerio.containers.autoupdate=registry, sopodman-auto-update.timerpicks it up once fired. Requirespodman.enabledandpodman.auto_update.enabled, and refuses a digest-pinnedimage— see the guards under thepodmansection above. Meaningless under Docker; update it there via the compose pull/up cycle instead.backup_before_update.enabled/.keep— whenauto_updateis on,/usr/local/sbin/bgrpiimage-portainer-backupruns asExecStartPreofpodman-auto-update.serviceon every timer fire (not only when an update is actually available), exporting theportainer_datavolume to a timestamped archive under/var/backups/bgrpiimage/and pruning to the lastkeeparchives. This exists because Portainer migrates its database one-way on startup — aportainer.dbwritten by a newer version won't open on an older one, so Podman's own update mechanism can't undo a bad Portainer update by itself. The export runs against the live volume — Portainer is not stopped first — so an archive taken whileportainer.db(SQLite) is mid-write can be torn; verify an archive before relying on it for a restore (gzip -t portainer-*.tar.gz, then extract and open the database) rather than assuming the newest one is good.
Under Docker, update / reconfigure workflow is unchanged:
sudo vim /etc/bgrpiimage/portainer/docker-compose.yml # edit
sudo docker compose -f /etc/bgrpiimage/portainer/docker-compose.yml pull
sudo docker compose -f /etc/bgrpiimage/portainer/docker-compose.yml up -d{
"enabled": true,
"allowed_origins": [
"origin=Debian,codename=${distro_codename},label=Debian",
"origin=Debian,codename=${distro_codename}-security,label=Debian-Security"
],
"package_blocklist": [],
"remove_unused_dependencies": true,
"schedule": {
"start": "02:00", // download + install window start (HH:MM)
"end": "04:00", // end
"persistent": true // run on next boot if missed
},
"auto_reboot": {
"enabled": true,
"if_required_only": true, // skip if /var/run/reboot-required absent
"window": { "start": "03:00", "end": "05:00" }
},
"mail": { "address": "", "on_error_only": true }
}See banner-and-updates.md for the full reboot
decision tree.
{
"enabled": true,
"pre_login_note": "Authorised users only. All access is logged."
}Generates three files:
/etc/issue— console pre-login (getty expands\n,\4,\6live)/etc/issue.net— SSH pre-login (static, referenced via sshdBannerdirective)/etc/update-motd.d/10-bgrpiimage— dynamic post-login MOTD
Output preview: banner-and-updates.md.
# dry-run: schema check + env resolution + merge (no file writes)
python scripts/generate.py config/variants/your-variant.json --dry-run
# raw JSON of the fully resolved config (for piping into jq)
python scripts/generate.py config/variants/your-variant.json --json
# full render: writes files into src/
python scripts/generate.py config/variants/your-variant.jsonOr via the tools container: ./tools/run.sh validate your-variant.