Skip to main content

Phase 6 β€” NGINX Ingress Controller

Expose HTTP(S) services in the cluster via hostnames instead of NodePorts. After this phase, every cluster service can be reached at a real URL like homer.10.0.0.200.nip.io instead of 10.0.0.2:30902.

This phase builds directly on Phase 4 (MetalLB) β€” the Ingress controller is exposed as a Service type: LoadBalancer, which MetalLB binds to a single dedicated IP from the 10.0.0.200–250 pool. The result is the same architecture every cloud Kubernetes cluster uses: one external IP, host-based routing in front of any number of backends.


Architecture​

Browser
β”‚ GET http://homer.10.0.0.200.nip.io/
β–Ό
nip.io β†’ resolves to 10.0.0.200
β–Ό
MetalLB advertises 10.0.0.200 via ARP on the cluster network
β–Ό
NGINX Ingress Controller pod (LoadBalancer Service)
β”‚ reads Ingress resources, picks the rule matching Host header
β–Ό
Service "homer" (ClusterIP, port 8080)
β–Ό
Pod homer-…

nip.io is a public DNS service that resolves any subdomain of the form <anything>.<ip>.nip.io to that IP. Zero setup; perfect for homelab testing without standing up a real DNS server.


Why F5 NGINX Open Source (not the community ingress-nginx)​

The community kubernetes/ingress-nginx project (Helm chart ingress-nginx/ingress-nginx) entered read-only / archived maintenance in early 2026 β€” the upstream maintainers stopped accepting feature work after a string of CVE disclosures and a sustainability-of-volunteers conversation. New deployments should use the actively-maintained alternative:

F5 NGINX Open Source Ingress Controller (Helm chart nginx-stable/nginx-ingress, repo https://helm.nginx.com/stable).

Differences worth knowing if you've used the community chart before:

Community ingress-nginx (archived)F5 nginx-ingress (this phase)
Helm chartingress-nginx/ingress-nginxnginx-stable/nginx-ingress
Annotation prefixnginx.ingress.kubernetes.io/...nginx.org/...
Extra CRDsNone (just standard Ingress)VirtualServer, VirtualServerRoute, Policy, TransportServer
IngressClass controllerk8s.io/ingress-nginxnginx.org/ingress-controller
Configmap nameingress-nginx-controllernginx-ingress

For most workloads the standard Ingress resource is portable β€” the differences only bite when you start using annotations or controller-specific features.


Step 1 β€” Disable k3s's bundled Traefik​

k3s ships with Traefik as the default ingress controller. Running two ingress controllers at once is a footgun (which one binds 80/443? which one handles /?), so disable Traefik first.

On the control-plane node (set-hog), edit /etc/rancher/k3s/config.yaml:

disable:
- servicelb
- traefik

servicelb (klipper-lb) was already disabled in Phase 4 when MetalLB took over LoadBalancer duties. Adding traefik to the same list keeps things consistent.

Apply by restarting k3s:

sudo systemctl restart k3s

The API server is unavailable for ~10–30 seconds during the restart. Workloads keep running because each kubelet has cached state.

Cleanup after disable​

When traefik is added to disable, k3s spawns a helm-delete-traefik Job that runs helm uninstall traefik. On this cluster the Service got stuck on its service.kubernetes.io/load-balancer-cleanup finalizer (MetalLB had given it 10.0.0.200). The Job hangs at helm uninstall ... --wait until the finalizer clears.

Force-clear it manually:

kubectl patch svc traefik -n kube-system -p '{"metadata":{"finalizers":[]}}' --type=merge
kubectl delete svc traefik -n kube-system --grace-period=0 --force
kubectl delete job helm-install-traefik helm-delete-traefik -n kube-system --ignore-not-found

The 10.0.0.200 IP is now free in the MetalLB pool.


Step 2 β€” Install F5 NGINX Ingress via Helm​

helm repo add nginx-stable https://helm.nginx.com/stable
helm repo update

helm install nginx-ingress nginx-stable/nginx-ingress \
--namespace ingress-nginx \
--create-namespace \
--set controller.service.type=LoadBalancer \
--set controller.ingressClass.setAsDefaultIngress=true

Two --set flags do the work:

  • controller.service.type=LoadBalancer β€” exposes NGINX via a LoadBalancer Service, which MetalLB will assign an IP to.
  • controller.ingressClass.setAsDefaultIngress=true β€” makes the nginx IngressClass the default, so any Ingress resource without an explicit ingressClassName field uses NGINX automatically.

Verify:

kubectl get pods -n ingress-nginx
kubectl get svc -n ingress-nginx
kubectl get ingressclass

Expected output:

NAME READY STATUS RESTARTS AGE
nginx-ingress-controller-5587758447-wxqpw 1/1 Running 0 15s

NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
nginx-ingress-controller LoadBalancer 10.43.27.103 10.0.0.200 80:31030/TCP,443:30326/TCP

NAME CONTROLLER AGE
nginx nginx.org/ingress-controller 16s

The EXTERNAL-IP is what MetalLB assigned. Confirm NGINX is serving:

curl -sI http://10.0.0.200/
# HTTP/1.1 404 Not Found
# Server: nginx/1.29.7

A 404 here is correct β€” NGINX is up but no Ingress rule matches yet.


Step 3 β€” Route a Service through Ingress​

Convert Homer (currently NodePort) to ClusterIP and create an Ingress for it.

# homer-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: homer
namespace: homer
spec:
ingressClassName: nginx
rules:
- host: homer.10.0.0.200.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: homer
port:
number: 8080
kubectl apply -f homer-ingress.yaml

# Demote homer service from NodePort β†’ ClusterIP (Ingress no longer needs it exposed)
kubectl patch svc homer -n homer --type=json \
-p '[{"op":"replace","path":"/spec/type","value":"ClusterIP"},
{"op":"remove","path":"/spec/ports/0/nodePort"}]'

Test:

curl -sI http://homer.10.0.0.200.nip.io/
# HTTP/1.1 200 OK
# Server: nginx/1.29.7

Or open it in a browser (works from the controller, and from any Tailscale-connected device because the controller advertises 10.0.0.0/24).


Result on this cluster​

Installed and verified on 2026-04-28:

  • Chart: nginx-stable/nginx-ingress (F5 NGINX OSS)
  • NGINX version: 1.29.7
  • LoadBalancer IP: 10.0.0.200 (assigned by MetalLB from the 10.0.0.200–250 pool)
  • IngressClass: nginx (default, controller nginx.org/ingress-controller)
  • First app routed: Homer at homer.10.0.0.200.nip.io (Service demoted from NodePort to ClusterIP)
  • Traefik: disabled in /etc/rancher/k3s/config.yaml, manually finalizer-cleared and removed

What's still missing (deferred to later phases)​

  • TLS / HTTPS β€” added in Phase 15 via cert-manager + Let's Encrypt (or an internal CA for *.nip.io style hosts).
  • Real DNS β€” nip.io is fine for testing but ideally apps live behind a real domain. Phase 6 of the remote-access docs covers Cloudflare Tunnel + DNS, which is the natural follow-up.
  • WAF / rate limiting / auth β€” F5 NGINX OSS supports basic auth, IP allowlists, and rate-limit annotations (nginx.org/limit-req-rate); deeper protection (NAP, ModSecurity) is NGINX Plus territory and out of scope for the homelab build.

Done When​

βœ” Traefik disabled and removed from kube-system
βœ” F5 NGINX Ingress controller pod Running in ingress-nginx
βœ” MetalLB assigned a 10.0.0.x IP to the controller's LoadBalancer Service
βœ” At least one app reachable via http://<host>.<ip>.nip.io/