Live Demo →

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:

  1. The plugin's draw(W, H) function runs inside LÖVE2D headlessly (offscreen Mesa softpipe)
  2. LÖVE2D saves a PNG
  3. ImageMagick converts PNG → 1-bit 800×480 BMP with Floyd-Steinberg dithering
  4. BMP is uploaded to S3-compatible storage via AWS SigV4
  5. 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.

MethodPathAuthDescription
GET/api/setupID header (MAC)Register device, get API key + first image
GET/api/displayID + Access-Token headersGet current image URL + refresh interval
POST/api/logDevice telemetry ping (body ignored)
GET/images/:file.bmp / .pngImage proxy (BMP for devices, PNG for other clients)
GET/healthHealth check → {"ok":true}
GET/api/infoVersion, uptime, counts, S3 health
GET/metricsPrometheus metrics
GET/demoLive render preview

Tech Stack

ComponentRole
LuaJITRuntime — controls everything
libmicrohttpd (FFI)HTTP server, external polling mode
libcurl (FFI)HTTP client for S3-compatible storage
S3-compatible storageState (state/devices.json, state/screens.json, state/playlists.json) + rendered images under images/Backblaze B2 recommended
LÖVE2D (offscreen)Render engine — spawned per render
ImageMagickPNG → 1-bit BMP3 conversion
Mesa softpipeSoftware 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

VariableRequiredDefaultDescription
S3_ACCESS_KEY_IDYesS3 access key ID
S3_SECRET_ACCESS_KEYYesS3 secret access key
S3_BUCKETYesBucket name
S3_REGIONYesRegion, e.g. us-west-004 (B2) or auto (R2)
S3_ENDPOINTYesS3 endpoint URL, e.g. https://s3.us-west-004.backblazeb2.com
S3_PUBLIC_URLNoBase URL for public downloads (CDN or provider download host, e.g. https://f004.backblazeb2.com/file/my-bucket)
ADMIN_KEYNoBearer token for admin API; endpoints return 503 if unset
PORTNo8080HTTP server listen port
SERVER_URLNohttp://localhost:8080Base URL embedded in image links returned to devices
TRMNL_WIDTHNo800Render canvas width in pixels
TRMNL_HEIGHTNo480Render canvas height in pixels

Source

git.mntechstudios.com/forgejo_admin/trmnl-lua