Skip to content

nas-traefik Ansible Role Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use dev-flow:subagent-driven-development (recommended) or dev-flow:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Codify the NAS’s TrueNAS community-catalog Traefik app as an idempotent Ansible role (nas-traefik) so a NAS rebuild reproduces it from Git, sourcing the Cloudflare DNS-01 token from Vault and scoping the insecure dashboard off the 0.0.0.0 wildcard.

Architecture: A midclt-driven reconcile role modeled on ansible/roles/nas-otel-collector (control-node Vault reads via community.hashi_vault.vault_kv2_get; app.queryapp.create/app.update --job). Unlike nas-otel (a custom_app with a compose string), Traefik is a catalog app (train=community, app=traefik, version=1.0.15), so the payload is a declarative values object built from role defaults + the Vault-sourced token. Bead: hl-383r. Pairs with hl-uegs item 3 (:444 hardening).

Tech Stack: Ansible (FQCN modules, no_log), TrueNAS 26 middleware API (midclt call app.* --job), HashiCorp Vault (KV v2 + optionally a dedicated Cloudflare API token), scripts/nas-playbook.sh (Vault-key SSH wrapper).


Decisions (resolve before/within implementation)

Section titled “Decisions (resolve before/within implementation)”

D1 — Cloudflare token source (SECURITY). The app needs CF_DNS_API_TOKEN for ACME DNS-01. Two options:

  • (Recommended) Dedicated DNS-scoped token at secret/fzymgc-house/infrastructure/nas/traefik-cf-dns-token (field token), provisioned via tf/cloudflare (a cloudflare_api_token scoped to Zone:DNS:Edit on fzymgc.house) + written to Vault via tf/vault. Least privilege; a NAS compromise can’t touch the whole CF account.
  • (Fallback) Reuse secret/fzymgc-house/infrastructure/cloudflare/bootstrap-token (field token) — the broad account token the TF cloudflare provider uses. Simpler, but widens blast radius and couples the NAS app to the bootstrap credential.

The role reads from a configurable var (nas_traefik_cf_token_vault_path / _property), so the choice is a defaults change, not a code change. Task 1 provisions the dedicated token (separate tf/cloudflare + tf/vault PR/apply); if deferred, set the var to the bootstrap path and file a follow-up bead.

D2 — :444 dashboard hardening (hl-uegs item 3). Currently api_port.host_ips: [] (binds 0.0.0.0:444) with --api.insecure=true — an unauthenticated HTTP dashboard on the whole LAN. This role sets api_port.host_ips: ["192.168.20.201"] so the dashboard is reachable only on the Traefik VIP, not .200/all interfaces. (A stronger follow-up — route the dashboard through web-secure + forward-auth and drop api.insecure — is out of scope here and tracked separately.)


  • ansible/roles/nas-traefik/defaults/main.yml — all declarative values (catalog coordinates, network, storage, run_as, resources, ACME args, Vault token path). One responsibility: the desired app spec.
  • ansible/roles/nas-traefik/meta/main.yml — role metadata (galaxy_info, no deps), mirrors nas-otel-collector.
  • ansible/roles/nas-traefik/tasks/main.yml — the reconcile: read token from Vault → build valuesapp.queryapp.create (absent) / app.update (drift).
  • ansible/nas-playbook.yml — add a nas-traefik-tagged role invocation (targets nas).
  • ansible/CLAUDE.md — add the role to the Roles Reference table.
  • docs/operations/nas.md — add a Tag-map row + a short “Traefik app” note.
  • tf/cloudflare/nas_traefik_token.tf — (Task 1, D1) mint the dedicated CF DNS token and write it to Vault, co-located in one module (per nas_email.tf, because a sensitive computed value can’t cross workspaces).
  • tf/vault/policy-terraform-workspaces.tf — (Task 1) grant terraform-cloudflare-admin write on the new KV path (applies first).

Task 0: Confirm the catalog app.create/app.update values schema

Section titled “Task 0: Confirm the catalog app.create/app.update values schema”

Files: none (investigation; findings inform Task 3).

  • Step 1: Dump the live values shape

Run (via the Vault-key wrapper pattern):

