trmnl-lua
A self-hosted TRMNL-compatible e-ink display server, built in LuaJIT. One process, ~15MB RAM, no database.
Why
TRMNL is an e-ink display device that polls a server for images to show. The official server is a full Ruby/Hanami app with PostgreSQL, Valkey, and Sidekiq — a lot of infrastructure for a personal display showing a clock.
This project replaces that stack with something minimal: a single LuaJIT process, ~15MB of RAM at idle, no database, no queue, no background workers. All state lives in a Backblaze B2 bucket. The container is fully stateless.
How It Works
TRMNL device → GET /api/setup → register, get image_url
→ GET /api/display → poll for next image + refresh interval
→ GET /images/*.bmp → fetch rendered BMP (proxied from B2)
On each render:
- The plugin's
draw()function runs inside LÖVE2D headlessly (offscreen Mesa softpipe) - LÖVE2D saves a PNG
- ImageMagick converts PNG → 1-bit 800×480 BMP with Floyd-Steinberg dithering
- BMP is uploaded to Backblaze B2 via S3-compatible API with AWS SigV4
- Device fetches the BMP through this server's
/images/proxy
Plugin API
Plugins are plain Lua files that use the love.graphics API:
-- plugins/clock.lua
local M = {}
M.refresh_seconds = 60
function M.draw()
love.graphics.setColor(0, 0, 0)
local time_font = love.graphics.newFont(108)
love.graphics.setFont(time_font)
love.graphics.printf(os.date("%H:%M"), 0, 150, 800, "center")
local date_font = love.graphics.newFont(30)
love.graphics.setFont(date_font)
love.graphics.printf(os.date("%A, %B %d"), 0, 290, 800, "center")
end
return M
Plugins can be stored locally (bundled in the container) or uploaded to B2 under plugins/<name>.lua for hot-reload without redeployment.
HTTP API
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/setup | ID header (MAC) | Register device, get API key + first image |
| GET | /api/display | ID + Access-Token headers | Get current image URL + refresh interval |
| POST | /api/log | Access-Token | Device log (written to stdout) |
| GET | /images/:file.bmp | — | BMP proxy (fetched from B2) |
| GET | /health | — | Health check → {"ok":true} |
| GET | /demo | — | Live render preview |
Tech Stack
| Component | Role |
|---|---|
| LuaJIT | Runtime — controls everything |
| libmicrohttpd (FFI) | HTTP server, external polling mode |
| libcurl (FFI) | HTTP client for Backblaze B2 |
| Backblaze B2 | State (devices.json, screens.json) + rendered BMPs |
| LÖVE2D (offscreen) | Render engine — spawned per render |
| ImageMagick | PNG → 1-bit BMP3 conversion |
| Mesa softpipe | Software OpenGL for LÖVE2D headless |
Deployment
# Build
sudo docker build -t trmnl-lua:latest .
sudo docker save trmnl-lua:latest | microk8s ctr images import -
# Apply manifests
microk8s kubectl apply -f k8s/trmnl.yaml
# Secret (via Doppler)
doppler run -- sh -c 'microk8s kubectl create secret generic trmnl-b2 -n trmnl \
--from-literal=B2_ACCESS_KEY_ID="$B2_ACCESS_KEY_ID" \
--from-literal=B2_SECRET_ACCESS_KEY="$B2_SECRET_ACCESS_KEY" \
--from-literal=B2_BUCKET="$B2_BUCKET" \
--from-literal=B2_REGION="$B2_REGION"'
The container image is ~140MB. At idle it uses ~15MB RAM and 0% CPU.