trmnl-lua
A self-hosted server for TRMNL e-ink display devices. One LuaJIT process, ~15MB RAM, no database.
Quick Start
git clone https://git.mntechstudios.com/forgejo_admin/trmnl-lua
cd trmnl-lua && cp .env.example .env # add your S3 credentials
docker compose up
Then on your TRMNL device, set the custom server URL to http://your-host:8080. The device registers itself automatically on first boot — no manual setup needed.
You'll need an S3-compatible storage bucket. Backblaze B2 is recommended — it has a generous free tier and works out of the box. See Environment Variables for the full list of options.
Scriptable Widget (iOS)
Don't have a physical device? The Scriptable iOS app can act as a TRMNL client — it registers itself as a device, polls /api/display, and renders the current image as a homescreen widget.
Get the script from examples/scriptable.js in the repo. Add it to Scriptable, then add a medium widget to your homescreen and set the widget Parameter to your server URL (e.g. https://trmnl.mntechstudios.com).
On first run the script generates a random device MAC, registers with /api/setup, and stores the api_key in iOS Keychain. It refreshes on the same schedule as the server's refresh_rate.
Why
TRMNL is an e-ink display that polls a server for images to show. The official cloud server is a full Ruby/Hanami app with PostgreSQL, Valkey, and Sidekiq — a lot of infrastructure for a personal display showing a clock.
This replaces that stack with something minimal: a single LuaJIT process, ~15MB RAM at idle, no database, no queue, no background workers. All state lives in an S3-compatible storage bucket (Backblaze B2 recommended). 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 S3)
On each render:
- The plugin's
draw(W, H)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 S3-compatible storage via 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(W, H)
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, W, "center")
local date_font = love.graphics.newFont(30)
love.graphics.setFont(date_font)
love.graphics.printf(os.date("%A, %B %d"), 0, 290, W, "center")
end
return M
Plugins are stored in S3 under plugins/<name>.lua and hot-reloaded without redeployment.
HTTP API
Key endpoints — see the full API reference for all routes including admin and playlist management.
| 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 | — | Device telemetry ping (body ignored) |
| GET | /images/:file.bmp / .png | — | Image proxy (BMP for devices, PNG for other clients) |
| GET | /health | — | Health check → {"ok":true} |
| GET | /api/info | — | Version, uptime, counts, S3 health |
| GET | /metrics | — | Prometheus metrics |
| 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 S3-compatible storage |
| S3-compatible storage | State (state/devices.json, state/screens.json, state/playlists.json) + rendered images under images/ — Backblaze B2 recommended |
| 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-s3 -n trmnl \
--from-literal=S3_ACCESS_KEY_ID="$S3_ACCESS_KEY_ID" \
--from-literal=S3_SECRET_ACCESS_KEY="$S3_SECRET_ACCESS_KEY" \
--from-literal=S3_BUCKET="$S3_BUCKET" \
--from-literal=S3_REGION="$S3_REGION" \
--from-literal=S3_ENDPOINT="$S3_ENDPOINT" \
--from-literal=S3_PUBLIC_URL="$S3_PUBLIC_URL"'
The container image is ~140MB. At idle it uses ~15MB RAM and 0% CPU.
Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
S3_ACCESS_KEY_ID | Yes | — | S3 access key ID |
S3_SECRET_ACCESS_KEY | Yes | — | S3 secret access key |
S3_BUCKET | Yes | — | Bucket name |
S3_REGION | Yes | — | Region, e.g. us-west-004 (B2) or auto (R2) |
S3_ENDPOINT | Yes | — | S3 endpoint URL, e.g. https://s3.us-west-004.backblazeb2.com |
S3_PUBLIC_URL | No | — | Base URL for public downloads (CDN or provider download host, e.g. https://f004.backblazeb2.com/file/my-bucket) |
ADMIN_KEY | No | — | Bearer token for admin API; endpoints return 503 if unset |
PORT | No | 8080 | HTTP server listen port |
SERVER_URL | No | http://localhost:8080 | Base URL embedded in image links returned to devices |
TRMNL_WIDTH | No | 800 | Render canvas width in pixels |
TRMNL_HEIGHT | No | 480 | Render canvas height in pixels |