Files
honeyDueAPI/docs/deployment/06-traefik-ingress.md
T
Trey t 6f303dbbaa
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Migrate prod deploy from Swarm to K3s; add full deployment book
Infrastructure:
- Stack now runs on K3s v1.34.6 HA (3 Hetzner CX33 nodes as managers)
- Traefik DaemonSet + hostNetwork replaces Caddy + ingress mesh
- All manifests in deploy-k3s/manifests/; Swarm config (deploy/) kept
  temporarily for reference

Bug fixes surfaced during migration:
- Dockerfile: golang:1.24-alpine -> 1.25-alpine (go.mod requires 1.25)
- cache_service.go: remove sync.Once reassignment from inside Do()
  callback (was causing 'unlock of unlocked mutex' fatal after
  Redis Ping failure)
- router.go: relax CSP from 'default-src none' to 'default-src self'
  + allowlist fonts.googleapis.com so the marketing landing page CSS
  actually loads in browsers
- deploy/scripts/deploy_prod.sh: use docker buildx with
  --platform linux/amd64 so arm64 (Apple Silicon) dev machines produce
  images runnable on x86_64 Hetzner nodes; fix array expansion under
  set -u
- deploy/swarm-stack.prod.yml: fix secret source references to use
  top-level aliases (the '\${X_SECRET}' form never actually resolved);
  dozzle ports: long-form host_ip is rejected by Swarm, switched to
  short-form (bound to 0.0.0.0 with UFW-based loopback restriction);
  worker replicas 2 -> 1 (Asynq scheduler singleton)
- deploy-k3s/manifests/admin/deployment.yaml: probe path '/admin/' -> '/'
  (Next.js serves at root; /admin/ returned 404 and killed pods);
  startupProbe failureThreshold 12 -> 24
- deploy-k3s/manifests/pod-disruption-budgets.yaml: worker minAvailable
  1 -> 0 (singleton)
- deploy-k3s/manifests/api/deployment.yaml: startupProbe failureThreshold
  12 -> 48 (MigrateWithLock serializes across 3 replicas on first-boot;
  real startup takes up to 240s)
