About a year ago I built a digital picture-frame out of an old Fire HD 10 tablet, Fully-Kiosk and Immich-Kiosk (post here). That lasted until the battery started to swell. I don’t want a puffy lithium cell charging 24/7 on my wall, and the tablet won’t run from the charger alone, so that was the end of iteration one.

Iteration two: a normal monitor on a Raspberry Pi Zero 2 W, still showing Immich-Kiosk in a browser. Worked fine for almost a year, then the SD card died.

This is iteration three. Same screen, same Pi, but no browser, and the OS lives entirely in RAM.

The finished frame

The finished frame

Why the SD card died

Iteration two ran Alpine, but not diskless. A browser writes all the time: logs, caches, session state. Running the OS from RAM would have fixed that, but Chromium on a 512 MB board doesn’t leave enough RAM for it. So everything sat on the card, the card got written around the clock, and eventually it gave up.

For the replacement I wanted two things:

  • The OS runs from RAM. The SD card gets read at boot and never written.
  • No browser. One small program fetches images from 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 config only hits the card when I run lbu commit.

On top of that, immich-rust-kiosk, a Rust program I vibecoded over an evening. It talks to the Immich API, decodes the images and writes them directly to the display via KMS/DRM. No X11, no Wayland, no compositor, not even a framebuffer console. It also draws a clock, the outside temperature and some photo metadata in the corners. The binary is 3.1 MB, statically linked, and uses about 10 MB of RAM.

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

Installing Alpine diskless

The Alpine Raspberry Pi image boots straight into a live system and setup-alpine walks you through the rest.

  1. Format the SD card with one FAT32 partition and unpack the alpine-rpi tarball onto it.
  2. Boot, log in as root (no password), run setup-alpine.
  3. Keyboard, hostname (alpine-frame), network. Pick wlan0, let it scan, enter the WiFi password. I gave it a static IP (10.0.7.28/20, gateway 10.0.0.1) so I can always SSH in.
  4. Timezone Europe/Berlin, ntpd from busybox, a mirror, openssh.
  5. When asked how to use the disk: none. This is what keeps the system diskless.
  6. For config storage pick the SD card. lbu commit then writes /etc as a ~17 KB archive onto the boot partition.

After that the card is never written unless I run lbu commit or update the binary (see below).

Enabling KMS

Without the VideoCore KMS driver there is no /dev/dri/card0. Add this 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 instead of the default 256 MB, which is a lot on a 512 MB board. Bluetooth is off because nothing uses it.

The immich-rust-kiosk project

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

Roughly 2800 lines of Rust in eight files:

  • main.rs: the slideshow loop, night schedule, and a second thread that prepares the next image while the current one is on screen.
  • immich.rs: API client on ureq. Pages through POST /api/search/metadata with isFavorite: true, fetches GET /api/assets/{id}/thumbnail?size=preview, plus the asset detail, /api/faces and /api/albums endpoints for the overlays and the crop.
  • render.rs: JPEG (zune-jpeg) and WebP (image-webp) decoding, a bilinear resampler, the portrait side-by-side layout, face-aware cropping, cross-fade.
  • overlay.rs: clock, temperature and metadata text, rasterized from a bundled font.
  • weather.rs: Open-Meteo client.
  • kms.rs: opens /dev/dri/card0, allocates two dumb buffers, page-flips between them, handles DPMS.
  • window.rs: optional desktop preview backend.
  • config.rs: env var parsing.

It shows the favourites of one or more Immich accounts, shuffled. With one API key per user the pools get merged and deduplicated, so my wife’s favourites and mine end up in one stream.

Landscape photos are cover-fitted, so no black bars. Portrait photos are shown two at a time, side by side, split according to their aspect ratios, instead of as a thin strip in the middle of the screen. When cropping, the app asks Immich where the faces are and shifts the crop towards them, so nobody gets cut out of their own photo. That’s FRAME_FACE_FOCUS, on by default; centered crop is the fallback.

Cross-compiling for the Pi

Built on my x86 workstation, statically linked against musl:

1
nix build .#cross-aarch64-musl

The flake uses nixpkgs’ aarch64-multiplatform-musl cross prefix, and .cargo/config.toml sets target-cpu=cortex-a53 for the Pi Zero 2’s actual core.

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
3263648 bytes

Plain cargo build --release --target aarch64-unknown-linux-musl works too. There are a few regression tests (cargo test) for the things I got wrong first: the diptych has to fill both halves to the bottom, and cover-fit has to fill the whole screen.

Previewing on the desktop

The KMS backend needs DRM master, which my desktop compositor already holds. So there’s an optional window feature that shows the slideshow in a normal window:

1
2
3
4
5
nix develop
FRAME_WINDOW=1 \
IMMICH_URL=https://immich.example.net \
IMMICH_API_KEY=<key> \
cargo run --release --features window

Frames are still composed at the real display resolution and scaled down (FRAME_WINDOW_SCALE, default 0.5), so fades, diptychs and overscan cropping look like they will on the Pi. ESC quits.