Terminal window
ssh nas-automation@192.168.20.200 \
'sudo midclt call app.config traefik' | python3 -m json.tool

Expected: JSON containing top-level TZ, network (api_port, entry_points), storage, run_as, resources, labels, traefik (api_insecure, dashboard, additional_args, additional_envs). The user-editable subset (everything except ix_context, ix_volumes, ix_certificates, release_name) is the values object.

  • Step 2: Confirm the create/update call signatures

Run:

Terminal window
ssh nas-automation@192.168.20.200 'sudo midclt call app.create' 2>&1 | head
ssh nas-automation@192.168.20.200 'sudo midclt call app.update' 2>&1 | head

Expected: an Error: … missing … required message that names the accepted params. Confirm app.create accepts {app_name, catalog_app, train, version, values} and app.update accepts (app_name, {values}). If the names differ, adjust Task 3’s payload keys accordingly — this is the one schema assumption in the plan.

  • Step 3: Record findings

Append to bead: bd note hl-383r "app.create/update schema: <verified keys>".


Task 1: Provision a dedicated Cloudflare DNS token in Vault (D1)

Section titled “Task 1: Provision a dedicated Cloudflare DNS token in Vault (D1)”

Files:

  • Create: tf/cloudflare/nas_traefik_token.tf (mint the token + co-located Vault write)
  • Modify: tf/vault/policy-terraform-workspaces.tf (grant terraform-cloudflare-admin write on the new KV path — applies first)

Two workspaces, ordered: tf/vault (main-cluster-vault) applies first (the KV-path grant), then tf/cloudflare (main-cluster-cloudflare) mints + writes. The token mint and the vault_kv_secret_v2 write live in the same module (tf/cloudflare), exactly like nas_email.tf — a sensitive computed token value cannot cross workspaces. If deferring, skip to Task 2 and set nas_traefik_cf_token_vault_path to the bootstrap path (D1 fallback), then file a follow-up bead.

  • Step 1: Grant the Vault KV path (tf/vault, applies first)

In tf/vault/policy-terraform-workspaces.tf, add paired secret/data/… + secret/metadata/… write capability for fzymgc-house/infrastructure/nas/traefik-cf-dns-token to the terraform-cloudflare-admin policy — copy the existing infrastructure/nas/cloudflare-email grant as the template. Without it, the tf/cloudflare KV write is denied.

  • Step 2: Mint the token + write to Vault (tf/cloudflare)

Create tf/cloudflare/nas_traefik_token.tf. All referenced data sources/vars already exist in the module (data.cloudflare_account_api_token_permission_groups_list.all in api-token-permission-groups.tf, data.cloudflare_zone.fzymgc_house in terraform.tf, var.cloudflare_account_id):

# SPDX-License-Identifier: MIT
# terraform: language=hcl
# nas_traefik_token.tf - Dedicated Cloudflare DNS token for the NAS Traefik app's
# ACME DNS-01 challenge. Least-privilege vs the account-wide bootstrap token:
# scoped to DNS edit on the fzymgc.house zone. Read control-node-side by the
# nas-traefik Ansible role. Modeled on nas_email.tf. [hl-383r]
#
# PREREQUISITE: the bootstrap token (ephemeral.vault_kv_secret_v2.cloudflare_bootstrap_token)
# already manages all account DNS, so it HOLDS "DNS Write" and can grant it — no 403
# (unlike the email tokens, whose group the bootstrap lacked). The tf/vault KV-path
# grant (Step 1) MUST apply first or the vault write is denied.
locals {
# Resolve the exact permission-group id by display name from the full account
# list. A wrong/drifted name yields [] -> "index 0 out of range" at plan.
dns_write_permission_group = [
for pg in data.cloudflare_account_api_token_permission_groups_list.all.result :
pg.id if pg.name == "DNS Write"
][0]
}
# Account-owned (cloudflare_account_token, NOT cloudflare_api_token — survives the
# bootstrap user leaving), scoped to the fzymgc.house ZONE resource so the token
# can only edit DNS in that one zone.
resource "cloudflare_account_token" "nas_traefik_dns" {
account_id = var.cloudflare_account_id
name = "nas-traefik-acme-dns"
policies = [{
effect = "allow"
permission_groups = [
{ id = local.dns_write_permission_group },
]
resources = jsonencode({
"com.cloudflare.api.account.zone.${data.cloudflare_zone.fzymgc_house.id}" = "*"
})
}]
}
# Credential -> Vault (co-located; read by ansible/roles/nas-traefik over the
# operator's Vault session, not consumed by any TFC workspace or ESO).
resource "vault_kv_secret_v2" "nas_traefik_cf_dns" {
mount = "secret"
name = "fzymgc-house/infrastructure/nas/traefik-cf-dns-token"
data_json = jsonencode({
token = cloudflare_account_token.nas_traefik_dns.value
})
custom_metadata {
max_versions = 10
data = {
managed_by = "terraform"
purpose = "nas-traefik-acme-dns01"
}
}
}

