Create a rasberrypi powered TV box to connect to various services through a tailnet
  • Shell 88.9%
  • Go Template 8%
  • CSS 3.1%
Find a file
Kurt Anderson a43a44d4cc Skip unsubstituted __TOKEN__ placeholders as tiles; reorder Plex before Jellyfin
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>
2026-08-24 00:09:41 -07:00
files Skip unsubstituted __TOKEN__ placeholders as tiles; reorder Plex before Jellyfin 2026-08-24 00:09:41 -07:00
fleet Add first-boot re-individualization and per-device config 2026-08-23 21:18:01 -07:00
.env.example Add auto-derived per-device Jitsi tile 2026-08-23 22:42:10 -07:00
.gitignore Add public-repo scaffolding: .gitignore, .env pattern, build script 2026-08-23 21:17:40 -07:00
build.sh Add auto-derived per-device Jitsi tile 2026-08-23 22:42:10 -07:00
provision.sh Fix chromium-browser -> chromium binary/package name 2026-08-24 00:00:27 -07:00
README.md Document the golden device's own first-boot dance 2026-08-23 22:50:03 -07:00

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.txt so it doesn't come up blank on a TV it wasn't tested against.
  • Idempotent provisioning script. provision.sh can 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

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

  2. 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
    
  3. 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.

  4. 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 HOSTNAME is set in kiosk-config.txt (still the default stick-changeme at 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).

  5. 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
    
  6. 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:

  1. Expands the root filesystem to fill the SD card (PiShrink shrank it down to minimum size).
  2. 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.
  3. Sets the hostname from kiosk-config.txt.
  4. 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 imageprovision.sh narrows unattended-upgrades to 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.txt on 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 run sudo /opt/kiosk/kiosk-apply-config.sh to 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-browser
  • kiosk-session.service — the X session itself starting/stopping/crashing
  • kiosk-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
    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=Running
    
    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.
  • system-errors at severity err or 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.