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.

  1. Format the SD card with one FAT32 partition, unpack the alpine-rpi tarball onto it.
  2. Boot, log in as root (no password), run setup-alpine.
  3. Answer the questions: keyboard layout, hostname (alpine-frame), network. For network pick wlan0, 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, gateway 10.0.0.1.
  4. Timezone Europe/Berlin, NTP client ntpd (busybox), mirror from the list, SSH server openssh.
  5. When asked how to use the disk: none. That keeps the system diskless.
  6. For config storage choose the SD card, then lbu commit writes the whole /etc as 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:

1
2
3
4
dtoverlay=vc4-kms-v3d,cma-128
boot_delay=0
disable_splash=1
dtoverlay=disable-bt

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

1
git clone https://git.relict.de/tom/immich-rust-kiosk.git

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 using ureq. One call pages through POST /api/search/metadata with isFavorite: true to collect favourites, another fetches GET /api/assets/{id}/thumbnail?size=preview.
  • render.rs - image decoding (JPEG via zune-jpeg, WebP via image-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:

1
nix build .#cross-aarch64-musl

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:

1
2
3
4
$ file result/bin/immich-kiosk
ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked
$ ls -l result/bin/immich-kiosk
2962464 bytes

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:

1
2
3
4
5
scp result/bin/immich-kiosk [email protected]:/tmp/
ssh [email protected]
mount -o remount,rw /media/mmcblk0p1
mv /tmp/immich-kiosk /media/mmcblk0p1/
mount -o remount,ro /media/mmcblk0p1

Testing

First without a display, to check URL, API keys and decoding:

1
FRAME_HEADLESS=1 /media/mmcblk0p1/immich-kiosk

Then for real, with the config exported in the shell. The log reads like this:

1
2
3
4
5
[11:32:59] display 1920x1080
[11:32:59] immich accounts: tom, jayne
[11:32:59] tom: 780 favorites (780 new to pool)
[11:32:59] jayne: 754 favorites (702 new to pool)
[11:33:01] prepared tom/285e73d0-... in 1650ms (pair)

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/sbin/openrc-run

supervisor=supervise-daemon
name="immich-kiosk"
description="Immich favorites slideshow on KMS/DRM"
command="${KIOSK_BIN:-/media/mmcblk0p1/immich-kiosk}"
pidfile="/run/immich-kiosk.pid"
output_log="${KIOSK_LOG:-/var/log/immich-kiosk.log}"
error_log="${KIOSK_LOG:-/var/log/immich-kiosk.log}"
respawn_delay="${KIOSK_RESPAWN_DELAY:-10}"

depend() {
	after networking wpa_supplicant
}

Configuration lives in /etc/conf.d/immich-kiosk, which is exported into the process environment:

1
2
3
4
5
6
7
8
export IMMICH_URL=https://immich.example.com
export IMMICH_API_KEY=<redacted>,<redacted>
export IMMICH_USERS=tom,jayne
export FRAME_INTERVAL=60
export FRAME_FADE=1
export FRAME_OFF=22:00
export FRAME_ON=08:00
export FRAME_HSCALE=1.15

Then:

1
2
3
rc-update add immich-kiosk default
lbu commit -d
reboot

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

flowchart LR immich["Immich
/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.

sequenceDiagram participant M as main loop participant W as prepare thread participant I as Immich M->>W: next asset (with pool) W->>I: GET thumbnail (size=preview) I-->>W: JPEG / WebP W-->>M: composed 1920x1080 frame M->>M: cross-fade (~30 fps), page flip Note over M: hold FRAME_INTERVAL s
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_HSCALE composes 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:

VariableDefaultMeaning
IMMICH_URLrequiredBase URL of the Immich instance
IMMICH_API_KEYrequiredOne or more API keys, comma-separated
IMMICH_USERS-Display names per key, for log lines only
FRAME_INTERVAL60Seconds per slide
FRAME_FADE1Cross-fade seconds (0 = hard cut)
FRAME_OFF / FRAME_ON-Night blanking window, HH:MM each, both required
FRAME_HSCALE1.0Horizontal 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 size2.9 MB, statically linked
RSS of the app10.5 MB (peak 61 MB during compose)
Whole system RAM in use69 MB of 416 MB
CPU~5% average
SoC temperature44 °C, passive
SD card writes in normal operationnone
Compose time per slide0.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 preview size, not the original files. At 1920x1080 that’s more than enough