display-pi setup guide — fresh Pi to Sunday-morning ready
End-to-end procedure for turning a blank Raspberry Pi into a working RTMP-driven kiosk display. Written for a single-Pi deployment receiving an RTMP push from an ATEM Mini Pro; adapt the obvious bits if your upstream is different.
If you already have a working Pi and just want to push code changes, skip to Day-to-day operations.
What you need
Hardware
| Item | Pi 4 (recommended) | Pi 5 (also supported, see caveats) |
|---|---|---|
| Board | Pi 4 Model B, 2 GB is enough (4/8 GB for headroom) | Pi 5, 4 GB or 8 GB |
| Power supply | Official 5 V / 3 A USB-C | Official 5 V / 5 A USB-C-PD |
| Cooling | Passive heatsink fine | Active cooler strongly recommended |
| Storage | 32 GB+ SD card (Samsung Pro / SanDisk Extreme — avoid no-name) | |
| HDMI | Micro-HDMI to HDMI cable, port 0 | Same |
| Network | Ethernet preferred — RTMP video on Wi-Fi is unreliable | |
| Capture (optional) | USB3 HDMI capture (e.g. MACROSILICON 345f:2131) on the workstation for visual debugging |
On RAM: a 2 GB Pi 4 is fine for the relay-only kiosk role. nginx relays the RTMP push (no transcode) and mpv decodes in hardware (
--hwdec=v4l2m2m-copy), so the whole stack sits well under 1 GB. The real reliability levers are cooling, SD-card quality, and Ethernet — not memory. Step up to 4/8 GB only if you add a second job to the same Pi (recording, a browser overlay, a MediaMTX relay).
HDMI 0 is the micro-HDMI port closer to the USB-C power input. The
script and player are pinned to vc4hdmi0 (HDMI 0); use HDMI 1 only if
you change the wiring documented in
docs/dev-journal/2026-04-25-hdmi-audio-routing.md.
Software you’ll be installing
- Raspberry Pi OS Lite (64-bit, Debian Bookworm) — the script assumes this exact base.
- The
display-pirepo, cloned to your workstation. - An RTMP source (an ATEM Mini Pro is what we use; OBS/ffmpeg also work).
Network plan
Have an answer for these before you start:
- Pi IP address — DHCP is fine; reserve it on the router so it’s stable.
- RTMP allow-list — the CIDR(s) permitted to push RTMP. Default is the
wired LAN
192.168.0.0/24; tighten to the ATEM’s exact IP (e.g.192.168.0.42/32) for even better hygiene once it’s stable. The stream key is not a secret, so this allow-list is the real control on who can push to the display. - Stream key — defaults to
restoration. The ATEM and the kiosk must agree on this. - Static fallback IP (optional) — if you ever need to reach the Pi on a
network with no DHCP server (a laptop patched straight into the Pi, a
dumb switch, a field rig), set
STATIC_IPand the Pi will bind an extra fixed address in addition to DHCP. Set it to something outside your normal LAN range so it never collides — e.g.192.168.50.1/24. See Optional: a static fallback IP below.
Installation procedure
Fast path: once steps 1–4 are done (flash, first boot, SSH config, repo checkout on the workstation), a single command takes the Pi the rest of the way — base kiosk, full deploy, volunteer web manager, and shortcut files:
make provision HOST=displaypi STREAM_KEY=restorationIt runs
setup→deploy→setup-web→volunteer-web-urlin that order (the order matters — seemake help), and every step is idempotent so it’s safe to re-run. Steps 5–8 below do the same work by hand and explain each piece; read them to understand whatprovisionautomates, or to run a step on its own.
1. Flash the SD card
Use the Raspberry Pi Imager.
- OS: Raspberry Pi OS Lite (64-bit), Bookworm.
- Customise (gear icon, do this — saves you a lot of first-boot pain):
- Hostname:
displaypi(or whatever you’ll use in your SSH config). - Username:
rpi(or pick another, but remember — this is the deploy user). - SSH: enable, prefer public-key auth (paste your workstation’s
~/.ssh/id_*.pub). - Wi-Fi: only fill in if you actually intend to use Wi-Fi. Ethernet is better for video.
- Locale and timezone: set to your local timezone (logs will be more readable).
- Hostname:
- Write, eject, insert into the Pi.
2. First boot
- Plug in Ethernet, HDMI 0 (port closer to USB-C), and any USB peripherals you want.
- Plug in power. The Pi will expand the filesystem and reboot once.
-
After ~90 seconds, find the IP. Easiest: check your router’s DHCP lease table for the new
displaypientry. Or:# From the workstation, on the same LAN: ping displaypi.local # mDNS, often works arp -a | grep -i 'b8:27:eb\|d8:3a:dd\|dc:a6:32\|2c:cf:67' # Pi MAC OUIs
3. SSH config on the workstation
Add an alias so all the project’s tooling (make deploy, make logs,
etc.) just works:
# ~/.ssh/config
Host displaypi
HostName 192.168.0.106 # or displaypi.local, or whatever
User rpi
IdentityFile ~/.ssh/id_ed25519
Smoke test:
ssh displaypi true && echo OK
If you didn’t pre-load your SSH key during imaging:
ssh-copy-id displaypi
4. Clone the repo onto the workstation
git clone git@github.com:dmcbane/display-pi.git
cd display-pi
5. Run setup-kiosk.sh on the Pi
The Pi needs a copy of the repo to run setup-kiosk.sh. Ship it over:
# From the workstation (in the display-pi/ checkout):
rsync -avz --exclude='.git/' . displaypi:~/display-pi/
ssh displaypi
cd ~/display-pi
Edit configuration if you need to (the defaults in install/setup-kiosk.sh
are sane for a 192.168.x.x LAN with a restoration stream key — read the
top of the file and adjust if not):
$EDITOR install/setup-kiosk.sh
Run it:
bash install/setup-kiosk.sh
You’ll be prompted for your sudo password once. The script is idempotent — safe to re-run. It does, in order:
- Installs packages (cage, mpv, nginx-rtmp, pipewire, watchdog, …)
- Creates the
kioskuser - Enables
seatd - Configures nginx with the RTMP module
- Updates
/boot/firmware/config.txtandcmdline.txt(watchdog,vc4.force_hotplug=1,consoleblank=0) 5b. IfSTATIC_IPis set, binds an extra static IP on Ethernet alongside DHCP (see Optional: a static fallback IP). Skipped whenSTATIC_IPis empty. - Installs the splash image at
/home/kiosk/splash.png. If the repo shipsimages/splash.png, that file is used as-is. If it’s absent but other images are present inimages/, setup prompts you to pick one (on a non-interactive run it skips the prompt). With no usable image, it generates a placeholder from$SPLASH_TEXT. - Installs a minimal bootstrap player script (overwritten by the full
one when you
make deploy) - Installs the systemd user service
- Configures the hardware watchdog
- Configures PipeWire client.conf
10b. Installs the deploy sudoers whitelist (
/etc/sudoers.d/kiosk-deploy) 10c. Generates the system locale (DISPLAY_LOCALE, defaulten_US.UTF-8), makes it the default, and stripsLANG/LC_*from sshd’sAcceptEnvso an SSH login never shows the cannot change locale warning — no matter what the client forwards (see Optional: change the system locale). - Installs logrotate config
- Installs the healthcheck cron stub
When it finishes, reboot:
sudo reboot
After ~30 s the Pi will come back into kiosk mode: a brief boot diagnostics screen, then the splash image. From here on, you do not need to log into the Pi for routine work.
Optional: a static fallback IP
On a normal LAN the Pi gets its address from DHCP and you never think about
this. But sometimes there’s no DHCP server — you’ve patched a laptop straight
into the Pi with one cable, plugged it into a dumb switch, or set it up in the
field. Set STATIC_IP and the Pi binds a fixed IPv4 address in addition to
its DHCP lease, so it’s reachable at a known address in both situations:
# During setup (or provision), from the workstation:
make setup STATIC_IP=192.168.50.1/24 HOST=displaypi
Pick a subnet outside your normal LAN range (e.g. 192.168.50.0/24) so the
static address never collides with a real DHCP lease. NetworkManager keeps
requesting DHCP as usual, so this is purely additive — it does not change how
the Pi behaves on your regular network.
To reach the Pi over the fallback address, give the connecting machine another address in the same subnet and SSH across:
# On the laptop, once cabled to the Pi (macOS/Linux example):
sudo ip addr add 192.168.50.2/24 dev eth0
ssh rpi@192.168.50.1
Notes:
- The change applies on the next reboot — setup does not bounce the live
connection (that would drop the SSH session it runs over). To activate it
immediately instead, run
sudo nmcli connection up kiosk-staticon the Pi (expect a brief network blip if you’re SSH’d in over that NIC). - It’s implemented as a dedicated NetworkManager profile named
kiosk-staticwith a higher autoconnect priority than the stockWired connection 1. Re-running setup recreates it cleanly, so addresses never stack up. - By default no gateway or DNS is set on the static address — it’s for direct
reach, not the Pi’s default route (DHCP still supplies the real route
wherever the Pi is plugged in). If the static address is the Pi’s primary
identity on a DHCP-less network and it needs a route/resolver, pass them
explicitly:
make setup STATIC_IP=192.168.50.1/24 STATIC_GATEWAY=192.168.50.254 STATIC_DNS=192.168.50.254,1.1.1.1. - Reachability is a routing question, not a gateway-on-the-Pi question: a
machine on a different subnet (say your laptop at
192.168.1.x/24trying to reach192.168.0.106) can only get there if its router knows a route to that subnet. On the same L2 segment, give the connecting machine an address in the static subnet (as in the example above) and it works with no gateway anywhere. - To remove it later, re-run with
STATIC_IP=none:make setup STATIC_IP=none HOST=displaypi.
Optional: change the system locale
By default the Pi is set to en_US.UTF-8, and — importantly — sshd is told to
ignore the locale your SSH client forwards. That combination means every
login is clean and identical no matter which machine you connect from: no more
-bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
You don’t have to do anything to get this; it’s part of the base setup. If your
region isn’t US English, set DISPLAY_LOCALE so the Pi’s default matches:
make setup DISPLAY_LOCALE=en_GB.UTF-8 HOST=displaypi
Why the warning happened in the first place: a fresh Raspberry Pi OS image has
almost no locales generated, but most SSH clients forward LANG/LC_* from the
desktop they run on. When the Pi doesn’t have that locale, every program that
reads it complains. Setup fixes both halves — it generates the default locale
and stops sshd from importing the forwarded values — so the result no longer
depends on the client at all.
6. First deploy from the workstation
Back on your workstation, in the display-pi/ checkout:
make deploy
Should finish without prompting for any password (the sudoers whitelist
from step 10b is already in place). This pushes the full repo to the
Pi, symlinks the real player.sh, restarts the kiosk service, and
reloads nginx.
7. Configure your RTMP source
The ATEM Mini Pro is what we use. In ATEM Software Control:
- Settings → Output → Stream Service. Add a custom destination using Blackmagic’s Streaming.xml generator or by editing the Streaming.xml file directly.
- Server URL:
rtmp://<pi-ip>/live(e.g.rtmp://192.168.0.106/live) - Stream key:
restoration(or whatever you set ininstall/setup-kiosk.sh). - Press the LIVE button on the ATEM. The Pi should switch from the splash to your live feed within a few seconds.
For OBS Studio: same URL/key, plug into the Stream settings under Custom.
For ad-hoc testing without the ATEM, see step 8.
8. Verify with a test stream
From the workstation:
make test-stream # 60 s of 1080p test pattern + 440 Hz tone
The Pi should switch from splash to the test pattern within a few
seconds, you should hear a 440 Hz tone, and make test-stream-long
gives you 5 minutes if you need more time at the receiver.
If you can’t hear audio:
docs/dev-journal/2026-04-25-hdmi-audio-routing.md
walks through the diagnosis.
Day-to-day operations
All from the workstation, in the display-pi/ checkout:
| Command | What it does |
|---|---|
make deploy |
Push repo + restart kiosk service. No password prompts. |
make test-stream |
60 s test pattern with audio. |
make test-stream-long |
5 min version, for in-place AV testing. |
make ssh |
Interactive shell on the Pi. |
make logs |
Tail kiosk + nginx logs. |
make status |
Show kiosk service status. |
make diag |
Run diagnostics on the Pi (text output). |
make ssh-password |
Toggle SSH password login: STATE=on (key OR password), STATE=off (key-only), STATE=status (default, just report). |
make ping |
3 pings to verify the Pi is reachable. |
make reboot |
Reboot the Pi. |
make shutdown |
Power off the Pi (sudo poweroff). |
make sudoers |
One-time: install the deploy sudoers whitelist (only needed if you skip step 5 or rebuild the Pi without re-running setup-kiosk.sh). |
Splash images
The kiosk cycles through the images in /home/kiosk/splash.d/ whenever the
stream is idle, advancing one image each time the splash comes back up
(when the stream drops, or the kiosk service restarts). There is no timer — a
single continuous idle period shows one image until the splash is re-entered.
The cursor is persisted to /home/kiosk/.splash-index, so it keeps moving
across restarts instead of snapping back to the first slide (this is what makes
make restart step to the next image during testing).
/home/kiosk/splash.d and /home/kiosk/splash.png are symlinks into the
deployed repo (/home/kiosk/display-pi/images/…), exactly like the bin/
scripts — there are no separate copies to keep in sync.
- Rotation set: drop 1920×1080 PNGs into
~/display-pi/images/splash.d/on the workstation (prefix01-,02-, … to order them) andmake deploy. The deploy re-points the symlink and preserves the volunteer slide (00-volunteer.png, protected from--deleteby an rsync exclude). With one image the same slide shows every time. - Single fallback: if the folder is ever empty, the kiosk falls back to the
single
/home/kiosk/splash.png(→images/splash.png). - Cycle manually while testing:
make restart(advances one slide), or send amake test-streamand let it end (a stream toggle also advances one). - Volunteers replace their slide from the browser-based web manager (below);
their image joins the rotation. An offline SSH-bundle path also exists — see
docs/admin-splash-update.md.
The volunteer web manager
make provision (or make setup-web) installs a browser-based manager that lets
volunteers swap splash images, restart or reboot the Pi, and watch a live
System Status board — all from one bookmarked link, no SSH key required. The
link carries an access token you can rotate in one click if it leaks.
The manager is served over HTTPS by default with a certificate signed by a small CA generated on the Pi — no domain needed. Trust that CA once per device for a warning-free padlock:
make web-ca HOST=displaypi # saves display-pi-rootCA.crt — import it as a trusted root
If you control a domain, make setup-web-tls DOMAIN=… swaps in a publicly-trusted
Let’s Encrypt cert instead (no per-device import). Full walkthrough:
Web Manager — Splash, Status, HTTPS & Tokens.
SSH password login
setup-kiosk.sh configures the Pi to accept SSH login by public key OR
password. The setting lives in a single drop-in,
/etc/ssh/sshd_config.d/00-display-pi-auth.conf, whose 00- prefix makes it
sort first and win sshd’s first-value-wins resolution over any later drop-in
(including the key-only file rpi-imager writes) and the stock config.
Flip it without hand-editing config:
# From the workstation:
make ssh-password STATE=off # key-only (hardened)
make ssh-password STATE=on # allow public key OR password again
make ssh-password # STATE=status — show the effective setting
Public-key auth is always kept enabled, so STATE=off can never lock out
key-based logins. The toggle validates with sshd -t and applies with a
reload (not a restart), so the SSH session you run it from stays up even if the
new config were rejected. On the Pi directly:
sudo bash install/sshd-password-toggle.sh on|off|status.
To change the stream key, RTMP allow-list, or any other config: re-run
bash install/setup-kiosk.sh on the Pi after editing the config block at
the top. The script is idempotent and backs up everything it touches.
Pi 4 vs Pi 5 — known differences
setup-kiosk.sh was developed against a Pi 4 and is the supported
reference platform. The Pi 5 should work, with these caveats:
hdmi_enable_4kp60=1inconfig.txtis a Pi-4-only knob — the Pi 5 enables 4Kp60 by default on both micro-HDMI ports. The line is harmless on Pi 5 but unnecessary; you can remove it.dtoverlay=disable-bt— the overlay loads on both Pi 4 and Pi 5 but routes through the RP1 chip on Pi 5; behavior should match. Verify withbluetoothctl listafter setup if you care; should be empty.- Active cooling — the Pi 5 will throttle without an active cooler during sustained 1080p H.264 decode. Get the official Active Cooler or equivalent.
- PSU — the Pi 5 must have the 5 V / 5 A PSU (or a USB-PD source capable of negotiating 5 A). With a 3 A PSU, USB ports are limited to 600 mA total and you will get random brownouts under load.
- HDMI port enumeration — both boards expose
vc4hdmi0(HDMI 0, closer to the USB-C input) andvc4hdmi1; the project’s pin toCARD=vc4hdmi0works on both.
If you’re deploying a Pi 5 and hit something not covered here, please
add a note to docs/dev-journal/.
Troubleshooting
“No HDMI signal” on first boot
- Make sure the cable is in micro-HDMI port 0 (closer to USB-C).
- Confirm
vc4.force_hotplug=1is in/boot/firmware/cmdline.txt. If the TV was off when the Pi booted and the override is missing, the Pi negotiated zero modes and won’t display until you reboot with the TV on first. - Check
/sys/class/drm/card?-HDMI-A-1/statusover SSH — should sayconnected.
Stream is live but no audio on HDMI
This was the original sin that started the journal. See
docs/dev-journal/2026-04-25-hdmi-audio-routing.md. Quick check:
ssh displaypi 'sudo grep audio-device /home/kiosk/bin/player.sh'
# Should show: --audio-device=alsa/plughw:CARD=vc4hdmi0,DEV=0
Stream pushes from the ATEM but kiosk doesn’t switch
- Verify nginx accepted the publish:
make logsLook for
publishentries with the ATEM’s IP. - Verify the ATEM’s IP is in
RTMP_ALLOW_PUBLISH_CIDRS. - Verify the stream key matches.
- Verify port 1935 is reachable from the ATEM:
nc -zv <pi-ip> 1935
Kiosk service keeps crashing
make status # see Restart=always firing
make ssh
journalctl --user -u kiosk.service --since '5 min ago'
sudo tail -100 /tmp/player.log
The most common causes are nginx not running (publisher path broken) or the splash image missing/corrupted. The boot assessment screen will show which check failed — wait for it to display on HDMI before the splash takes over.
make deploy asks for a password
The deploy sudoers whitelist isn’t installed (or got deleted). Run:
make sudoers
This is the same step setup-kiosk.sh does as part of step 10b.
Pi gets stuck at the rainbow boot screen
Almost always a cmdline.txt corruption — setup-kiosk.sh defends against
this with a backup-and-restore-on-error path, but if something else
edited the file:
- Power off, pull the SD card.
- On your workstation, mount the boot partition, fix cmdline.txt (must be exactly one non-empty line).
- Re-insert and boot.
The script keeps timestamped backups at /boot/firmware/cmdline.txt.bak-*
so you can always restore.
Where to look next
- Architecture decisions:
docs/dev-journal/ - What changed when:
CHANGELOG.md - Source of truth for the player loop:
install/player.sh - All the install knobs: the config block at the top of
install/setup-kiosk.sh