- .gitignore: tighten 'api' -> '/api' (was matching deploy-k3s/manifests/api/
  and admin/src/app/api/*, hiding legitimate files)

New files:
- deploy-k3s/manifests/traefik-helmchartconfig.yaml: DaemonSet +
  hostNetwork override for k3s-bundled Traefik
- deploy-k3s/manifests/ingress/ingress-simple.yaml: plain Ingress
  without TLS (CF Flexible SSL) and without middleware
- deploy-k3s/MIGRATION_NOTES.md: operator-facing migration log

Documentation:
- docs/deployment/ — full deployment book, 26 files, ~42k words:
  - Part I Overview, infrastructure, orchestrator choice (Ch 0-2)
  - Part II Networking, firewall, Cloudflare (Ch 3-4, 13)
  - Part III Security, Traefik ingress (Ch 5-6)
  - Part IV Services, DB, storage, secrets, registry (Ch 7-11)
  - Part V Data flow, deploy process, observability, failures, runbook
    (Ch 12, 14-17)
  - Part VI Cost, Swarm postmortem, roadmap (Ch 18-20)
  - Appendices: glossary, kubectl cheat sheet, file locations,
    consolidated citations
- README.md: Production Deployment section replaced with pointer to
  the book; Go version bumped to 1.25

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 07:20:54 -05:00

15 KiB
Raw Blame History

06 — Traefik Ingress

Summary

Traefik is the reverse proxy that routes external HTTP requests to the right application pod based on the Host: header. We run Traefik v3 as a Kubernetes DaemonSet with hostNetwork: true — each of the three nodes has its own Traefik pod listening directly on the node's :80/:443. Cloudflare round-robins DNS across the three node IPs, so any node can serve any request. No external load balancer.

Why Traefik

K3s bundles Traefik by default. The alternatives:

Option Pros Cons
Traefik v3 (bundled) Zero install, excellent k8s integration, middleware system, active development Helm-driven config is indirect
NGINX Ingress Most popular, battle-tested Another thing to install, more config surface
HAProxy Ingress Extremely performant More hands-on, older docs
Caddy Simple config, auto-HTTPS caddy-docker-proxy / Ingress integration is less mature
Envoy / Istio Most featureful Massive overkill at our scale

Traefik came "free" with K3s, does the job, and its Swarm provider is what we would have used if we'd fixed our Swarm architecture. Using it on k3s keeps the mental model consistent.

Deployment model

flowchart TB
    subgraph CF[Cloudflare edge]
        DNS[DNS A records:<br/>api.myhoneydue.com → 3 node IPs<br/>admin.myhoneydue.com → 3 node IPs]
    end

    subgraph N1[hetzner1]
        T1[Traefik pod<br/>hostNetwork:true<br/>:80/:443]
        kernel1[Linux kernel<br/>net.ipv4.ip_unprivileged_port_start=0]
    end
    subgraph N2[hetzner2]
        T2[Traefik pod<br/>hostNetwork:true<br/>:80/:443]
        kernel2[Linux kernel]
    end
    subgraph N3[hetzner3]
        T3[Traefik pod<br/>hostNetwork:true<br/>:80/:443]
        kernel3[Linux kernel]
    end

    subgraph Cluster[k3s cluster services]
        APISvc[api Service :8000]
        AdminSvc[admin Service :3000]
    end

    DNS -. HTTP :80 .-> T1 & T2 & T3
    T1 & T2 & T3 -- reverse_proxy --> APISvc & AdminSvc

ASCII fallback

                  Cloudflare DNS
                  ┌───────────────────┐
                  │  api  → 3 IPs     │
                  │  admin→ 3 IPs     │
                  └─────────┬─────────┘
                            │ HTTP :80
        ┌───────────────────┼───────────────────┐
        ▼                   ▼                   ▼
  ┌──────────┐        ┌──────────┐        ┌──────────┐
  │ hetzner1 │        │ hetzner2 │        │ hetzner3 │
  │ Traefik  │        │ Traefik  │        │ Traefik  │
  │  :80/443 │        │  :80/443 │        │  :80/443 │
  │(hostNet) │        │(hostNet) │        │(hostNet) │
  └────┬─────┘        └────┬─────┘        └────┬─────┘
       │                   │                   │
       └── ClusterIP ──────┼── ClusterIP ──────┘
                           ▼
              ┌────────────────────────┐
              │ api Service   :8000    │
              │ admin Service :3000    │
              └────────────────────────┘

Why DaemonSet + hostNetwork

What we're trying to achieve: Any public-facing node should answer :80/:443. Cloudflare round-robins DNS; whichever node it picks, that node must serve.

The default k3s Traefik deployment is a single-replica Deployment exposed via a LoadBalancer Service. That requires either:

  • Hetzner Load Balancer (+ $8.49/mo, another thing to manage), or
  • K3s' built-in servicelb (klipper-lb) which binds node ports dynamically to proxy to the Service

Neither was quite what we wanted. With three replicas of the stock Traefik behind klipper-lb, each Traefik pod is reachable but there's an extra hop through klipper's proxy daemon.

DaemonSet + hostNetwork is cleaner: each Traefik pod is the host's :80/:443. No proxy daemon, no LB Service, no VIP. Cloudflare DNS → node IP → kernel → Traefik, one hop.

Trade-offs of hostNetwork

Pro:

  • One fewer layer of indirection; lower latency
  • No Service needed; no kube-proxy in the ingress path
  • Standard Cloudflare round-robin DNS is the failover mechanism

Con:

  • Traefik is in the host netns; it sees the node's interfaces, not the cluster overlay
  • Traefik still joins the cluster-DNS resolution (via hostNetwork's default DNS policy) so it can resolve Service names like api
  • Port conflicts possible if anything else wants :80/:443 on the node (nothing else does in our setup)

Trade-offs of DaemonSet

Pro:

  • One Traefik per node; matches our Cloudflare 3-IP round-robin exactly
  • Any node down = Cloudflare's origin health checks route around it

Con:

  • Updates require maxUnavailable > 0 (host ports conflict during surge) → brief moment where one node is down during rollout
  • 3× the memory usage vs. 1-replica Deployment (but Traefik is tiny — ~128 MB total across all three)

Our Traefik configuration

We reconfigure the bundled K3s Traefik via a HelmChartConfig. K3s uses the helm-controller to manage bundled addons; HelmChartConfig lets us override values without disabling-and-replacing the chart.

Full config at deploy-k3s/manifests/traefik-helmchartconfig.yaml. Key settings:

apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
  name: traefik
  namespace: kube-system
spec:
  valuesContent: |-
    deployment:
      kind: DaemonSet      # was Deployment
    hostNetwork: true
    service:
      enabled: false        # no LoadBalancer Service
    ports:
      web:
        port: 80
        hostPort: 80
      websecure:
        port: 443
        hostPort: 443
    updateStrategy:
      type: RollingUpdate
      rollingUpdate:
        maxUnavailable: 1
        maxSurge: 0
    securityContext:
      capabilities:
        drop: [ALL]
        add: [NET_BIND_SERVICE]
      readOnlyRootFilesystem: true
      runAsGroup: 65532
      runAsNonRoot: true
      runAsUser: 65532
    additionalArguments:
      - "--entrypoints.web.forwardedHeaders.trustedIPs=<CF ranges>"

Why each setting

  • kind: DaemonSet — one Traefik per node. Default is a Deployment with 1 replica.
  • hostNetwork: true — Traefik runs in the host's network namespace so it can bind real :80/:443 on the node.
  • service.enabled: false — no LoadBalancer Service is created. With hostNetwork, we don't need one.
  • ports.*.hostPort — explicit host port binding. Matches the container port (DaemonSet semantics with hostPort: 80 ensure the kubelet schedules at most one Traefik per node).
  • updateStrategy.maxUnavailable: 1, maxSurge: 0 — we accept one node being down during a Traefik update (host port can't be shared). The Traefik Helm chart rejects this config combination with maxSurge > 0 — this was the second config iteration.
  • Security context — non-root (UID 65532), read-only root filesystem, only NET_BIND_SERVICE capability. See Chapter 5.
  • forwardedHeaders.trustedIPs — Cloudflare's IP ranges. Traefik trusts X-Forwarded-Proto et al. only from these ranges, so a bypassing client can't spoof the proto header.

Forwarded-headers trustedIPs

The full list of trusted CF ranges is in our additionalArguments. It's the union of CF's published IPv4 and IPv6 ranges. When Cloudflare passes a request to origin, it adds X-Forwarded-For and X-Forwarded-Proto headers; Traefik only honors these if the request came from one of these IPs. Every other client's headers are ignored.

If CF publishes new IP ranges (rare but possible), the trustedIPs list needs updating. It's a raw string in our HelmChartConfig — we'd need to edit, apply, and bump the helm job.

Traefik v3 vs v2

K3s ships Traefik v3 (currently 3.6.10). The v2 → v3 migration changed a few things:

  • swarmMode removed (replaced by a swarm provider, but we don't use Swarm anyway)
  • Encoded-character handling changed (v3 warns about RFC 3986 handling; we ignore the warning)
  • Middleware CRD group is traefik.io/v1alpha1 (was containo.us)

Our deployment handles all of this automatically via the bundled chart.

Ingress resources

We define two standard k8s Ingress resources in deploy-k3s/manifests/ingress/ingress-simple.yaml:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: honeydue-api
  namespace: honeydue
spec:
  ingressClassName: traefik
  rules:
    - host: api.myhoneydue.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service: {name: api, port: {number: 8000}}
    - host: myhoneydue.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service: {name: api, port: {number: 8000}}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: honeydue-admin
  namespace: honeydue
spec:
  ingressClassName: traefik
  rules:
    - host: admin.myhoneydue.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service: {name: admin, port: {number: 3000}}

Traefik watches for Ingress resources with ingressClassName: traefik and programs its router table accordingly. Changes are applied within seconds — no restart needed.

What pathType: Prefix means

Every request starting with / matches (which is everything). Alternative is Exact (matches only the literal path). Prefix is the default for most Ingress controllers and matches how users think about URL routing.

How requests flow

  1. Cloudflare DNS resolves api.myhoneydue.com to one of three IPs (round-robin). Say it picks 178.105.32.198 (hetzner2).
  2. Cloudflare edge establishes TCP to 178.105.32.198:80 (plain HTTP, SSL=Flexible). Original HTTPS terminated at CF.
  3. UFW on hetzner2 accepts the SYN (80/tcp open from anywhere).
  4. Linux kernel sees a listener on 0.0.0.0:80 (the Traefik pod). Hands off the SYN.
  5. Traefik accepts the connection. Reads the HTTP request.
  6. Traefik matches the Host: header against its router table. Host: api.myhoneydue.comhoneydue-api Ingress → api Service.
  7. Traefik dials 10.43.167.83:8000 (api Service ClusterIP). This goes through the cluster DNS (CoreDNS) and kube-proxy (IPVS).
  8. kube-proxy IPVS rewrites the destination to a live api pod endpoint — say 10.42.2.6:8000 (api pod on hetzner3).
  9. Flannel VXLAN encapsulates the packet and sends to hetzner3 (UDP :8472 between node IPs).
  10. hetzner3's kernel decapsulates, delivers to the api pod.
  11. api pod processes, returns response.
  12. Response flows back the reverse path.

Cloudflare caches 200 responses at the edge (default TTL varies; for HTML/JSON usually 0 unless we set Cache-Control headers). So the second request for the same URL might not reach the origin at all.

Middleware (mostly unused)

Traefik supports middleware — small functions run before/after the proxy. The deploy-k3s/manifests/ingress/middleware.yaml scaffold defines:

  • rate-limit — 100 req/min average, 200 burst
  • security-headers — HSTS, X-Frame-Options, CSP, etc.
  • cloudflare-only — IP allowlist restricting origin to CF ranges
  • admin-auth — HTTP basic auth for admin panel

None of these are currently attached to our Ingresses. To enable, add the traefik.ingress.kubernetes.io/router.middlewares annotation to the Ingress:

metadata:
  annotations:
    traefik.ingress.kubernetes.io/router.middlewares: honeydue-security-headers@kubernetescrd,honeydue-rate-limit@kubernetescrd

We left them off to minimize surface area for the first week of the new cluster. Enabling is TODO in Chapter 20.

Traefik dashboard

Disabled. The Traefik dashboard (/dashboard/ and /api/) exposes runtime state and is potentially information leaky. The bundled k3s Traefik disables it by default, and we haven't re-enabled it.

If needed for debugging:

# Port-forward to a Traefik pod
kubectl port-forward -n kube-system daemonset/traefik 9000:9000
# (the chart exposes the dashboard on :9000 when enabled)
# Then visit http://localhost:9000/dashboard/

This requires kubectl access and isn't exposed publicly.

Version pinning

We take whatever Traefik version is bundled with K3s (currently 3.6.10). The bundled chart is pinned to a specific version in K3s' release notes; when we upgrade K3s the Traefik version can change. If that ever breaks something, we can pin a specific version via the HelmChartConfig's version field:

spec:
  version: 39.0.501+up39.0.5  # specific chart version

Limitations we accept

  • No sticky sessions. Every request to api.myhoneydue.com can go to a different pod. Our Go API is stateless — this is fine.
  • No canary deployments (yet). Traefik supports weighted routing via its CRDs (TraefikService) but we don't use them. TODO if/when we do gradual rollouts.
  • No mTLS. Traefik supports mutual TLS client auth for sensitive endpoints. We don't use it.
  • Single ingress class. Everything goes through the same Traefik. For multi-tenant setups we'd want separate ingress classes with separate policies.

Troubleshooting

Symptom Likely cause Fix
404 from Traefik Ingress doesn't match Host: Check Ingress host field, DNS
502 from Traefik Backend Service has no endpoints kubectl get endpoints -n honeydue
503 from Traefik Circuit breaker / backend unhealthy Check pod logs, readiness probe
504 from Traefik Backend slow Check pod CPU/memory, DB connections
Connection refused at 80 Traefik pod not running or kernel not listening kubectl get pods -n kube-system -l app.kubernetes.io/name=traefik; `ssh deploy@node 'ss -lntp
Mixed content error in browser X-Forwarded-Proto not honored by app Check trustedIPs includes CF; check app reads the header

Operator cheat sheet

# Traefik pods per node
kubectl get pods -n kube-system -l app.kubernetes.io/name=traefik -o wide

# Traefik logs (all pods)
kubectl logs -n kube-system -l app.kubernetes.io/name=traefik --tail=50 --prefix

# Ingress status
kubectl get ingress -n honeydue

# List all routers Traefik sees (requires dashboard or API)
kubectl exec -n kube-system daemonset/traefik -- traefik healthcheck

# Re-apply config
kubectl apply -f deploy-k3s/manifests/traefik-helmchartconfig.yaml
kubectl delete job -n kube-system helm-install-traefik  # triggers reinstall

# Restart all Traefik pods
kubectl rollout restart daemonset/traefik -n kube-system

References