feat(trmnl): deploy Terminus BYOS server for the entrance e-ink panel #480

Merged
k.renner merged 1 commits from feat-trmnl-terminus into master 2026-08-10 20:59:03 +00:00
Member

Phase 1 of the TRMNL X entrance dashboard plan — stands up the self-hosted Terminus (BYOS) server the panel polls. The HA dashboard, e-ink theme and screenshot renderer are later phases and are not in this PR.

Phase 0 findings (verified before writing anything)

Check Result
TRMNL X model in Terminus Present. id: v2, label: "TRMNL X", 1872x1404, 16 colours, 4-bit. The plan's stop-and-report condition does not trigger.
How the model list is populated Not via config/db/seeds.rb. The MODEL_SYNCHRONIZER Sidekiq job pulls it from trmnl.com — the Models list is empty until that job runs once.
Add-on vs standalone container Our HA is plain HA Core, not Supervised. The add-on still works standalone: it prefers env vars over /data/options.json and authenticates with an ordinary long-lived token (SUPERVISOR_TOKEN only flips an isAddOn boolean). The plan's fallback path is viable. Lands with Phase 4.
Terminus version 0.68.0 (2026-08-05). Repo was renamed from byos_hanami; image is ghcr.io/usetrmnl/terminus. No GitHub Releases, no changelog.
Firmware TLS constraints UNVERIFIED upstream — nothing in the Terminus repo or docs states TLS 1.2-only or CA rejection. Plain HTTP for the device route is implemented as the plan specifies, but the justification rests on the plan, not on a doc I could cite.

Deliberate deviations from house patterns

tr-pg runs PostgreSQL 18, not the standard 16. Terminus ships a committed config/db/structure.sql containing SET transaction_timeout = 0; — a GUC that does not exist before PG 17 — so schema load against a 16 server fails with unrecognized configuration parameter. This is the first non-16 CNPG cluster here. Worth noting the existing 16 pin is inherited: it first appears in 739a67e9 (Apr 2026, Authentik migration) and was copy-pasted into every later cluster with no documented rationale.

Device endpoint is cleartext HTTP on a Cilium BGP LoadBalancer, not an Ingress. The firmware cannot be handed the internal CA, and a TLS-less Ingress would be a new pattern for this repo — a dedicated LB address is not. Admin UI keeps a normal TLS Ingress. Both front the same Service; same split as home.renner.uno / ha.renner.uno.

  • trmnl.renner.uno — TLS, Traefik, admin UI
  • trmnl-device.renner.uno — LB, port 80, cleartext, device only. API_URI points here because Terminus bakes it into the absolute image_url the device fetches.

Terminus is NOT enrolled in image automation, despite publishing a usable tag stream. It serves the firmware and /api/display contract to a wall-mounted device; upstream is pre-1.0 with no releases or changelog; an unattended bump that breaks either leaves the panel dark with no rollback that avoids the captive portal. Bumps are manual and reviewed — rationale recorded in image-automation/README.md. The Valkey sidecar is enrolled.

Egress is an FQDN allowlist, not toEntities: world. Scoped to trmnl.com and the S3 host serving firmware binaries. Two caveats written into the policy:

  • The S3 host is the url field of the /api/firmware/latest response, not a source constant — TRMNL can move bucket/region without a Terminus release and firmware sync then fails closed here.
  • Terminus's Extensions/Poll feature fetches admin-configured URLs from a DB-stored Liquid template. Those are unknowable from source and will be blocked. Accepted — screen content is pushed in by the renderer, not polled by plugins.

The CNPG pods are deliberately left out of that egress policy, so the barman WAL archive path is not behind a rule that fails closed.

Registration edits

Namespace, OpenBao ClusterSecretStore conditions, network-policy kustomization, Velero schedule (08:15, next free stagger slot), image-automation kustomization, Gatus endpoint.

Verified

  • All six touched kustomizations build (kubectl kustomize), which is what ci-manifests-test.yml gates on.
  • All three pinned images confirmed to exist in their registries by manifest request: ghcr.io/usetrmnl/terminus:0.68.0, valkey/valkey:9.1-alpine, ghcr.io/cloudnative-pg/postgresql:18.
  • homelab/apps/trmnl seeded in OpenBao with a freshly generated APP_SECRET.

Blocked before this can reconcile

homelab/seed/trmnl/hetzner-creds is not seeded — the copy command was blocked by the sandbox classifier. Without it the hetzner-s3-creds ExternalSecret never syncs and the CNPG ObjectStore has no credentials. One command, values copied from the immich path:

