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).
Motivation
Section titled “Motivation”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).
The three apps (live)
Section titled “The three apps (live)”dozzle(custom): imageamir20/dozzle:v10.6.6(public), single service,cap_drop:[ALL]+no-new-privileges,user 568:568, mounts/var/run/docker.sockread-only, joins the externalnas-internalnetwork, healthcheck/dozzle healthcheck. Traefik labels routeHost(dozzle.nas.fzymgc.house)onweb,web-secure,tls.certresolver=cloudflare. No secret.rustfs(custom): imagerustfs/rustfs:1.0.0-beta.8(public),cap_drop:[ALL]+no-new-privileges,user 568:568, host binds/mnt/main/rustfs/data → /rustfs/data0and/mnt/main/rustfs/logs → /logs,nas-internalnetwork, healthcheckcurl 127.0.0.1:9000/health. Env includesRUSTFS_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; bothtls.certresolver=cloudflare.tailscale(catalog → to be converted to custom): imageghcr.io/tailscale/tailscale:v1.98.4,host_network=true, subnet router advertising192.168.20.0/22+ exit-node,accept_dns=true,accept_routes=false,userspace=false,auth_once=true, hostnametruenas-scale, node already tag-owned (tag:core, tag:nas). Catalog values expose onlyauth_key(→TS_AUTHKEY) which cannot carry an OAuth client secret (see Spec A) — hence the conversion.
Architecture
Section titled “Architecture”Shared reconcile pattern
Section titled “Shared reconcile pattern”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.
Per-role
Section titled “Per-role”nas-dozzle(simplest — no secret). Render the compose verbatim from the live definition (public image pin,docker.sockro,nas-internal, Traefik labels).app.create/updategated on compose drift. No Vault read.- Security note: the read-only
docker.sockmount 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.”
- Security note: the read-only
nas-rustfs. Same pattern + a Vault read ofRUSTFS_ACCESS_KEY/RUSTFS_SECRET_KEYfrom a new pathsecret/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 runningghcr.io/tailscale/tailscale:v1.98.4withnetwork_mode: host,cap_add:[NET_ADMIN]+/dev/net/tun(required for kernel-mode,userspace=false;tunis verified preloaded on TrueNAS SCALE vialsmod, soSYS_MODULE— whichrouter-tailscalecarries 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, sotag:coremust 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 becauserouterredundantly advertises the same routes and Spec A’sautoApproversauto-approves the new node (see Spec A “Cutover consequence”).
Security
Section titled “Security”- Secrets are read control-node-side from Vault (
community.hashi_vault.vault_kv2_get+vault_ca_cert_bundle),no_logon every task that touches them — thenas-otel-collectoridiom. No on-box vault-agent. - New Vault paths:
…/nas/rustfs-s3(rustfs — a one-time manual operatorvault kv putseeding the existing keys) and…/nas/tailscale-auth(tailscale — written by Spec A’stf/tailscale, which owns that write grant). No new read grant is required: the roles read under the operator’svault loginsession, whosetf/vault/policy-infrastructure-developer.hclalready grantssecret/data/fzymgc-house/infrastructure/*— the same wildcardnas-mailrelies on to readinfrastructure/nas/cloudflare-email(no dedicated read grant was minted for it either). - Public images (
dozzle,rustfs) need noapp.registrycredential (unlikenas-otel-collector’s private GHCR image);tailscale’sghcr.io/tailscale/tailscaleis public too. dozzle’s read-onlydocker.sockis the sharpest privilege; codified as-is, hardening deferred.
Wiring & hygiene
Section titled “Wiring & hygiene”- Add
nas-dozzle,nas-rustfs,nas-tailscalerole invocations toansible/nas-playbook.yml(each tagged, targetingnas), matching the existing play structure. - Update
ansible/CLAUDE.mdRoles Reference anddocs/operations/nas.mdtag map. - Re-render
docs/reference/nas-config-baseline.md(--tags nas-drift) — it is stale (lists 5 apps, omitsotelcol-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.
Sequencing & dependencies
Section titled “Sequencing & dependencies”nas-dozzle+nas-rustfsare independent of Spec A — implementable and deployable immediately.nas-tailscaleis gated on Spec A: the nas OAuth client at…/nas/tailscale-authand theautoApproversblock must be live before conversion (else the re-registered node’s routes need manual approval).- All three depend on the existing
traefikapp 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 --diffclean; then applychanged; then idempotent re-run =changed=0on the update task (drift marker unchanged). Achangedon re-run is a FAIL.dozzle:dozzle.nas.fzymgc.houseserves 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 statusshowstruenas-scaleonline, taggedtag:nas, advertising192.168.20.0/22+ exit, routes auto-approved; no secret in any transcript (no_log).- Re-rendered
nas-config-baseline.mdlists all 6 apps.
Out of scope
Section titled “Out of scope”traefikapp 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.
dozzlesocket-proxy hardening — follow-up, not “codify what exists.”
Open decisions — resolved
Section titled “Open decisions — resolved”- One spec for all three roles? → Yes — they share the
nas-otel-collectorpattern;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.nasconsumers 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.