Copying it to the Pi

The boot partition is read-only, so:

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, keys and decoding:

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

Then for real. The log looks like this:

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

Autostart

An OpenRC service with supervise-daemon, so a crash restarts it after ten seconds. This is 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
}

Config goes in /etc/conf.d/immich-kiosk:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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
export WEATHER_LAT=48.13
export WEATHER_LON=11.58

Then:

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

Logs go to /var/log, which is tmpfs, so they’re gone after a reboot. I don’t mind; if something’s off I SSH in and look.

How it works

flowchart LR immich["Immich
/api/search/metadata
/api/assets/{id}/thumbnail
/api/assets/{id}, /api/faces, /api/albums"] subgraph pi["Raspberry Pi Zero 2 W - Alpine diskless, root in RAM"] pool["fetch + shuffle
favourites, dedupe"] prep["prepare thread
decode, face-aware crop,
cover-fit, diptych, overscan crop"] overlay["overlay
clock, weather, metadata"] loop["main loop
schedule, fade, DPMS"] kms["KMS/DRM
/dev/dri/card0"] end panel["HDMI panel 1920x1080"] immich -- "HTTPS" --> pool pool -- "asset + pool" --> prep immich -- "metadata, faces, albums" --> prep prep -- "ScreenBuf" --> overlay overlay -- "ScreenBuf via channel" --> loop loop -- "page flip / DPMS" --> kms kms --> panel

The separate prepare thread matters on a 1 GHz quad-core. While one image sits on screen for 60 seconds, the next one is fetched, decoded and composed in the background, metadata included. That takes 0.7 to 3.8 s (worst case so far 8.5 s), which is invisible because it happens during the hold time.

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->>I: detail, faces, albums (per asset) I-->>W: date, location, people, album W-->>M: composed 1920x1080 frame + overlays 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 plain CPU blending, 30 steps per second into a back buffer, then a page flip. Nothing else is running, so it doesn’t cost anything noticeable.

Overlays

The first version just showed pictures. Then I added text in the corners, and suddenly it’s something people in the house actually look at. Font is Share Tech (bundled, OFL) with a soft shadow on subtle top/bottom scrims so it stays readable on any photo.

  • Top left: 24h clock, weekday and date.
  • Top right: outside temperature from Open-Meteo. Free, no API key, just WEATHER_LAT/WEATHER_LON. Refreshed every 10 minutes; if the service is down the old value stays.
  • Bottom: the photo’s metadata on fixed rows: EXIF date (or fileCreatedAt), location as “City, Country”, the people in the photo with their age at the time (from their birth date in Immich), and the newest album containing it, with , ... if there are more. Missing fields leave a gap so nothing jumps around.

In a diptych each half gets its own metadata block.

One thing I ran into: my wife shares albums with me, so some of “her” favourites are actually my assets, and her API key sees no people or albums for them. The app just tries the other configured keys until one returns the data. FRAME_DEBUG_META=1 dumps the raw Immich response per asset if you need to debug this kind of thing.

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, only for log lines
FRAME_INTERVAL60Seconds per slide
FRAME_FADE1Cross-fade seconds (0 = hard cut)
FRAME_OFF / FRAME_ON-Night blanking window, HH:MM each
FRAME_HSCALE1.0Horizontal overscan factor
FRAME_FACE_FOCUSon0 = plain centered crop
WEATHER_LAT / WEATHER_LON-Coordinates for the temperature overlay
FRAME_SIZE1920x1080Composition resolution for headless mode and preview
FRAME_HEADLESS-1 = fetch and compose without KMS
FRAME_DEBUG_META-1 = log raw Immich detail responses
FRAME_WINDOWoff1 = desktop window instead of KMS (needs the window feature)
FRAME_WINDOW_SCALE0.5Window size = FRAME_SIZE × scale

The API keys only need read access.

FRAME_HSCALE=1.15 is there because my panel’s visible area is a bit narrower than 1920 pixels, so full-screen images lost a strip on each side. Composing 15% wider and center-cropping fixes it. On a normal monitor leave it at 1.0.

Resource usage

Binary size3.1 MB, statically linked
RSS10.5 MB (peak 61 MB during compose)
System RAM in use69 MB of 416 MB
CPU~5% average
SoC temperature44 °C, passive
SD card writesnone
Compose time per slide0.7–3.8 s
Boot to first picture~1 min, mostly WiFi and NTP

The whole system now uses less RAM than a single Chromium tab did before.

Updating

Build, scp, remount,rw, move the binary, rc-service immich-kiosk restart. Or run contrib/deploy-pi.sh, which cross-builds, stops the service, keeps a .bak of the old binary, swaps it, restarts and tails the log. It skips all of that if the Pi already has the current build. PI_HOST/PI_BIN override the target.

What it doesn’t do

  • No videos or live photos
  • No album selection, favourites only (the album name does show up in the overlay)
  • No panel rotation
  • Uses Immich preview thumbnails, not originals. At 1920x1080 that’s plenty