Move Traefik off shared MetalLB IP 10.0.20.200 to a dedicated 10.0.20.203 with externalTrafficPolicy=Local, to (1) restore real client IPs for CrowdSec on the 24 non-proxied apps (currently SNAT'd to a node IP) and (2) enable QUIC. Forced off the shared IP because MetalLB forbids mixed ETP on a shared IP (10.0.20.200 also carries the Terraform state DB). In-place cutover selected. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
6.1 KiB
Plan: Migrate Traefik to dedicated IP 10.0.20.203 + ETP=Local
Date: 2026-05-30 · Pairs with: 2026-05-30-traefik-dedicated-ip-etp-local-design.md
Status: Draft — review required before executing. Nothing applied yet.
Goal: real client IPs to CrowdSec + working QUIC on the 24 direct apps, by
moving Traefik off the shared 10.0.20.200 onto its own 10.0.20.203 with
externalTrafficPolicy: Local. Shared IP .200 (incl. the TF state DB) is
left untouched until the final cleanup step.
Recommended cutover: in-place (simplest, most maintainable) inside a short planned window. Additive/zero-downtime variant noted at the end.
Phase 0 — Pre-flight (read-only, ~10 min)
- Snapshot current state (already captured in chat; re-confirm at execution):
- Traefik svc: IP
10.0.20.200,allow-shared-ip=shared, ETP=Cluster. .200shared by 10 services incl.dbaas/postgresql-lb:5432(TF state).- DNS apex
viktorbarzin.me A = 10.0.20.200(Technitium primary, split-horizon). - pfSense rdr: WAN 443 tcp+udp → alias
<nginx>(=10.0.20.200);admin@10.0.20.1. - Traefik 3 replicas (node4, node5, +1), PDB minAvailable=2.
- Traefik svc: IP
- Confirm
10.0.20.203still free in pool10.0.20.200-220. - Lower DNS TTL on the apex record to 60s (Technitium) ~30 min ahead of cutover to shrink the window. (Restore to normal afterward.)
- Baseline checks to compare against (run now, save output):
curl -sI https://immich.viktorbarzin.me(direct app) → 200/redirectcurl -sI https://<a-proxied-app>→ 200 (proxied path)- PG state reachable:
nc -vz 10.0.20.200 5432(or aterragrunt planno-op) - Traefik access log shows
10.0.20.103for a direct app (the bug we're fixing) http3check.netfor immich → QUIC FAILS (baseline)
Phase 1 — Terraform: dedicated IP + ETP=Local (reversible)
Edit stacks/traefik/modules/traefik/main.tf, Helm service block (~L165-173):
service = {
type = "LoadBalancer"
annotations = {
"metallb.io/loadBalancerIPs" = "10.0.20.203" # was 10.0.20.200
# allow-shared-ip REMOVED — Traefik no longer shares an IP
}
spec = {
externalTrafficPolicy = "Local" # was Cluster
}
}
scripts/tg planinstacks/traefik— review: only the Traefik Service changes (new IP, ETP, annotation removed). No change to other stacks.scripts/tg apply.- Immediately verify (ingress is briefly broken until DNS+pfSense move):
kubectl get svc traefik -n traefik→ IP10.0.20.203, ETP=Local.kubectl get svc -A | grep 10.0.20.200→ the other 9 services still hold.200.nc -vz 10.0.20.200 5432→ TF state DB still reachable (critical).curl -sI --resolve <app>:443:10.0.20.203 https://<direct-app>→ 200 (proves.203serves before DNS moves).
Rollback (Phase 1): revert the three lines → scripts/tg apply. Back to .200.
Phase 2 — Internal DNS cutover (Technitium)
- Update split-horizon apex:
viktorbarzin.me A → 10.0.20.203(primary; AXFR replicates to secondary/tertiary, or kicktechnitium-zone-sync). - Verify internal resolution:
dig +short immich.viktorbarzin.me→10.0.20.203from a cluster/LAN client;curl -sI https://immich.viktorbarzin.me→ 200.
Rollback (Phase 2): apex A → 10.0.20.200.
Phase 3 — pfSense (live firewall — operator-driven, alias not literal)
Per the "create a VIP/alias, don't hardcode" requirement:
- Create a pfSense Firewall Alias (Firewall ▸ Aliases), type Host:
name
traefik_lb, value10.0.20.203. (This is the correct pfSense object for a NAT-forward target — same kind as the existing<nginx>alias. If a CARP/IP-Alias Virtual IP is intended instead, confirm at review; a routed K8s LB IP normally uses an Alias, not a VIP.) - Repoint the 443 forward (Firewall ▸ NAT ▸ Port Forward): change the
existing WAN
https(TCP and UDP) rule's target fromnginx→traefik_lb. Leave the auto firewall rule linked. Do not touch thehttp-alt/7443rules (those are xray on<k8s_shared_lb>). - Apply pfSense changes.
- Verify externally:
http3check.netfor immich → QUIC OK (h3 established).- External
curlto a few direct apps → 200. - Traefik access log now shows real client IPs for direct apps (not
10.0.20.103).
Rollback (Phase 3): point the 443 rule's target back to nginx.
Phase 4 — Verify CrowdSec + the fleet (the real prize)
- Traefik logs: real public IPs on direct apps (sample several).
- CrowdSec: confirm it now ingests real IPs (a test decision / metrics);
confirm the source-IP allowlist (
10.0.20.0/22,192.168.1.0/24, tailnet) is active so family/LAN aren't banned. - Proxied apps unaffected (spot-check 2-3 — still real IPs via Cloudflare).
- All other
.200services healthy (PG state, headscale, wireguard, coturn, xray, etc.). - Restore DNS TTL to normal.
Phase 5 — Cleanup / docs
- Confirm Traefik no longer answers on
.200(it shouldn't after Phase 1). - Update docs (design doc "Affected docs" list):
.claude/CLAUDE.md,docs/architecture/networking.md, service-catalog, memory ids 3241-3246. - Commit TF + docs.
Rollback (full)
Reverse order: pfSense 443 target → nginx; apex A → .200; revert the
Traefik Service TF (IP .200, allow-shared-ip=shared, ETP=Cluster) → apply.
kubectl/Helm reach the API server directly (not via Traefik), so control is
retained even if ingress is down mid-cutover.
Additive (zero-downtime) variant — if the window is unacceptable
Instead of editing the Helm Service in place: add a second raw
kubernetes_service (type LoadBalancer, IP .203, ETP=Local, ports
web/80→8000, websecure/443→8443 TCP, websecure-http3/443→8443 UDP, selector =
Traefik pod labels). Both .200 (old) and .203 (new) serve Traefik. Cut
DNS+pfSense to .203, verify, then convert the Helm Service to ClusterIP
(drops .200). More config to carry long-term (a hand-maintained Service
duplicating Helm) — weigh against the brief in-place window.