bao kv put -address=https://openbao.renner.uno homelab/seed/trmnl/hetzner-creds \
  ACCESS_KEY_ID="$(bao kv get -address=https://openbao.renner.uno -field=ACCESS_KEY_ID homelab/seed/immich/hetzner-creds)" \
  ACCESS_SECRET_KEY="$(bao kv get -address=https://openbao.renner.uno -field=ACCESS_SECRET_KEY homelab/seed/immich/hetzner-creds)" \
  REGION="$(bao kv get -address=https://openbao.renner.uno -field=REGION homelab/seed/immich/hetzner-creds)"

Note BAO_ADDR is http:// and writes get a 308 that drops the body — hence the explicit -address=https://.

Not verified, and worth knowing

  • The device VLAN must resolve trmnl-device.renner.uno. The URI is typed into the captive portal by hand at provisioning, so if the panel's VLAN can't resolve renner.uno, this breaks at Phase 5 with no error visible from the cluster side. Untested — I have no client on that VLAN.
  • Probes are TCP, not HTTP. Terminus documents no health endpoint; guessing a path seemed worse than a port check. Tighten once the route set is confirmed against the running app.
  • First boot compiles assets and migrates a fresh DB, hence the 10-minute startupProbe budget. If it needs longer, that number is the thing to raise.

🤖 Generated with Claude Code

Phase 1 of the TRMNL X entrance dashboard plan — stands up the self-hosted Terminus (BYOS) server the panel polls. The HA dashboard, e-ink theme and screenshot renderer are later phases and are **not** in this PR. ## Phase 0 findings (verified before writing anything) | Check | Result | |---|---| | TRMNL X model in Terminus | **Present.** `id: v2`, `label: "TRMNL X"`, 1872x1404, 16 colours, 4-bit. The plan's stop-and-report condition does not trigger. | | How the model list is populated | **Not** via `config/db/seeds.rb`. The `MODEL_SYNCHRONIZER` Sidekiq job pulls it from trmnl.com — the Models list is empty until that job runs once. | | Add-on vs standalone container | Our HA is plain HA Core, **not** Supervised. The add-on still works standalone: it prefers env vars over `/data/options.json` and authenticates with an ordinary long-lived token (`SUPERVISOR_TOKEN` only flips an `isAddOn` boolean). The plan's fallback path is viable. Lands with Phase 4. | | Terminus version | `0.68.0` (2026-08-05). Repo was renamed from `byos_hanami`; image is `ghcr.io/usetrmnl/terminus`. No GitHub Releases, no changelog. | | Firmware TLS constraints | **UNVERIFIED upstream** — nothing in the Terminus repo or docs states TLS 1.2-only or CA rejection. Plain HTTP for the device route is implemented as the plan specifies, but the justification rests on the plan, not on a doc I could cite. | ## Deliberate deviations from house patterns **`tr-pg` runs PostgreSQL 18, not the standard 16.** Terminus ships a committed `config/db/structure.sql` containing `SET transaction_timeout = 0;` — a GUC that does not exist before PG 17 — so schema load against a 16 server fails with `unrecognized configuration parameter`. This is the first non-16 CNPG cluster here. Worth noting the existing 16 pin is inherited: it first appears in 739a67e9 (Apr 2026, Authentik migration) and was copy-pasted into every later cluster with no documented rationale. **Device endpoint is cleartext HTTP on a Cilium BGP LoadBalancer**, not an Ingress. The firmware cannot be handed the internal CA, and a TLS-less Ingress would be a new pattern for this repo — a dedicated LB address is not. Admin UI keeps a normal TLS Ingress. Both front the same Service; same split as `home.renner.uno` / `ha.renner.uno`. - `trmnl.renner.uno` — TLS, Traefik, admin UI - `trmnl-device.renner.uno` — LB, port 80, cleartext, device only. `API_URI` points here because Terminus bakes it into the absolute `image_url` the device fetches. **Terminus is NOT enrolled in image automation**, despite publishing a usable tag stream. It serves the firmware and `/api/display` contract to a wall-mounted device; upstream is pre-1.0 with no releases or changelog; an unattended bump that breaks either leaves the panel dark with no rollback that avoids the captive portal. Bumps are manual and reviewed — rationale recorded in `image-automation/README.md`. The Valkey sidecar **is** enrolled. **Egress is an FQDN allowlist, not `toEntities: world`.** Scoped to `trmnl.com` and the S3 host serving firmware binaries. Two caveats written into the policy: - The S3 host is the `url` field of the `/api/firmware/latest` response, not a source constant — TRMNL can move bucket/region without a Terminus release and firmware sync then fails closed here. - Terminus's Extensions/Poll feature fetches admin-configured URLs from a DB-stored Liquid template. Those are unknowable from source and **will** be blocked. Accepted — screen content is pushed in by the renderer, not polled by plugins. The CNPG pods are deliberately left out of that egress policy, so the barman WAL archive path is not behind a rule that fails closed. ## Registration edits Namespace, OpenBao `ClusterSecretStore` conditions, network-policy kustomization, Velero schedule (08:15, next free stagger slot), image-automation kustomization, Gatus endpoint. ## Verified - All six touched kustomizations build (`kubectl kustomize`), which is what `ci-manifests-test.yml` gates on. - All three pinned images confirmed to exist in their registries by manifest request: `ghcr.io/usetrmnl/terminus:0.68.0`, `valkey/valkey:9.1-alpine`, `ghcr.io/cloudnative-pg/postgresql:18`. - `homelab/apps/trmnl` seeded in OpenBao with a freshly generated `APP_SECRET`. ## Blocked before this can reconcile `homelab/seed/trmnl/hetzner-creds` is **not** seeded — the copy command was blocked by the sandbox classifier. Without it the `hetzner-s3-creds` ExternalSecret never syncs and the CNPG ObjectStore has no credentials. One command, values copied from the immich path: ``` bao kv put -address=https://openbao.renner.uno homelab/seed/trmnl/hetzner-creds \ ACCESS_KEY_ID="$(bao kv get -address=https://openbao.renner.uno -field=ACCESS_KEY_ID homelab/seed/immich/hetzner-creds)" \ ACCESS_SECRET_KEY="$(bao kv get -address=https://openbao.renner.uno -field=ACCESS_SECRET_KEY homelab/seed/immich/hetzner-creds)" \ REGION="$(bao kv get -address=https://openbao.renner.uno -field=REGION homelab/seed/immich/hetzner-creds)" ``` Note `BAO_ADDR` is `http://` and writes get a 308 that drops the body — hence the explicit `-address=https://`. ## Not verified, and worth knowing - **The device VLAN must resolve `trmnl-device.renner.uno`.** The URI is typed into the captive portal by hand at provisioning, so if the panel's VLAN can't resolve `renner.uno`, this breaks at Phase 5 with no error visible from the cluster side. Untested — I have no client on that VLAN. - Probes are TCP, not HTTP. Terminus documents no health endpoint; guessing a path seemed worse than a port check. Tighten once the route set is confirmed against the running app. - First boot compiles assets and migrates a fresh DB, hence the 10-minute `startupProbe` budget. If it needs longer, that number is the thing to raise. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
claude-bot added 1 commit 2026-08-10 20:55:21 +00:00
feat(trmnl): deploy Terminus BYOS server for the entrance e-ink panel
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ac2d728108
Phase 1 of the TRMNL X dashboard plan: stand up the self-hosted Terminus
(BYOS) server the panel will poll. The Home Assistant dashboard, the e-ink
theme and the screenshot renderer are later phases and are not in here.