Confirm "DNS Write" is the exact account permission-group display name (the [0] fails loudly at plan if not). If the bootstrap token turns out NOT to hold DNS edit, add “Zone / DNS : Edit” to it in the Cloudflare dashboard (same prerequisite class as nas_email.tf).

  • Step 3: Plan/verify

Run: terraform -chdir=tf/cloudflare plan (with a Vault session). Expect cloudflare_account_token.nas_traefik_dns + vault_kv_secret_v2.nas_traefik_cf_dns created (2 add). Do not apply locally; merge triggers TFC (tf/vault first, then tf/cloudflare).

  • Step 4: Commit (VCS per references/vcs-preamble.md).

Files:

  • Create: ansible/roles/nas-traefik/defaults/main.yml
  • Create: ansible/roles/nas-traefik/meta/main.yml
  • Step 1: Write defaults/main.yml
# SPDX-License-Identifier: MIT-0
# code: language=ansible
---
nas_traefik_app_name: traefik
nas_traefik_catalog_app: traefik
nas_traefik_train: community
nas_traefik_version: "1.0.15" # traefik v3.7.5
# Cloudflare DNS-01 token (control-node Vault read; D1). Point at the dedicated
# scoped token; fall back to the bootstrap token only if Task 1 is deferred.
nas_traefik_cf_token_vault_path: fzymgc-house/infrastructure/nas/traefik-cf-dns-token
nas_traefik_cf_token_property: token
# VIP the app publishes on (the NAS-apps reverse-proxy alias on bond0).
nas_traefik_vip_v4: "192.168.20.201"
nas_traefik_vip_v6: "fddb:f665:73f7:1::201"
nas_traefik_acme_email: "nas-admin@fzymgc.house"
# On-box drift marker: the desired (non-secret) values are rendered here, and the
# reconcile gates app.update on this file changing — so re-runs with an unchanged
# spec report `ok`. Mirrors nas-otel-collector's compose drift-marker pattern.
nas_traefik_state_dir: /mnt/apps/traefik
nas_traefik_marker_path: "{{ nas_traefik_state_dir }}/desired-values.json"
# Desired declarative values (the app.update `values` object minus the secret,
# which is merged in at runtime). Mirrors `midclt call app.config traefik`.
nas_traefik_values:
TZ: America/New_York
labels: []
run_as: { user: 568, group: 568 }
resources: { limits: { cpus: 2, memory: 4096 } }
network:
host_network: false
# Hardened: bind the insecure dashboard to the VIP only (was host_ips: []
# == 0.0.0.0, unauth on the whole LAN). [hl-uegs item 3]. IPv4-only is
# intentional (more restrictive than the dual-stack data entrypoints below).
# The container-internal ping:55555 healthcheck is NOT a user-editable network
# field (it lives in the catalog template's healthcheck, not `values`), so it
# is correctly absent here; Task 5 Step 3 confirms it survives the reconcile.
api_port: { bind_mode: published, port_number: 444, host_ips: ["192.168.20.201"] }
entry_points:
- { entrypoint_name: web, bind_mode: published, port_number: 80, protocol: tcp, host_ips: ["192.168.20.201", "fddb:f665:73f7:1::201"] }
- { entrypoint_name: web-secure, bind_mode: published, port_number: 443, protocol: tcp, host_ips: ["192.168.20.201", "fddb:f665:73f7:1::201"] }
networks:
- { name: nas-internal, containers: [{ name: traefik, config: { gw_priority: 2, priority: 2, aliases: [] } }] }
- { name: nas-public, containers: [{ name: traefik, config: { gw_priority: 1, priority: 1, aliases: [] } }] }
storage:
acme: { type: ix_volume, ix_volume_config: { dataset_name: acme, acl_enable: false } }
config: { type: ix_volume, ix_volume_config: { dataset_name: config, acl_enable: false } }
additional_storage: []
traefik:
api_insecure: true
dashboard: true
additional_args:
- { flag: "--certificatesresolvers.cloudflare.acme.email", value: "nas-admin@fzymgc.house" }
- { flag: "--certificatesresolvers.cloudflare.acme.dnschallenge.provider", value: "cloudflare" }
- { flag: "--certificatesresolvers.cloudflare.acme.dnschallenge.propagation.disablechecks", value: "true" }
- { flag: "--certificatesresolvers.cloudflare.acme.dnschallenge.propagation.delayBeforeChecks", value: "30s" }
- { flag: "--providers.docker.exposedbydefault", value: "false" }
# additional_envs is injected at runtime (Task 3) with the Vault token.
  • Step 2: Write meta/main.yml (copy the shape of ansible/roles/nas-otel-collector/meta/main.yml; no dependencies; galaxy_info role_name nas_traefik).

  • Step 3: Commit.


