Kubernetes Ingress on bare metal¶
Ingress is the thing that gets traffic into your cluster cleanly — one entry point, many apps, route by domain or path. In the cloud the load balancer side of that is automatic. On bare metal it isn't; you have to put your own load balancer in front. This guide walks through the whole setup end-to-end: HAProxy as the front-end, the NGINX Ingress Controller inside the cluster, and an Ingress rule that routes a real domain to a demo web-app.
Why Ingress exists (and why bare metal needs an extra piece)¶
The naive way to expose a Kubernetes app to the outside world is a Service of type LoadBalancer. In a cloud environment Kubernetes calls the cloud's API, the cloud spins up a managed load balancer with a public IP, and traffic flows: client → DNS → cloud LB → NodePort → Service → Pod.
The problem: one app, one cloud load balancer. Deploy ten apps, pay for ten load balancers. Deploy a hundred, pay for a hundred. That gets expensive fast — and it's also operationally annoying, because every new app needs new DNS, new TLS, and a new bill line.
Ingress is the answer. Instead of one cloud LB per app, you run a single Ingress Controller inside the cluster. The cloud LB points at the controller; the controller reads a set of Ingress rules and routes each request to the right Service based on the hostname or URL path in the request. You add a new app? You add a new Ingress rule. No new load balancer, no new DNS, just YAML.
┌─────────────────────────────┐
│ Kubernetes cluster │
│ │
client ──┐ │ ┌──────────────────────┐ │
│ web-app.example.com │ │ Ingress Controller │ │
▼ │ │ (reads rules) │ │
DNS ──► Load Balancer ────►│ └─────────┬────────────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ ▼ ▼ │
│ webapp svc videoapp svc │
│ │ │ │
│ ▼ ▼ │
│ webapp pod videoapp pod │
└─────────────────────────────┘
On a cloud the Load Balancer in that diagram appears automatically when you create the Ingress Service. On bare metal it doesn't. You have to provide it yourself — usually with HAProxy or Nginx on a separate host that round-robins traffic to your worker nodes. That's what we're building.
What you'll build¶
- An HAProxy load balancer on a separate VM (or bare-metal host), acting as the single entry point
- NGINX Ingress Controller running inside the cluster as two replicas across the workers
- A demo web-app (Nginx) deployed in its own namespace with two replicas
- An Ingress rule that routes
web-app.distrodomain.comto the web-app's Service - Local DNS faked via
/etc/hostsso we can hit the domain from a workstation without owning real DNS
Prerequisites¶
- A working Kubernetes cluster with at least two worker nodes — if you don't have one, the Install Kubernetes on Rocky Linux guide gets you there.
- One additional VM or host for the HAProxy load balancer. Doesn't need much — 1 core, 1GB RAM, 10GB disk is plenty.
kubectlconfigured to talk to your cluster (from the previous guide's Phase 5kubeuser setup).
About the IPs in this guide
The video uses these example IPs:
- Load balancer:
192.168.0.36 - worker01:
192.168.0.109 - worker02:
192.168.0.111
Substitute your own throughout. If you followed the Rocky Linux guide your workers will be on .164 and .167 instead.
Phase 1 — Set up the HAProxy load balancer¶
This is a fresh host that sits in front of the cluster.
Disable SELinux and the firewall¶
For a learning setup we want as few moving parts as possible. Production load-balancer hosts should run with both enabled, but properly configured — that's a separate guide.
# Disable SELinux
vim /etc/selinux/config
# (set SELINUX=disabled)
# Disable firewalld
systemctl disable --now firewalld
# Reboot so the SELinux change applies cleanly
reboot
After reboot, verify:
Install HAProxy¶
Configure HAProxy¶
Back up the shipped config and replace its frontend/backend section with our own. Keep the global and defaults blocks at the top of the file as they are — HAProxy needs them. Comment out (or delete) the shipped frontend main and backend app blocks at the bottom.
Append at the bottom of the file:
frontend http_in
bind *:80
mode http
default_backend http_back
backend http_back
balance roundrobin
server worker1 192.168.0.109:31034 check inter 2s fall 3
server worker2 192.168.0.111:31034 check inter 2s fall 3
About that port 31034
This is a NodePort that the Ingress Controller will eventually expose — but it doesn't exist yet, because we haven't deployed the controller. Use any value here for now (e.g., 31034). We'll come back and update it to the real NodePort in Phase 6.
Enable and start the service:
active (running) is what you want. The backend will show "no server available" until the cluster side is up — that's expected.
Phase 2 — Fake DNS via /etc/hosts¶
In real life you'd add a DNS A record pointing web-app.distrodomain.com at the HAProxy IP. For testing, we'll add a line to /etc/hosts on the workstation we're testing from. Same effect, no DNS provider needed.
On your workstation (Mac, Linux, or Windows — adjust the path for Windows):
Add:
Verify:
You should resolve to the HAProxy IP.
Phase 3 — Deploy the NGINX Ingress Controller¶
There are several Ingress Controller implementations. We're using the community edition of NGINX Ingress Controller (ingress-nginx) — it's the most widely used, well-documented, and the one most cloud guides assume.
On the cluster:
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.11.2/deploy/static/provider/baremetal/deploy.yaml
That manifest deploys a namespace, an IngressClass, RBAC, a Deployment, and a Service of type NodePort (the bare-metal variant doesn't try to provision a cloud LB).
Watch it come up:
Wait until the ingress-nginx-controller-* pod is 1/1 Running. Ctrl-C to exit watch.
Scale to two replicas¶
The manifest ships with one replica, which means if the worker running it dies, your Ingress goes down too. Scale to two so each worker has one:
Search for replicas: (there'll be exactly one match) and change 1 → 2. Save and quit.
Verify:
You should see two pods, one on each worker.
Phase 4 — Deploy a demo web-app¶
We need something to route traffic to. Create web-app.yml:
---
apiVersion: v1
kind: Namespace
metadata:
name: web-app
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-app
namespace: web-app
labels:
app.kubernetes.io/name: web-app
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: web-app
template:
metadata:
labels:
app.kubernetes.io/name: web-app
spec:
containers:
- name: web-app
image: nginx
---
apiVersion: v1
kind: Service
metadata:
name: web-app
namespace: web-app
labels:
app.kubernetes.io/name: web-app
spec:
selector:
app.kubernetes.io/name: web-app
ports:
- name: http
port: 80
protocol: TCP
targetPort: 80
Apply it:
Two pods, one Service of type ClusterIP. ClusterIP means the Service is only reachable from inside the cluster — which is fine, because the Ingress Controller (which is inside the cluster) will be the one talking to it.
Phase 5 — Create the Ingress rule¶
This is where you tell the Ingress Controller: "when a request comes in for web-app.distrodomain.com, send it to the web-app service on port 80."
Create ingress-rule.yml:
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-app-ingress
namespace: web-app
labels:
app.kubernetes.io/name: web-app
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: web-app.distrodomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-app # must match the Service name from Phase 4
port:
number: 80 # must match the Service port from Phase 4
ingressClassName vs the old annotation
Older guides use kubernetes.io/ingress.class: nginx as an annotation. That's been deprecated since Kubernetes 1.22 in favor of spec.ingressClassName. Use the new way; you only need both if you're supporting really old clusters.
Apply:
Look for Address: in the output — it should list the worker IPs once the controller has reconciled. That means the rule is live in the controller.
Phase 6 — Point HAProxy at the real Ingress NodePort¶
In Phase 1 we put a placeholder port in the HAProxy backend. Now we need the real NodePort:
You'll see something like:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
ingress-nginx-controller NodePort 10.108.x.x <none> 80:31646/TCP,443:31528/TCP
The 31646 is the NodePort mapped to the controller's port 80. Yours will be different — copy yours.
Back on the load balancer:
Change the port in both server lines from the placeholder to the real one:
backend http_back
balance roundrobin
server worker1 192.168.0.109:31646 check inter 2s fall 3
server worker2 192.168.0.111:31646 check inter 2s fall 3
Restart HAProxy:
The backend status should change from "no server available" to both workers being UP within a few seconds.
Phase 7 — Moment of truth¶
From your workstation:
…or open http://web-app.distrodomain.com in a browser. You should see the default Nginx welcome page.
That means every piece worked: DNS (faked) → HAProxy → worker NodePort → ingress-nginx-controller pod → web-app Service → web-app pod. Every layer of the stack we just built, end-to-end.
The full request path, traced¶
When you hit http://web-app.distrodomain.com in the browser, this is what actually happens:
1. client browser issues GET /
│
2. hostname resolved via /etc/hosts (or real DNS)
│ → 192.168.0.36
▼
3. HAProxy on 192.168.0.36:80
│ round-robin to one of the workers
▼
4. worker0X:31646 (NodePort of ingress-nginx-controller service)
│ kube-proxy routes to a controller pod
▼
5. ingress-nginx-controller pod
│ reads ingress rule: host=web-app.distrodomain.com → service=web-app
▼
6. web-app Service (ClusterIP, port 80)
│ round-robin to one of its endpoints
▼
7. web-app pod (nginx) → serves HTTP 200 + default welcome page
Every Ingress request follows this path. The only thing that changes per app is step 5 — different Ingress rules route different hostnames (or paths) to different Services. Add a new app and you add a new rule; you don't add anything in front of the cluster.
What's next¶
You now have the bare-metal Ingress stack working end-to-end. Real-world next steps:
- TLS via
cert-manager+ Let's Encrypt — turnhttp://intohttps://automatically, no manual cert handling. Guide coming. - Path-based routing —
web-app.distrodomain.com/api/to one service,/admin/to another, all on the same hostname. Just morepaths:entries in the rule. - Real DNS — once you're done testing with
/etc/hosts, point a real A record at the HAProxy IP. - HA HAProxy — a single load balancer is a single point of failure. Run two with
keepalivedand a floating VIP, or use BGP-based options like MetalLB.
Got stuck?¶
Two fastest places to get help:
- Distro Domain Discord — drop the error, your YAML, or a
kubectl describe ingressoutput and someone will dig in. Real humans, no bots. - Comments on the YouTube video — I read every one.
Common failure modes:
- Ingress shows
Address: <none>after several minutes — usually means the controller can't reconcile because theingressClassNamedoesn't match. Checkkubectl get ingressclassand confirmnginxis listed. curlreturns connection refused — HAProxy isn't running, or its backend port doesn't match the controller's real NodePort. Re-check Phase 6.curlreturns503 Service Temporarily Unavailable— the Ingress rule exists but the controller can't find a healthy backend Pod. Checkkubectl get pods -n web-appforRunning.curlreturns the wrong Nginx page — you're hitting the Ingress Controller's default backend, not your app. Means theHost:header doesn't match the rule. Re-verify/etc/hostsand thehost:value in your Ingress rule match exactly.