Phase 0 verification, all confirmed against upstream before writing any of
this:

  * TRMNL X IS a first-class model in Terminus (id v2 / label "TRMNL X",
    1872x1404, 16 colours, 4-bit) — the stop-and-report condition in the
    plan does not trigger. Note it is NOT in config/db/seeds.rb: the model
    list is populated by the MODEL_SYNCHRONIZER job pulling trmnl.com, so
    the Models list is empty until that job has run once.
  * The HA add-on can run standalone. Our HA is plain HA Core, not
    Supervised, but the add-on reads env vars in preference to
    /data/options.json and authenticates with an ordinary long-lived
    token — SUPERVISOR_TOKEN only flips an isAddOn boolean. So the
    fallback path in the plan (upstream container, not add-on) is viable.
    That deployment lands with Phase 4.

Deviations from the repo's house patterns, each deliberate:

  * tr-pg runs PostgreSQL 18, not the standard 16. Terminus ships a
    committed config/db/structure.sql containing `SET transaction_timeout
    = 0;`, a GUC that does not exist before PG 17, so schema load against
    a 16 server fails outright. This is the first non-16 CNPG cluster
    here. The existing 16 pin is inherited from 739a67e9 (Apr 2026) and
    has no documented rationale.
  * The device endpoint is cleartext HTTP on a Cilium BGP LoadBalancer
    (trmnl-device.renner.uno) rather than an Ingress. The firmware cannot
    be given the internal CA, and a TLS-less Ingress would be a new
    pattern for this repo — a dedicated LB address is not. The admin UI
    keeps a normal TLS Ingress at trmnl.renner.uno; both front the same
    Service. Same split as home.renner.uno / ha.renner.uno.
  * ghcr.io/usetrmnl/terminus is NOT enrolled in image automation despite
    publishing a usable tag stream. It serves the firmware and
    /api/display contract to a wall-mounted device, upstream is pre-1.0
    with no releases or changelog, and an unattended bump that breaks
    either leaves the panel dark. Bumps are manual and reviewed. The
    Valkey sidecar is enrolled — it touches no device-facing contract.
  * Egress is an FQDN allowlist (trmnl.com, plus the S3 host that serves
    firmware binaries), not toEntities: world. The S3 host is returned by
    the API at request time rather than being a source constant, so it can
    drift; the policy comment says where to look when firmware sync
    starts failing. This also means Terminus's Extensions/Poll feature,
    which fetches admin-configured URLs, will not work — accepted, since
    screen content is pushed in by the renderer.
  * The CNPG pods are deliberately left OUT of that egress policy so the
    barman WAL archive path is not behind a rule that fails closed.

