Roughly a year ago I built a DIY digital picture-frame with Fully-Kiosk and Immich-Kiosk on an old Fire HD 10 tablet, documented in this post. That was iteration one. It ended when the battery started expanding - not something I want charging around the clock inside a picture frame on the wall, and the tablet refuses to run from the charger with the battery unplugged.
Iteration two replaced the tablet with a regular screen hooked up to a Raspberry Pi Zero 2 W, showing Immich-Kiosk. Immich-Kiosk is a web application, so a browser had to render it around the clock - and that browser killed the Pi’s SD card after almost a year.
This post is iteration three: same screen, same Pi, but the browser is gone and the operating system now runs entirely from RAM.
Why the SD card died
Iteration two already ran Alpine Linux, but not in diskless mode. A kiosk browser writes constantly: logs, caches, browser state. The usual cure is to run the OS from RAM and only read the card at boot, but the RAM that takes is exactly what the browser on a 512 MB board doesn’t have. So the OS sat on the card, wrote to it around the clock, and the card wore out.
For the replacement I wanted both properties at once:
- The operating system runs entirely from RAM. The SD card is only read at boot, never written.
- No browser. A single program fetches images from my Immich and puts them on the screen.
The new stack
Alpine Linux in diskless mode: root is a tmpfs, the boot partition is mounted read-only, and configuration is written back to the card only when I explicitly run lbu commit. Even kernel modules come from a read-only squashfs.
And immich-rust-kiosk, a small Rust application I vibecoded in an evening. It talks to the Immich API, decodes the images, and writes them straight to the display through KMS/DRM - the kernel’s display interface. No X11, no Wayland, no compositor, no framebuffer console. The binary is 2.9 MB, statically linked, and needs about 10 MB of RAM.
Everything is open source: https://git.relict.de/tom/immich-rust-kiosk.git
Installing Alpine Linux in diskless mode
Alpine’s Raspberry Pi image boots straight into a live system, and setup-alpine asks a dozen questions on the console.
- Format the SD card with one FAT32 partition, unpack the
alpine-rpitarball onto it. - Boot, log in as
root(no password), runsetup-alpine. - Answer the questions: keyboard layout, hostname (
alpine-frame), network. For network pickwlan0, let it scan, choose the SSID and type the WiFi password. Then a static address, because I want SSH access at a known IP:10.0.7.28/20, gateway10.0.0.1. - Timezone
Europe/Berlin, NTP clientntpd(busybox), mirror from the list, SSH serveropenssh. - When asked how to use the disk: none. That keeps the system diskless.
- For config storage choose the SD card, then
lbu commitwrites the whole/etcas a ~17 KB archive onto the boot partition.
In normal operation the card sees zero writes. The only moment anything is written is an explicit lbu commit - and the binary update described below, which needs one remount,rw.
Enabling KMS on the Pi
The kernel needs the VideoCore KMS driver, otherwise there is no /dev/dri/card0 and nothing to draw on. Append to usercfg.txt on the boot partition:
| |
cma-128 gives the GPU 128 MB of contiguous memory for framebuffers - the overlay’s default is 256 MB, a lot on a 512 MB board. Bluetooth is off because the frame has no use for it.
The immich-rust-kiosk project
| |
About 1000 lines of Rust in five files:
main.rs- the slideshow loop: schedule handling, ordering, the night blanking window, and a second thread that prepares the next image while the current one is on screen.immich.rs- the API client, roughly 80 lines usingureq. One call pages throughPOST /api/search/metadatawithisFavorite: trueto collect favourites, another fetchesGET /api/assets/{id}/thumbnail?size=preview.render.rs- image decoding (JPEG viazune-jpeg, WebP viaimage-webp), a hand-written bilinear resampler, the side-by-side composition for portrait shots, and the cross-fade blending.kms.rs- the DRM part: opens/dev/dri/card0, allocates two dumb buffers, and page-flips between them. Also handles DPMS for turning the panel off at night.config.rs- environment variable parsing and the night schedule.
It shows the favourites of one or more Immich accounts in random order. Multiple accounts are a deliberate feature: with one API key per user, the pool is merged and deduplicated, and each preview is fetched with its owner’s key. My wife’s and my favourites become one mixed stream.
Portrait photos are composed as a diptych - two of them side by side, split proportionally to their aspect ratios - so portrait shots don’t appear as a letterboxed sliver in the middle of the screen. Landscape photos are cover-fitted to fill the panel, so there are never black bars.
Cross-compiling for the Pi Zero 2 W
The binary is built on my x86 workstation, statically linked against musl so it runs on Alpine with no runtime dependencies:
| |
The flake uses nixpkgs’ aarch64-multiplatform-musl cross prefix, and .cargo/config.toml sets target-cpu=cortex-a53 so the compiler uses the Pi Zero’s actual core. Result:
| |
Without Nix, plain cargo build --release --target aarch64-unknown-linux-musl works too; the render code has regression tests (cargo test) for the parts that were easiest to get wrong: the diptych must fill both regions to the bottom, and cover-fit must fill the whole screen.
Transferring it to the Pi
The binary goes onto the boot partition, which is mounted read-only during normal operation:
| |
Testing
First without a display, to check URL, API keys and decoding:
| |
Then for real, with the config exported in the shell. The log reads like this:
| |
Autostart after boot
An OpenRC service with supervise-daemon as supervisor, so a crash restarts it after ten seconds. The service file (contrib/immich-kiosk.initd in the repo):
| |
Configuration lives in /etc/conf.d/immich-kiosk, which is exported into the process environment:
| |
Then:
| |
The service comes up with the default runlevel, and because it’s now part of /etc, lbu commit persists it. Note the logs go to /var/log, which is tmpfs - they exist until the next reboot. That’s fine; the frame either shows pictures or it doesn’t, and if I need live logs I SSH in.
How it works
/api/search/metadata
/api/assets/{id}/thumbnail"] subgraph pi["Raspberry Pi Zero 2 W - Alpine diskless, root in RAM"] pool["fetch + shuffle
favourites, dedupe"] prep["prepare thread
decode, cover-fit,
diptych, overscan crop"] loop["main loop
schedule, fade, DPMS"] kms["KMS/DRM
/dev/dri/card0"] end panel["HDMI panel 1920x1080"] immich -- "HTTPS" --> pool pool -- "asset + pool" --> prep prep -- "ScreenBuf via channel" --> loop loop -- "page flip / DPMS" --> kms kms --> panel
The pipeline matters on a 1 GHz quad-core: while the current image sits on screen for 60 seconds, the prepare thread fetches and composes the next one. Composing takes 0.7–3.8 s (worst outlier so far 8.5 s) - comfortably hidden, because it happens during the hold time, not after it.
check night window every second M->>M: after FRAME_OFF? DPMS off, sleep 30 s
The fade is software blending on the CPU: 30 steps per second, each step blended into a back buffer and page-flipped. With the tiny compositor doing nothing else, that costs nothing measurable.
Features
- Favourites from one or more Immich accounts, merged into one shuffled, deduplicated pool, refreshed every pass
- Cover-fit rendering: images fill the screen, no black bars
- Automatic diptych for portrait photos, split by aspect ratio
- Cross-fade between slides
- Night blanking via DPMS on a schedule, safe against the Pi’s missing RTC (more on that below)
- Overscan compensation:
FRAME_HSCALEcomposes the frame wider and crops it back, for panels that cut off pixels - Graceful degradation: a failing account is retried with backoff and simply excluded; the rest keeps running
- Headless self-test mode
Configuration
Everything is environment variables:
| Variable | Default | Meaning |
|---|---|---|
IMMICH_URL | required | Base URL of the Immich instance |
IMMICH_API_KEY | required | One or more API keys, comma-separated |
IMMICH_USERS | - | Display names per key, for log lines only |
FRAME_INTERVAL | 60 | Seconds per slide |
FRAME_FADE | 1 | Cross-fade seconds (0 = hard cut) |
FRAME_OFF / FRAME_ON | - | Night blanking window, HH:MM each, both required |
FRAME_HSCALE | 1.0 | Horizontal overscan factor |
FRAME_HEADLESS | - | 1 = fetch and compose without KMS, for testing |
The API keys only need read access - the app reads favourites and thumbnails, nothing else. IMMICH_USERS is cosmetic: with two keys in IMMICH_API_KEY, the first belongs to the first user and so on, purely for nicer log lines.
FRAME_HSCALE=1.15 is my panel’s fault: its active area is slightly smaller than the advertised 1920 pixels, so a full-screen image lost a strip on each side. Composing 15% wider and center-cropping fixes it. On a monitor without overscan you leave it at 1.0.
What it costs to run
Measured on the running frame:
| Binary size | 2.9 MB, statically linked |
| RSS of the app | 10.5 MB (peak 61 MB during compose) |
| Whole system RAM in use | 69 MB of 416 MB |
| CPU | ~5% average |
| SoC temperature | 44 °C, passive |
| SD card writes in normal operation | none |
| Compose time per slide | 0.7–3.8 s |
| Boot to first picture | ~1 min (WiFi + NTP dominate) |
For comparison: the previous setup ran an entire browser to end up showing JPEGs. The whole Alpine system here uses less RAM than a single Chromium tab.
Updating
Build, scp, one remount,rw, move the binary, rc-service immich-kiosk restart.
What it doesn’t do
- No videos or live photos
- No clock, date, or metadata overlay
- No album support - favourites only, by design
- No panel rotation
- Thumbnails are Immich
previewsize, not the original files. At 1920x1080 that’s more than enough