Task 3: Reconcile task — Vault read, build values, create/update

Section titled “Task 3: Reconcile task — Vault read, build values, create/update”

Files:

  • Create: ansible/roles/nas-traefik/tasks/main.yml

  • Step 1: Write tasks/main.yml

# SPDX-License-Identifier: MIT-0
# code: language=ansible
---
# Traefik as a TrueNAS COMMUNITY-CATALOG app (train=community, app=traefik).
# Unlike nas-otel (custom_app), the payload is a declarative `values` object.
# The Cloudflare DNS-01 token is read on the CONTROL NODE (no on-box vault-agent)
# and merged into values.traefik.additional_envs. midclt calls use argv + --job.
- name: Read Cloudflare DNS token from Vault
ansible.builtin.set_fact:
nas_traefik_cf_token: >-
{{ lookup('community.hashi_vault.vault_kv2_get',
nas_traefik_cf_token_vault_path,
ca_cert=vault_ca_cert_bundle).data.data[nas_traefik_cf_token_property] }}
no_log: true
- name: Compose the full values object (inject the secret env)
ansible.builtin.set_fact:
nas_traefik_full_values: >-
{{ nas_traefik_values | combine({
'traefik': nas_traefik_values.traefik | combine({
'additional_envs': [
{'name': 'CF_DNS_API_TOKEN', 'value': nas_traefik_cf_token}
]
})
}, recursive=True) }}
no_log: true
- name: Ensure state dir for the drift marker
ansible.builtin.file:
path: "{{ nas_traefik_state_dir }}"
state: directory
mode: "0750"
become: true
- name: Render desired-values drift marker (NON-SECRET spec only)
ansible.builtin.copy:
content: "{{ nas_traefik_values | to_nice_json }}\n"
dest: "{{ nas_traefik_marker_path }}"
mode: "0640"
become: true
register: nas_traefik_marker
- name: Query existing app
ansible.builtin.command:
argv: [midclt, call, app.query, '[["name","=","{{ nas_traefik_app_name }}"]]']
become: true
changed_when: false
check_mode: false
register: nas_traefik_app_q
- name: Create catalog app (absent)
ansible.builtin.command:
argv:
- midclt
- call
- --job
- app.create
- >-
{{ {
"app_name": nas_traefik_app_name,
"catalog_app": nas_traefik_catalog_app,
"train": nas_traefik_train,
"version": nas_traefik_version,
"values": nas_traefik_full_values
} | to_json }}
become: true
when: (nas_traefik_app_q.stdout | from_json) | length == 0
changed_when: true
no_log: true
- name: Update catalog app (present) — only when the desired spec changed
ansible.builtin.command:
argv:
- midclt
- call
- --job
- app.update
- "{{ nas_traefik_app_name }}"
- "{{ {'values': nas_traefik_full_values} | to_json }}"
become: true
# Real idempotency gate (mirrors nas-otel-collector's marker-change gate): update
# only when the app exists AND our desired spec changed, so a re-run with an
# unchanged spec reports `ok`, not `changed`. Token-only rotation does not touch
# the marker — force a refresh with `-e nas_traefik_force=true`.
when:
- (nas_traefik_app_q.stdout | from_json) | length > 0
- (nas_traefik_marker is changed) or (nas_traefik_force | default(false) | bool)
changed_when: true
no_log: true
  • Step 2: Adjust payload keys to match Task 0’s verified schema if they differ (e.g. catalog_app/train/version naming).

  • Step 3: Commit.


