Skip to content

NAS App Role Codification — Design

Design bead: hl-mjh0 (shared with Spec A). Sibling spec: 2026-07-05-tailscale-config-as-code-design.md (Spec A — the tailnet foundation this one consumes). Related: hl-383r (nas-traefik, separate track), nas-otel-collector (the role template).

Turn the NAS’s three un-codified TrueNAS apps into idempotent Ansible roles so a rebuild reproduces them from Git, completing the “every NAS app reproducible from Git” sweep:

  • nas-dozzle — the Docker log-viewer UI (dozzle.nas.fzymgc.house).
  • nas-rustfs — the S3 service (s3.nas / s3-console.nas.fzymgc.house).
  • nas-tailscale — the tailnet node, converted from catalog to custom so it can authenticate via the Vault-sourced OAuth client minted in Spec A.

After this, all NAS apps except traefik (tracked separately, hl-383r) and storj (excluded — still running/earning) are codified. otelcol-nas already is (nas-otel-collector).

Three of the NAS’s six apps (dozzle, rustfs, otelcol-nas) are TrueNAS custom apps and three (traefik, tailscale, storj) are catalog apps. Only otelcol-nas is codified. dozzle and rustfs are custom-compose drift with no role and no tracking; tailscale needs codification to consume Spec A’s OAuth credential. Codifying them makes a NAS rebuild deterministic and brings the last app-level drift under version control — the same rationale that produced nas-otel-collector.

Current state (grounded, verified live 2026-07-05 via midclt app.config)

Section titled “Current state (grounded, verified live 2026-07-05 via midclt app.config)”

The reconcile template — nas-otel-collector

Section titled “The reconcile template — nas-otel-collector”

ansible/roles/nas-otel-collector is the proven custom-app role pattern (ADR hl-wo1m): read secrets from Vault on the control node (community.hashi_vault.vault_kv2_get, no_log) → render the compose to an on-box drift marker and build the same string via lookup('ansible.builtin.template', …)app.query [["name","=",…]]app.create {app_name, custom_app:true, custom_compose_config_string} when absent / app.update <name> {custom_compose_config_string} gated on the render being changed, so a re-run with an unchanged spec reports ok. midclt calls use argv + --job. Middleware schema confirmed live: app.create(data) / app.update(id_, data).

  • dozzle (custom): image amir20/dozzle:v10.6.6 (public), single service, cap_drop:[ALL] + no-new-privileges, user 568:568, mounts /var/run/docker.sock read-only, joins the external nas-internal network, healthcheck /dozzle healthcheck. Traefik labels route Host(dozzle.nas.fzymgc.house) on web,web-secure, tls.certresolver=cloudflare. No secret.
  • rustfs (custom): image rustfs/rustfs:1.0.0-beta.8 (public), cap_drop:[ALL] + no-new-privileges, user 568:568, host binds /mnt/main/rustfs/data → /rustfs/data0 and /mnt/main/rustfs/logs → /logs, nas-internal network, healthcheck curl 127.0.0.1:9000/health. Env includes RUSTFS_ACCESS_KEY + RUSTFS_SECRET_KEY (secrets), RUSTFS_CONSOLE_ENABLE=true, RUSTFS_VOLUMES=/rustfs/data0. Traefik labels: s3.nas.fzymgc.house → service port 9000; s3-console.nas.fzymgc.house → port 9001; both tls.certresolver=cloudflare.
  • tailscale (catalog → to be converted to custom): image ghcr.io/tailscale/tailscale:v1.98.4, host_network=true, subnet router advertising 192.168.20.0/22 + exit-node, accept_dns=true, accept_routes=false, userspace=false, auth_once=true, hostname truenas-scale, node already tag-owned (tag:core, tag:nas). Catalog values expose only auth_key (→TS_AUTHKEY) which cannot carry an OAuth client secret (see Spec A) — hence the conversion.

All three roles are custom-compose midclt-reconcile roles following nas-otel-collector:

ansible/roles/nas-<app>/
├── defaults/main.yml # app name, image+tag, ports, storage paths, Vault paths, the compose vars
├── meta/main.yml # galaxy_info, no deps
├── tasks/main.yml # Vault read (no_log) → render marker + build string → app.query → create/update
└── templates/compose.yaml.j2 # the custom-app compose (the app definition)