Images verified to exist in their registries before pinning:
ghcr.io/usetrmnl/terminus:0.68.0, valkey/valkey:9.1-alpine,
ghcr.io/cloudnative-pg/postgresql:18.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
k.renner merged commit b529864453 into master 2026-08-10 20:59:03 +00:00
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit ac2d72810827966cdac18dd76f46f62b3881df2f.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Phase 1 of the TRMNL BYOS deployment: a new trmnl namespace running Terminus (web + sidekiq), a Valkey queue, a dedicated CNPG PostgreSQL 18 cluster with offsite Barman backup, an admin Ingress plus a cleartext device LoadBalancer, a Cilium egress/ingress policy set, and the associated registrations (namespace, OpenBao ClusterSecretStore, Velero schedule, image-automation, Gatus). I cross-checked every deviation the PR body calls out (PG18 pin, cleartext device LB, unenrolled image automation, FQDN egress allowlist) against the existing patterns for immich, paperless, authentik, and home-assistant in this repo, and against the actual cluster CIDRs — all of it lines up. I did not find a blocking defect.

🟡 Suggestions

  • kubernetes/apps/trmnl/objectstore.yaml and hetzner-creds-externalsecret.yaml depend on the OpenBao path homelab/seed/trmnl/hetzner-creds, which the PR body itself says is not yet seeded (blocked by a sandbox classifier). As written, once this merges the hetzner-s3-creds ExternalSecret will fail to sync and the CNPG ObjectStore/WAL archiving for tr-pg won't come up until that manual bao kv put is run. Worth doing before/immediately after merge so the cluster doesn't sit half-reconciled with no backup path.
  • kubernetes/infrastructure/network-policies/trmnl-ns.yaml:834-846 hardcodes trmnl-fw.s3.us-east-2.amazonaws.com as a toFQDNs egress target, but the comment directly above it says this hostname comes from a live API response (/api/firmware/latest) and is not a source constant — TRMNL can move the bucket without a Terminus release. This is explicitly called out as an accepted risk rather than an oversight, so it's not blocking, just flagging that firmware sync is the first thing to check if it silently stops working.
  • kubernetes/apps/trmnl/terminus.yaml doesn't set readOnlyRootFilesystem on either the terminus or sidekiq container, while the new valkey.yaml sidecar does. That's likely deliberate (asset compile + Hanami boot need a writable rootfs) and matches the looser pattern elsewhere in the repo (e.g. paperless's main container isn't read-only either), so this is a minor consistency note, not a request.

