--- created: 2026-05-06 path: Sources/Homelab project: traefik-deployment status: active tags: - homelab - traefik - dns - knot - tls - cloudflare - tailscale - mcp type: project-plan updated: 2026-05-06 --- # Traefik Deployment — Homelab Ingress ## Goal Replace ad-hoc `host:port` access with consistent named endpoints for ~8 services across three surfaces (public, private, east-west). Build the full ingress stack — Traefik, Knot DNS, wildcard TLS — and migrate MCP behind it as the first real service. ## Locked Architecture ### Topology - **Traefik VM** on PVE (own VM, runs Docker + tailscaled + cloudflared) - **Knot primary** in LXC on PVE - **Knot secondary** in Docker on NAS (Ubuntu bare metal) - **Pi-hole + Unbound** stays as recursive frontend + ad-blocker - Cross-VLAN firewall locks PVE↔NAS to explicit flows (Knot AXFR, Traefik→NAS services) ### DNS - Knot authoritative for `herbylab.dev` internal - Active/passive replication via native AXFR + NOTIFY - Pi-hole conditional forwards `herbylab.dev` queries to Knot - Split-horizon: same name resolves to Traefik VM internally, Cloudflare externally - Zone file in git, Ansible deploys to primary ### TLS - Wildcard `*.herbylab.dev` via Traefik + Cloudflare DNS-01 - One cert covers public and private surfaces - Tailscale certs dropped from the plan ### Naming - Capability-based: `.herbylab.dev` regardless of host - No `lab.` infix, no `dev.` prefix — domain is by function, not location ### Routing surfaces - **Public:** Cloudflare → cloudflared (on Traefik VM) → Traefik → service - **Private:** Tailscale → Traefik VM tailnet IP → service (DNS via Pi-hole → Knot) - **East-west:** direct, never through Traefik ### Visual ┌─────────────────────┐ │ Cloudflare Edge │ │ *.herbylab.dev │ └──────────┬──────────┘ │ tunnel tailnet ────────────────────┼──────────────── tailscale0 │ ┌──────────▼──────────┐ │ Traefik VM (PVE) │ │ - traefik │ │ - cloudflared │ │ - tailscaled │ └──────────┬──────────┘ │ ┌────────────────┼────────────────┐ │ │ │ ┌───▼──┐ ┌───▼──┐ ┌───▼──┐ │ MCP │ │whoami│ │ ... │ └──────┘ └──────┘ └──────┘ ## Build Phases ### Phase 1 — Foundations No services routed yet. Each step produces a verifiable artifact. - [ ] **1.1 Cloudflare API token** - Scope: `herbylab.dev` zone, DNS edit permissions only - Verify with `curl` against CF API - Store in password manager - [ ] **1.2 Traefik VM on PVE** - Fresh Debian or Ubuntu, 1 vCPU, 1GB RAM, on PVE VLAN - Install Docker, Tailscale; `tailscale up` - Verify: pingable from laptop over tailnet - Snapshot - [ ] **1.3 Knot primary in LXC on PVE** - Stand up LXC, install Knot - Load minimal zone file with one test record - Verify: `dig test.herbylab.dev @` returns expected answer - [ ] **1.4 Knot secondary in Docker on NAS** - Configure as AXFR slave - Open cross-VLAN firewall rule for AXFR - Verify: `dig test.herbylab.dev @` matches primary - [ ] **1.5 Pi-hole conditional forwarding** - Configure Pi-hole: `herbylab.dev` → Knot - Verify from a normal client: `dig test.herbylab.dev` returns Knot's answer - [ ] **1.6 Wildcard cert via Traefik + Cloudflare DNS-01** - Minimal Traefik config, ACME with DNS-01 challenge - Request `*.herbylab.dev` - Verify: cert in `acme.json`, expiry ~90 days out - No services routed yet **Phase 1 checkpoint:** working DNS chain, working wildcard cert, Traefik VM with no routes. Production unchanged. Safe abort point. ### Phase 2 — Canary (`whoami`) - [ ] **2.1 Deploy `whoami` on Traefik VM** - Docker compose, labels for both public and private surfaces - [ ] **2.2 Add DNS records** - Knot zone: `whoami.herbylab.dev` → Traefik VM tailnet IP - Cloudflare: `whoami.herbylab.dev` → cloudflared tunnel - [ ] **2.3 Four-position verification** - Laptop on tailnet → `https://whoami.herbylab.dev` works, real cert - Laptop off tailnet (LTE hotspot) → same URL works via cloudflared - `dig` from inside LAN returns Traefik VM IP - `dig` from outside returns Cloudflare IP - [ ] **2.4 Decision: keep `whoami` as permanent diagnostic, or tear down** ### Phase 3 — MCP Migration Parallel-path safety net. Existing `cloudflared → MCP` stays running until the new path is verified. - [ ] **3.1 Add Traefik labels to MCP container** - Add internal DNS record in Knot - Existing public path untouched - [ ] **3.2 Verify private surface first** - From tailnet: `https://mcp.herbylab.dev` → Traefik → MCP works - [ ] **3.3 Cut public traffic over** - Flip Cloudflare DNS / tunnel config to land `mcp.herbylab.dev` on Traefik - Verify Claude can still call MCP - Rollback path: flip DNS back - [ ] **3.4 Decommission old direct cloudflared route** - After a few days of stability ## Open Items (Build-Time Decisions) - [ ] Inventory current `*.herbylab.dev` records to migrate from Pi-hole local DNS into Knot zone file - [ ] TTL strategy — start at 300 during buildout, raise to 3600 once stable - [ ] Second Pi-hole instance for redundancy (parallel work, not blocking) - [ ] Pi-hole sync method — gravity-sync vs Teleporter - [ ] Ansible role structure for Knot primary deployment ## Key Principles - **Capability-based naming.** Domain reflects function, not host. Service can move hosts without renaming. - **Surfaces compose, they don't overlap.** Cloudflare = public auth + ACME validation. Tailscale = device naming on tailnet. Traefik = service routing once a request lands. Internal DNS = telling LAN clients where to send the packet. - **East-west stays direct.** Service-to-service inside Docker/LAN never traverses Traefik. - **Build outside-in for foundations, inside-out for services.** Cert and DNS first; canary before production. - **Parallel paths for migration.** Never cut over working infrastructure without a rollback flip. ## Reference Prior session notes: `Tech/Sessions/homelab-dns-decision.md` (Knot architecture lock-in, alternatives considered, why split-horizon with public domain). ...sent from Jenny & Travis