- Shell 88.9%
- Go Template 8%
- CSS 3.1%
A device whose kiosk-config.txt was seeded straight from the tracked template (no build.sh pass, or a manual edit that didn't touch every field) still has literal __TOKEN__ text in unfilled slots. That's not blank, so it rendered as real-but-broken tiles - 7 garbage EXTRA_TILE entries on sillygoose alone. Also moves Jellyfin to last in KNOWN_TILES - it's a backup to Plex, not the primary. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|---|---|---|
| files | ||
| fleet | ||
| .env.example | ||
| .gitignore | ||
| build.sh | ||
| provision.sh | ||
| README.md | ||
pi-stick-kiosk
Turn a Raspberry Pi 4/5 into a reproducible, open, HDMI-stick-style media client — Jellyfin/Plex/Dispatcharr (or whatever you point it at), no vendor lock-in, boots straight into a full-screen homepage, no keyboard/mouse required after setup.
Design goals:
- One golden image, cloned onto every stick. You configure a device
once, shrink the SD card into a
.img, and every future stick is just "flash this file." No re-running install steps per unit. - Per-device config lives on the boot partition, as a plain text file editable from Windows/Mac/Linux without booting Linux at all. Same image, different hostname/URLs per stick.
- Safe to clone. SD card cloning has three classic footguns — stale SSH host keys, duplicate machine-id, unexpanded filesystem. A first-boot service fixes all three automatically, once, per device.
- Survives being plugged into a random TV. HDMI hotplug/EDID quirks are
handled in
config.txtso it doesn't come up blank on a TV it wasn't tested against. - Idempotent provisioning script.
provision.shcan be re-run safely if you want to update the golden image later — it doesn't double-install or duplicate config.
How the pieces fit together
sillygoose/
├── .env.example # keys for the personal values below
├── build.sh # renders .env values into local/ (gitignored)
├── provision.sh # run ONCE on the golden device (as root)
├── files/
│ ├── kiosk.env.example # template for /boot/firmware/kiosk-config.txt
│ ├── kiosk-firstboot.service # runs once per cloned SD card
│ ├── kiosk-firstboot.sh
│ ├── kiosk-session.service # the actual kiosk browser session
│ ├── kiosk-browser.sh
│ ├── kiosk-heartbeat.service # periodic health status -> journal
│ ├── kiosk-heartbeat.timer
│ ├── kiosk-heartbeat.sh
│ ├── kiosk-logs-bundle.service # nightly log bundle for remote pull
│ ├── kiosk-logs-bundle.timer
│ ├── kiosk-logs-bundle.sh
│ ├── kiosk-status.sh # quick manual status check over SSH
│ ├── kiosk-apply-config.sh # re-render homepage + restart session
│ ├── journald-kiosk.conf # persistent, size-capped journal config
│ ├── logrotate-kiosk-heartbeat # rotation for the flat heartbeat log
│ ├── zabbix_agent2.conf.tmpl # active-checks-only, dormant until configured
│ ├── openbox-autostart
│ ├── config.txt.append # HDMI/GPU tuning appended to boot config
│ └── homepage/
│ ├── index.html
│ └── style.css
└── fleet/
└── prep-sd-card.sh # helper: writes kiosk-config.txt onto a
# freshly-flashed clone from your laptop
Personalizing your fork
This repo is meant to be public, so the tracked files above use generic
__PLACEHOLDER__ tokens (in README.md, files/kiosk.env.example, and
fleet/prep-sd-card.sh) instead of your real Jellyfin/Dispatcharr/extra-tile
URLs. To fill those in without ever committing your real values:
cp .env.example .env # then edit .env with your real URLs
./build.sh # renders personalized copies into local/ (gitignored)
build.sh writes ready-to-use output to local/README.md,
local/files/kiosk.env (copy this onto a boot partition as
kiosk-config.txt, or use in place of files/kiosk.env.example in Step 1
below), and local/fleet/prep-sd-card.sh. .env and local/ are both
gitignored.
Step 1 — Build the golden device
-
On your laptop: flash Raspberry Pi OS Lite (64-bit, Bookworm) to an SD card with Raspberry Pi Imager. In the advanced options (gear icon), set a temporary hostname (e.g.
kiosk-golden), enable SSH with your public key, and connect it to your network — Wi-Fi or Ethernet.Same image works for Pi 4 and Pi 5, so you can build one golden card and clone it onto mixed hardware.
-
Put the card in the Pi and power it on. Then, on the Pi (over SSH from your laptop):
ssh pi@kiosk-golden.local git clone https://your-forgejo/you/pi-stick-kiosk.git # or scp it over cd pi-stick-kiosk sudo ./provision.sh -
provision.sh, running on the Pi, installs X11 + Openbox + Chromium, sets up autologin into a kiosk session, deploys the homepage, tunes HDMI/GPU settings, and installs the first-boot re-individualization service. That service fires on any boot where its "already run" flag is missing — which is true right now, since this card has never booted before, so it'll also fire once on the golden device itself. That's fine; see step 5. -
On the Pi: reboot. Expect a brief extra, automatic reboot partway through — that's the first-boot service regenerating SSH host keys and machine-id, and applying whatever
HOSTNAMEis set inkiosk-config.txt(still the defaultstick-changemeat this point, so it'll just log a warning and leave it). The boot after that one lands in the full-screen homepage. Confirm HDMI-CEC or your remote/keyboard can navigate the tiles (arrow keys + Enter, or click/tap). -
On the Pi, before you shut down for shrinking: re-clear the first-boot flag so this run doesn't get baked into the golden image — otherwise every future clone would inherit sillygoose's own regenerated SSH host keys and machine-id instead of getting fresh ones of their own, which is exactly the footgun this service exists to prevent.
sudo rm -f /var/lib/kiosk-firstboot-done -
Once you're happy with it,
sudo poweroff(on the Pi), then physically pull the SD card back out and take it to your Linux box for step 2.
Step 2 — Shrink it into a distributable image
On a Linux box (or WSL), with the golden SD card in a reader — using PiShrink:
sudo dd if=/dev/sdX of=golden-raw.img bs=4M status=progress conv=fsync
sudo pishrink.sh -z golden-raw.img stick-image.img
-z gzips it too. stick-image.img.gz is now your "throw it on every
stick" artifact. Keep it in your Forgejo/NAS as the versioned build output —
treat it like any other image build, bump a version tag in
kiosk-config.txt's comments when you rebuild.
Step 3 — Deploy a new stick
On your laptop (or Linux box/WSL), with a new, blank SD card in the reader — flash the shrunk image onto it:
gunzip -k stick-image.img.gz
sudo dd if=stick-image.img of=/dev/sdX bs=4M status=progress conv=fsync
(or use Raspberry Pi Imager → "Use custom" → point at stick-image.img,
which also lets you re-set Wi-Fi credentials per device without touching
the boot partition manually.)
Before first boot — while that same card is still plugged into your
computer, not in a Pi — edit its config. The dd/Imager write above
recreated the card's boot partition as plain FAT32, so your OS mounts it
just like a USB drive: /Volumes/bootfs on macOS, a drive letter on
Windows, or wherever you mount it on Linux. Open kiosk-config.txt there
in any text editor:
HOSTNAME=stick-livingroom
JELLYFIN_URL=__JELLYFIN_URL__
DISPATCHARR_URL=__DISPATCHARR_URL__
PLEX_URL=https://app.plex.tv/desktop
Or, still on your laptop, run fleet/prep-sd-card.sh to write that
file for you instead of hand-editing. Note it only writes the file — it
expects the partition to already be mounted at the path you give it (on
macOS that's automatic on insert; on Linux you may need to mount it
yourself first, see the script's header comment for the exact command):
./fleet/prep-sd-card.sh /Volumes/bootfs stick-livingroom
Now eject the card, move it to the Pi, and power it on. Everything from
here happens on the Pi itself — no laptop, no SSH. On this first boot
the kiosk-firstboot service:
- Expands the root filesystem to fill the SD card (PiShrink shrank it down to minimum size).
- Regenerates SSH host keys and the systemd machine-id, so ten cloned sticks don't all claim to be the same host on the network.
- Sets the hostname from
kiosk-config.txt. - Reboots once, then never runs again (flag file at
/var/lib/kiosk-firstboot-done).
After that reboot it comes up straight into the kiosk homepage. Hand it to whoever it's for.
Hardware: case, not bare dongle
This build targets a Pi 4/5 in a small case with a short HDMI cable,
sitting next to the TV — not a bare board jammed straight into the HDMI
port. From the recipient's perspective it's the same "plug it in and it
works" experience, but you get better cooling, a cable that isn't
cantilevering the whole board off a TV's HDMI socket, and one less way for
the connection to be marginal on a random TV. config.txt.append
reflects this — it keeps hdmi_force_hotplug (still useful for TVs with
flaky EDID reporting) but drops the HDMI signal-boost setting that
dongle-in-port builds need to compensate for a shorter, weaker connector
path.
Tailscale, dormant by default
Every golden image ships with Tailscale installed but logged out — it
costs nothing to have on standby, and since these devices leave your LAN
the moment you hand them out, you'll likely want it eventually even if you
don't have a concrete plan today. To activate it for a given stick,
generate a one-off auth key at
login.tailscale.com/admin/settings/keys
and drop it into that device's kiosk-config.txt as TAILSCALE_AUTHKEY
before first boot (or add it later over a LAN SSH session and re-run
kiosk-firstboot.sh's Tailscale block by hand). --ssh is passed
automatically, so once a stick is on your tailnet you can tailscale ssh
into it without separately managing keys per device.
Managing sticks once they leave your network
- Tailscale (above) gets you back in without port-forwarding or static IPs, wherever the stick physically ends up.
- Update policy is already locked in on the golden image —
provision.shnarrowsunattended-upgradesto security-only patches with automatic reboot disabled (see Step 1), baked in before the image is ever shrunk and cloned. Nothing to remember per stick: an update that breaks Chromium or the display driver on a device sitting in someone else's living room is a much worse day than on a server you can console into, and by the time it's out there you can't SSH in to fix it easily. - Tile URLs can be updated remotely, no re-imaging needed. A stick
already in the field keeps its own copy of
kiosk-config.txton its boot partition. If a URL changes, SSH in (over Tailscale once it's joined), edit the relevant line in/boot/firmware/kiosk-config.txt, then runsudo /opt/kiosk/kiosk-apply-config.shto re-render the homepage and restart the session so Chromium picks it up. Rebuilding the golden image only changes what future clones start with — it doesn't reach devices already handed out, so this is the actual path for those.
Logging and pulling logs remotely
Every stick logs to the systemd journal, made persistent across reboots (RPi OS Lite defaults to RAM-only logs, which throw everything away on every reboot — not acceptable for a device you can't walk over and look at). Journal storage is capped at 150MB total with 60-day retention, so a forgotten stick doesn't slowly fill its SD card.
What gets logged:
kiosk-firstboot— the one-time re-individualization run (hostname set, SSH keys/machine-id regenerated, Wi-Fi/Tailscale join attempts). This is exactly the record you want later when a stick is misbehaving and you're trying to tell "bad setup at individualization time" apart from "something broke after the fact" — so besides the journal, it's also appended to/var/log/kiosk-heartbeat.log's sibling,/var/log/kiosk-firstboot.log, a small flat file that is not subject to the 150MB/60-day journal cap below. It's a handful of lines per device, so it's never rotated — it just persists for the life of the SD card.kiosk-browser— every Chromium start/crash/restart, tagged so you can isolate it from everything else:journalctl -t kiosk-browserkiosk-session.service— the X session itself starting/stopping/crashingkiosk-heartbeat— a structured status line every 15 minutes: uptime, load, disk/memory free, CPU temp, whether the kiosk session and Chromium are actually running, network reachability, and Tailscale state. Example line:journalctl -t kiosk-heartbeat -n 1
This is grep/parse-friendly on purpose — if you ever want to pipe it into Zabbix as a log-monitoring item instead of just eyeballing it over SSH, the format is ready for that.host=stick-livingroom version=2026.08 uptime_s=48213 load1=0.12 disk_used=41% disk_avail=15G mem_avail_mb=3102 cpu_temp_c=52.1 kiosk_session=active chromium=yes net_route=yes net_reachable=yes tailscale=Runningsystem-errorsat severityerror worse, system-wide — catches issues outside the kiosk's own units too (display driver, kernel, etc.)
Pulling logs remotely: once a stick is on Tailscale (see above), a fresh log bundle is always sitting at a fixed path, regenerated nightly at 4:30am plus on demand:
# one command, from anywhere on your tailnet
ssh stick-livingroom 'sudo cat /var/log/kiosk-bundle-latest.tar.gz' \
> stick-livingroom-logs.tar.gz
# or generate a fresh one first if you don't want to wait for the nightly run
ssh stick-livingroom 'sudo /opt/kiosk/kiosk-logs-bundle.sh'
The bundle contains the full, never-rotated kiosk-firstboot.log, 14 days
of kiosk-tagged journal output, system-wide errors, the full current
boot's log, a point-in-time snapshot (disk, memory, temp, kiosk-session
status, Tailscale status, last 20 heartbeats), and a copy of
kiosk-config.txt — with WIFI_PSK and TAILSCALE_AUTHKEY redacted,
since there's no reason secrets should ride along in a file you might copy
around further.
For a faster look without pulling the whole bundle:
ssh stick-livingroom 'sudo /opt/kiosk/kiosk-status.sh'
which prints uptime, disk, the last 5 heartbeats, and current kiosk-session/Tailscale state in one screenful.
Zabbix, over Tailscale only
Zabbix agent2 is installed on every stick, same dormant-until-configured
pattern as Tailscale: it stays disabled until a device's
kiosk-config.txt sets ZABBIX_SERVER_ACTIVE.
This is deliberately active-checks-only — the agent calls out to your
server on a schedule and pulls its own item config, rather than your
server reaching in. That's the right shape for devices that leave your
LAN: it means nothing needs to listen for inbound connections from
anywhere, on any network these sticks end up on. Passive checks are
pinned off entirely (Server=127.0.0.1, ListenIP=127.0.0.1) rather than
just "not used" — even over Tailscale, nothing but loopback can query the
agent directly. DenyKey=system.run[*] blocks remote command execution
regardless of what gets configured server-side later.
Point ZABBIX_SERVER_ACTIVE at your Zabbix server or proxy's Tailscale
hostname/IP, never a public address:
ZABBIX_SERVER_ACTIVE=zabbix-server.your-tailnet.ts.net:10051
ZABBIX_HOST_METADATA=kiosk-stick
ZABBIX_HOST_METADATA tags the host so a Zabbix autoregistration
action can auto-add each stick under the right template as it checks in
for the first time, instead of creating hosts by hand for every unit you
hand out — set one up under Configuration → Actions → Autoregistration
actions, matching on host metadata, before you ship the first stick.
The heartbeat data (uptime, disk, temp, kiosk-session/Chromium liveness,
network reachability) is written both to the journal and to a flat file
at /var/log/kiosk-heartbeat.log (rotated daily, 7 kept) specifically so
it's usable as a Zabbix log[] item source — journald isn't a format
Zabbix reads natively, so the flat file exists purely as a bridge for
this. Everything else about the logging setup above still applies
unchanged.
If you provision a stick without a working Tailscale connection but with
ZABBIX_SERVER_ACTIVE set, the agent still starts — it just retries
until connectivity exists rather than failing hard, so ordering between
the two doesn't need to be perfect.
If you outgrow golden-image cloning
Once you're iterating a lot, look at
pi-gen — the actual tool
Raspberry Pi OS itself is built with. You'd turn provision.sh into a
custom pi-gen stage and get a fully from-scratch, CI-buildable .img with
no golden-device step at all. Not included here because it's a bigger
lift (Docker-based build environment, stage/substage plumbing) and the
golden-image approach gets you the same reproducibility with much less
ceremony for a homelab-scale batch of sticks.