🟢 Nits

  • kubernetes/apps/trmnl/terminus.yaml:445-479 (sidekiq container) has no liveness/readiness probe at all, unlike the terminus web container. This matches the existing precedent for secondary containers in this repo (e.g. paperless's bridge-tls-proxy sidecar is also unprobed), so it's consistent, just worth a mental note if the sidekiq worker ever needs its own health signal.

Tests

There's no traditional test suite for this kind of change — the repo's bar is kubectl kustomize building cleanly for touched kustomizations, which the PR body says was verified for all six. I didn't re-run that locally, but I did verify structurally: the new kubernetes/apps/trmnl/kustomization.yaml resource list matches the files actually added, the trmnl namespace is correctly threaded through namespaces.yaml, openbao-clustersecretstore.yaml (alphabetically inserted), network-policies/kustomization.yaml, and image-automation/kustomization.yaml, and the Flux Kustomization in kubernetes/clusters/homelab/trmnl.yaml matches the dependsOn/healthCheckExprs shape used by immich.yaml/paperless.yaml/authentik.yaml verbatim. No CI-visible test gap here beyond the manifest-build check the repo already relies on.

Recommendation

Looks reasonable — no blocking issues; comments above are optional (the OpenBao seed-credential gap is the one item worth resolving before this fully reconciles, but it's already flagged by the author in the PR description, not a defect in the diff itself).

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `ac2d72810827966cdac18dd76f46f62b3881df2f`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Phase 1 of the TRMNL BYOS deployment: a new `trmnl` namespace running Terminus (web + sidekiq), a Valkey queue, a dedicated CNPG PostgreSQL 18 cluster with offsite Barman backup, an admin Ingress plus a cleartext device LoadBalancer, a Cilium egress/ingress policy set, and the associated registrations (namespace, OpenBao `ClusterSecretStore`, Velero schedule, image-automation, Gatus). I cross-checked every deviation the PR body calls out (PG18 pin, cleartext device LB, unenrolled image automation, FQDN egress allowlist) against the existing patterns for `immich`, `paperless`, `authentik`, and `home-assistant` in this repo, and against the actual cluster CIDRs — all of it lines up. I did not find a blocking defect. ## 🟡 Suggestions - `kubernetes/apps/trmnl/objectstore.yaml` and `hetzner-creds-externalsecret.yaml` depend on the OpenBao path `homelab/seed/trmnl/hetzner-creds`, which the PR body itself says is **not yet seeded** (blocked by a sandbox classifier). As written, once this merges the `hetzner-s3-creds` ExternalSecret will fail to sync and the CNPG `ObjectStore`/WAL archiving for `tr-pg` won't come up until that manual `bao kv put` is run. Worth doing before/immediately after merge so the cluster doesn't sit half-reconciled with no backup path. - `kubernetes/infrastructure/network-policies/trmnl-ns.yaml:834-846` hardcodes `trmnl-fw.s3.us-east-2.amazonaws.com` as a `toFQDNs` egress target, but the comment directly above it says this hostname comes from a live API response (`/api/firmware/latest`) and is not a source constant — TRMNL can move the bucket without a Terminus release. This is explicitly called out as an accepted risk rather than an oversight, so it's not blocking, just flagging that firmware sync is the first thing to check if it silently stops working. - `kubernetes/apps/trmnl/terminus.yaml` doesn't set `readOnlyRootFilesystem` on either the `terminus` or `sidekiq` container, while the new `valkey.yaml` sidecar does. That's likely deliberate (asset compile + Hanami boot need a writable rootfs) and matches the looser pattern elsewhere in the repo (e.g. `paperless`'s main container isn't read-only either), so this is a minor consistency note, not a request. ## 🟢 Nits - `kubernetes/apps/trmnl/terminus.yaml:445-479` (`sidekiq` container) has no liveness/readiness probe at all, unlike the `terminus` web container. This matches the existing precedent for secondary containers in this repo (e.g. paperless's `bridge-tls-proxy` sidecar is also unprobed), so it's consistent, just worth a mental note if the sidekiq worker ever needs its own health signal. ## Tests There's no traditional test suite for this kind of change — the repo's bar is `kubectl kustomize` building cleanly for touched kustomizations, which the PR body says was verified for all six. I didn't re-run that locally, but I did verify structurally: the new `kubernetes/apps/trmnl/kustomization.yaml` resource list matches the files actually added, the `trmnl` namespace is correctly threaded through `namespaces.yaml`, `openbao-clustersecretstore.yaml` (alphabetically inserted), `network-policies/kustomization.yaml`, and `image-automation/kustomization.yaml`, and the Flux `Kustomization` in `kubernetes/clusters/homelab/trmnl.yaml` matches the `dependsOn`/`healthCheckExprs` shape used by `immich.yaml`/`paperless.yaml`/`authentik.yaml` verbatim. No CI-visible test gap here beyond the manifest-build check the repo already relies on. ## Recommendation Looks reasonable — no blocking issues; comments above are optional (the OpenBao seed-credential gap is the one item worth resolving before this fully reconciles, but it's already flagged by the author in the PR description, not a defect in the diff itself). <!-- pr-review:sha=ac2d72810827966cdac18dd76f46f62b3881df2f -->
Sign in to join this conversation.