Compute & networking
What runs
k3s on three nodes (k3s-1, k3s-2, k3s-3), on Debian 13.
kube-vip in ARP mode, which gives
LoadBalancerServices a stable address on the home network. k3s's built-in ServiceLB is turned off from the first install.Traefik ingress on one kube-vip address. Every public hostname resolves to it, and Traefik gets certificates from Let's Encrypt. It sees each visitor's real IP and logs every request as JSON.
CrowdSec in front of every site: it bans attackers' IPs, blocks requests that exploit known vulnerabilities, and records what a full web application firewall would catch (Edge protection, below).
Why this choice
k3s is a full Kubernetes distribution in one small binary, and that suits a handful of small machines.
TODO: why k3s over kubeadm or Talos.
kube-vip gives ingress one address that doesn't belong to any single node. One kube-vip pod, chosen by leader election, announces the address. If that node goes down, another pod takes over the announcement.
TODO: why kube-vip over ServiceLB, in your words.
Edge protection
internet ─▶ router ─▶ VIP (kube-vip) ─▶ Traefik ─▶ CrowdSec bouncer (plugin)
├─ banned IP? ─▶ 403
└─ AppSec (WAF) ─▶ known exploit? ─▶ 403
└─ OWASP CRS ─▶ recorded only
CrowdSec agent ◀── Traefik's access logs CrowdSec API ◀── community blocklist
Three layers, each switched on only after it was proven:
| Layer | What it does | Blocks? |
|---|---|---|
| IP reputation | bans IPs that scan or brute-force, from Traefik's logs, plus IPs that thousands of other CrowdSec users have reported | yes |
| Virtual patching | blocks requests that exploit known CVEs, with rules updated daily, often before the app itself is patched | yes |
| OWASP Core Rule Set | flags generic attacks (SQL injection, XSS, path traversal) | not yet: it only records |
Why CrowdSec. I wanted Coraza, the open-source WAF engine that runs the OWASP Core Rule Set, in front of Traefik. Its Traefik plugin is no longer maintained for production, by its author's own note, and Traefik's native Coraza is only in the commercial Traefik Hub. CrowdSec's WAF runs Coraza inside, is maintained, and adds the IP-reputation layer.
Real client IPs first. By default, Kubernetes rewrites every visitor's
address on the way in, so Traefik saw one internal IP for everyone and wrote
no access logs at all (a setting was silently ignored). An IP ban is useless
without real IPs, so that came first: access logs on, and the Service set to
deliver traffic only to Traefik's own node (externalTrafficPolicy: Local).
Minutes later, the logs showed a scanner probing the bare home IP.
Fails open. If CrowdSec is down, requests pass uninspected rather than every site going dark, and an alert says so. For a homelab, availability wins. The plugin's default is the opposite, so this is set explicitly.
Home is never banned, but still inspected. Devices at home, and pods calling the lab's own sites, reach them through the router, so they arrive as the home IP. That IP is on a never-ban list; it isn't trusted to skip inspection, so a stale entry after the ISP changes it can't open a hole.
Nothing downloaded at start-up. Traefik plugins normally download when Traefik starts. With CrowdSec on every route, a failed download would take every site down, so the plugin's source is pinned in git and unpacked into Traefik's pod.
What this proves
Checked on 2026-10-23, from a phone on cellular and from home:
| Check | Result |
|---|---|
Client IPs before, and after Local |
every request was 10.42.0.0; after, the phone's carrier IP, and a scanner within minutes |
Request for /.env (a secrets probe) |
403, recorded as a blocked exploit |
| Banning the phone's IP | 403 on protected sites within 60 seconds; unprotected sites unaffected |
| OWASP CRS on the same probe | recorded as a file-inclusion attempt, nobody banned |
Every site, a login through Zitadel, git push, a CI image push to the registry |
all pass |
| CrowdSec's WAF made unreachable | every site still 200 at normal speed; protection back 2 seconds after |
Trade-offs and known limits
One network. ARP mode only works when every node shares one Layer 2 network. Across routed networks, kube-vip needs BGP mode.
Same interface name on every node. kube-vip runs as one DaemonSet, so every pod gets the same interface setting. Debian names interfaces by hardware position, so each node is renamed to
lan0first. Without that, the address stops working whenever a node with a different name wins the election.Traefik config lives in k3s. k3s installs Traefik itself. Changes go in a
HelmChartConfigso k3s keeps them, not in edits that k3s would revert.Real IPs cost a short blip when Traefik moves. With
Local, the address follows the Traefik pod, so a node reboot or drain means about 8 seconds of downtime while it moves.A kube-vip race took the sites down once. Switching the Service to
Localthrough k3s's config file updated it twice in quick succession, and kube-vip left the address unannounced: a 3½-minute outage. I reproduced it on a throwaway Service, found the fix (restart the kube-vip pod on the wrong node, about 9 seconds), then made the real change with a single update and no downtime.New bans take up to a minute. Traefik keeps a local copy of the ban list and refreshes it every 60 seconds, so no request ever waits on CrowdSec.
The Core Rule Set only records, for now. Its first day flagged ordinary
git pushtraffic, so it needs Gitea's git endpoints excluded before it's allowed to ban. Large uploads are inspected up to 10 MB.
Next
The Core Rule Set review: exclude git traffic, then let it ban.
A 4th node: the hardware is here.
EKS and the AWS Load Balancer Controller, to run the same pattern on managed Kubernetes (roadmap).