# 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/<plugin>/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.yaml) → netbird
  mesh → [caddy (121)](../knowledge/wiki/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](../knowledge/wiki/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 <age_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)](../knowledge/wiki/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.yaml): 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 <trmnl_poll_token>`, 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 <tok>" 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`.