Idempotency gate = the rendered compose/secret marker being changed (re-run with unchanged spec ⇒ ok, not changed). This is the one invariant every role must preserve.

  • nas-dozzle (simplest — no secret). Render the compose verbatim from the live definition (public image pin, docker.sock ro, nas-internal, Traefik labels). app.create/update gated on compose drift. No Vault read.
    • Security note: the read-only docker.sock mount is codified as-is (matches running state). A socket-proxy for least privilege is a tracked follow-up, out of scope for “codify what exists.”
  • nas-rustfs. Same pattern + a Vault read of RUSTFS_ACCESS_KEY/RUSTFS_SECRET_KEY from a new path secret/fzymgc-house/infrastructure/nas/rustfs-s3, injected into the compose env (no_log). Host binds /mnt/main/rustfs/{data,logs} (leaf datasets — bind-mountable). Traefik dual-host labels (s3 + s3-console).
  • nas-tailscale (catalog→custom conversion). A custom compose running ghcr.io/tailscale/tailscale:v1.98.4 with network_mode: host, cap_add:[NET_ADMIN] + /dev/net/tun (required for kernel-mode, userspace=false; tun is verified preloaded on TrueNAS SCALE via lsmod, so SYS_MODULE — which router-tailscale carries on Firewalla’s embedded Linux — is not needed here), a host-path state volume, and env from Spec A’s OAuth client: TS_CLIENT_ID + TS_CLIENT_SECRET (Vault …/nas/tailscale-auth, no_log), TS_EXTRA_ARGS=--advertise-tags=tag:nas,tag:core --advertise-exit-node (both current node tags — a re-register assigns only the advertised set, so tag:core must be kept), TS_ROUTES=192.168.20.0/22, TS_HOSTNAME=truenas-scale, TS_AUTH_ONCE=true, TS_ACCEPT_DNS=true, TS_USERSPACE=false. Conversion replaces the catalog app and re-registers the node once (new identity; the stale node is removed) — non-disruptive because router redundantly advertises the same routes and Spec A’s autoApprovers auto-approves the new node (see Spec A “Cutover consequence”).
  • Secrets are read control-node-side from Vault (community.hashi_vault.vault_kv2_get + vault_ca_cert_bundle), no_log on every task that touches them — the nas-otel-collector idiom. No on-box vault-agent.
  • New Vault paths: …/nas/rustfs-s3 (rustfs — a one-time manual operator vault kv put seeding the existing keys) and …/nas/tailscale-auth (tailscale — written by Spec A’s tf/tailscale, which owns that write grant). No new read grant is required: the roles read under the operator’s vault login session, whose tf/vault/policy-infrastructure-developer.hcl already grants secret/data/fzymgc-house/infrastructure/* — the same wildcard nas-mail relies on to read infrastructure/nas/cloudflare-email (no dedicated read grant was minted for it either).
  • Public images (dozzle, rustfs) need no app.registry credential (unlike nas-otel-collector’s private GHCR image); tailscale’s ghcr.io/tailscale/tailscale is public too.
  • dozzle’s read-only docker.sock is the sharpest privilege; codified as-is, hardening deferred.
  • Add nas-dozzle, nas-rustfs, nas-tailscale role invocations to ansible/nas-playbook.yml (each tagged, targeting nas), matching the existing play structure.
  • Update ansible/CLAUDE.md Roles Reference and docs/operations/nas.md tag map.
  • Re-render docs/reference/nas-config-baseline.md (--tags nas-drift) — it is stale (lists 5 apps, omits otelcol-nas).
  • Confirm Renovate tracks the three image pins (amir20/dozzle, rustfs/rustfs, ghcr.io/tailscale/tailscale) in the role defaults, so updates flow like other pinned images.
  • nas-dozzle + nas-rustfs are independent of Spec A — implementable and deployable immediately.
  • nas-tailscale is gated on Spec A: the nas OAuth client at …/nas/tailscale-auth and the autoApprovers block must be live before conversion (else the re-registered node’s routes need manual approval).
  • All three depend on the existing traefik app for HTTP routing (labels only — no change to traefik here).

Verification (per role — infra “tests” are dry-run → apply → observe → idempotent re-run)

Section titled “Verification (per role — infra “tests” are dry-run → apply → observe → idempotent re-run)”
  • scripts/nas-playbook.sh --tags nas-<app> --check --diff clean; then apply changed; then idempotent re-run = changed=0 on the update task (drift marker unchanged). A changed on re-run is a FAIL.
  • dozzle: dozzle.nas.fzymgc.house serves a valid LE cert and the log UI.
  • rustfs: s3.nas (:9000) + s3-console.nas (:9001) reachable; an S3 client authenticating with the seeded keys works (no client breakage).
  • nas-tailscale: after conversion, tailscale status shows truenas-scale online, tagged tag:nas, advertising 192.168.20.0/22 + exit, routes auto-approved; no secret in any transcript (no_log).
  • Re-rendered nas-config-baseline.md lists all 6 apps.
  • traefik app codification — hl-383r.
  • storj — excluded (running/earning; keep-vs-graceful-exit is a separate unfiled decision).
  • Tailscale tailnet config (ACL/DNS/settings/OAuth-client minting, router-tailscale migration) — Spec A.
  • dozzle socket-proxy hardening — follow-up, not “codify what exists.”
  • One spec for all three roles?Yes — they share the nas-otel-collector pattern; nas-tailscale’s Spec-A dependency is a sequencing note, not a reason to split.
  • rustfs S3 credentials — seed existing or rotate?Seed the existing keys into Vault (operator, 2026-07-05) — non-disruptive; rotation would break current s3.nas consumers until updated.
  • dozzle docker.sock?Codify as-is (read-only), hardening deferred.
  • nas-tailscale identity on conversion?Re-register (operator, 2026-07-05) — see Spec A.