# 2026-06-24 — TRMNL plugins LXC (128) + middleware deploy pipeline ## Goal Stand up a dedicated LXC to host self-hosted **middleware for TRMNL e-ink plugins**. TRMNL cloud polls `https://trmnl.hubris.network//dashboard` every 15 min; the middleware fetches/shapes live data and returns JSON that TRMNL merges into the plugin's Liquid template. First consumer: the Munich Home Dashboard (`/munich-home/dashboard`). One LXC + one FastAPI service hosts all current and future plugins (router per plugin). Source repo: gitea `dtoro/terminalito` (app code). This repo only documents the fabric wiring, same split as Artifacto/Plato. ## Current state - No TRMNL middleware in the lab. Highest LXC id is 127 (see `containers/index.md`). - Public hostnames terminate at the [VPS netbird traefik](../hosts/netbird-vps.md) → netbird mesh → [caddy (121)](../containers/121-caddy.md) → backend LXC. Cert obtained by Caddy (IONOS DNS-01) and mirrored to the VPS by the daily cert-sync timer on the host. - Auto-deploy pipelines are gitea-webhook driven, two shapes (see [auto-deploy](../infrastructure/auto-deploy.md)). ## Target state ``` TRMNL cloud --GET 15m, Bearer token--> https://trmnl.hubris.network/munich-home/dashboard VPS traefik (public TLS) --netbird--> caddy (121) --> trmnl (128) :9851 trmnl-plugins.service ├ Open-Meteo (weather) ├ MVG departures (transit) └ Google Calendar (OAuth, SOPS) ``` - **LXC 128 `trmnl`**: Debian, unprivileged, ~1 core / 512 MiB–1 GiB / 8 GiB rootfs. No mounts. - **Service** `trmnl-plugins.service` → `uvicorn server.app:app --host 0.0.0.0 --port 9851`, `EnvironmentFile=/etc/trmnl-plugins/env`. Auth: every path except `/health` requires `Authorization: Bearer $TRMNL_POLL_TOKEN`. - **Auto-deploy** (Shape B): `/opt/terminalito` working tree, sibling `/opt/terminalito-deploy/`. - Public hostname `trmnl.hubris.network`. ## Pre-flight checklist - [ ] Confirm next free LXC id is 128 (`homelab list`, `containers/index.md`). - [ ] Decide IP on `192.168.8.0/16` LAN (e.g. `192.168.8.211`) — pick a free one. - [ ] Have Google OAuth client + refresh token, MVG stop globalIds, and a generated `trmnl_poll_token` ready for the secret (see `dtoro/terminalito` README). ## Step-by-step procedure 1. **Provision + enroll** ```bash # create LXC 128 trmnl on hubris (Debian), then enroll it: homelab client add trmnl # joins netbird, provisions /etc/age/key.txt, edits inventory.yaml homelab client add --finalize-pubkey # commits the age pubkey ssh trmnl 'apt-get install -y python3-venv git' ``` 2. **Secret** (`trmnl-oauth`): create `secrets/trmnl-oauth.yaml` with `google_client_id/secret/refresh_token`, MVG stop ids, and `trmnl_poll_token`; add a `path_regex` rule in `.sops.yaml` granting **trmnl**'s age pubkey; `sops updatekeys`. The service reads it at deploy time via `homelab secret trmnl-oauth` → `/etc/trmnl-plugins/env`. 3. **App + service** on LXC 128 (clone uses the **internal** gitea URL — `git.hubris.network` resets from inside LXCs): ```bash git clone http://192.168.8.121:3000/dtoro/terminalito.git /opt/terminalito python3 -m venv /opt/terminalito/server/.venv /opt/terminalito/server/.venv/bin/pip install -r /opt/terminalito/server/requirements.txt # install /etc/systemd/system/trmnl-plugins.service, enable --now ``` 4. **Deploy pipeline** (Shape B, mirrors homelab-mcp): create `/opt/terminalito-deploy/` `{webhook.py,deploy.sh}` (HMAC vs `/etc/terminalito-deploy/secret`, filter `refs/heads/main`, `git pull` → `pip install -r server/requirements.txt` → rebuild env from `homelab secret` → `systemctl restart trmnl-plugins`). Receiver `:9797`. Git creds at `/etc/terminalito-deploy/git-credentials` (mode 600). Register a gitea webhook on `dtoro/terminalito`; add `192.168.8.<128-ip>` to gitea `app.ini` `ALLOWED_HOST_LIST`. 5. **DNS**: add `trmnl.hubris.network` A → `192.168.8.175` (caddy) on [Technitium (107)](../containers/107-dns.md). 6. **Caddy** (`dtoro/caddy-conf`, commit+push auto-deploys): ``` trmnl.hubris.network { reverse_proxy 192.168.8.<128-ip>:9851 } ``` 7. **Public exposure** on the [VPS](../hosts/netbird-vps.md): add traefik router+service for `Host(\`trmnl.hubris.network\`)` → `http://192.168.8.<128-ip>:9851`; add the host to the cert-sync map so the LE cert mirrors over. 8. **TRMNL cloud**: create a Polling private plugin, URL `…/munich-home/dashboard`, header `Authorization: Bearer `, refresh 15 min; paste `full.liquid`; add to a playlist. ## Verification - `ssh trmnl systemctl is-active trmnl-plugins` → `active`; `curl -s localhost:9851/health` → `ok`. - LAN: `curl -s -H "Authorization: Bearer " https://trmnl.hubris.network/munich-home/dashboard` → 200 JSON; without the header → 401; `/health` → 200. - Public: same curl from off-mesh resolves via VPS, 200. - Push a no-op commit → `/opt/terminalito-deploy` logs show pull+restart; webhook 202. - `homelab mcp get_host trmnl` and `search_docs trmnl` agree with `containers/128-trmnl.md`. ## Post-migration When executed, write changelog entries (same date) on: `containers/128-trmnl.md` (new page), `containers/index.md` (row), `inventory.yaml` (`services.trmnl`), `infrastructure/auto-deploy.md` (pipeline row + `ALLOWED_HOST_LIST` + changelog), `containers/104-gitea.md` (webhook + allowed host), `containers/121-caddy.md` (new proxied host), `containers/107-dns.md` (A record), `hosts/netbird-vps.md` (public route + cert-sync). Then set this plan's status to `Done` in `plans/index.md`.