Files:

  • Modify: ansible/nas-playbook.yml

  • Step 1: Add the role invocation

Locate the nas-targeted play in ansible/nas-playbook.yml (same play the other nas-* roles run under) and add:

- role: nas-traefik
tags: [nas-traefik]

(Match the exact list/mapping style already used in that file — confirm by reading it first.)

  • Step 2: Syntax check

Run: ansible-playbook -i ansible/inventory ansible/nas-playbook.yml --syntax-check Expected: no errors.

  • Step 3: Commit.

Task 5: Verify (dry-run → apply → observe)

Section titled “Task 5: Verify (dry-run → apply → observe)”

Files: none (verification is the “test” for infra).

  • Step 1: Dry-run (the failing/first test)

Run: scripts/nas-playbook.sh --tags nas-traefik --check --diff Expected: the Vault read + query tasks run; create/update shows as would-change (or ok if values already match). No secret in output (no_log). Fix any template/schema errors before applying.

  • Step 2: Apply

Run: scripts/nas-playbook.sh --tags nas-traefik Expected: changed on the create/update task, no failures.

  • Step 2b: Idempotency (re-run == green, PASS/FAIL)

Run: scripts/nas-playbook.sh --tags nas-traefik again. Expected (PASS): recap shows changed=0 for the update task — the drift marker is unchanged, so the gated app.update is skipped (ok). A changed here is a FAIL (the marker or gate is wrong), not a “note it”.

  • Step 3: Observe live state
Terminal window
ssh nas-automation@192.168.20.200 'sudo docker inspect -f "{{range \$p,\$c := .HostConfig.PortBindings}}{{\$p}} {{range \$c}}{{.HostIp}}:{{.HostPort}} {{end}}{{end}}" ix-traefik-traefik-1'

Expected: 80/tcp+443/tcp on 192.168.20.201, and 444/tcp now on 192.168.20.201 (not 0.0.0.0) — confirms the hardening.

  • Step 4: Cert sanity

Run: echo | openssl s_client -connect 192.168.20.201:443 -servername s3.nas.fzymgc.house 2>/dev/null | openssl x509 -noout -subject -issuer Expected: a valid Let’s Encrypt cert for s3.nas.fzymgc.house (Traefik still routing after the reconcile).

  • Step 5: Confirm .200:444 is closed

Run: echo | openssl s_client -connect 192.168.20.200:444 2>&1 | head -3 Expected: connection refused / no listener (dashboard no longer on the UI IP).


Files:

  • Modify: ansible/CLAUDE.md (Roles Reference table)
  • Modify: docs/operations/nas.md (Tag map + a short Traefik-app note)
  • Step 1: Add to ansible/CLAUDE.md Roles Reference:
| `nas-traefik` | Reconcile the TrueNAS community-catalog Traefik app (NAS-apps reverse-proxy VIP .201; ACME DNS-01 via Vault-sourced Cloudflare token; :444 dashboard bound to the VIP) | `nas` |
  • Step 2: Add a nas-traefik row to the Tag map in docs/operations/nas.md and a 2-3 line note (catalog app, values in role defaults, CF token from Vault, .201 VIP).

  • Step 3: Commit.


  • --check --diff clean, then apply green, then idempotent re-run (no failures).
  • docker inspect: 444/tcp bound to .201 (hardening applied), 80/443 still on .201.
  • s3.nas.fzymgc.house:443 still serves a valid LE cert (routing intact).
  • .200:444 refuses (dashboard off the UI IP).
  • No secret in any transcript (no_log on every token-touching task).