Skip to content

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.com to the web-app's Service
  • Local DNS faked via /etc/hosts so 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.
  • kubectl configured to talk to your cluster (from the previous guide's Phase 5 kube user 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:

getenforce      # should say Disabled
systemctl status firewalld   # should be inactive (dead)

Install HAProxy

dnf install -y 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.

cp /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.bkp
vim /etc/haproxy/haproxy.cfg

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:

systemctl enable --now haproxy
systemctl status haproxy

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):

sudo vim /etc/hosts

Add:

192.168.0.36   web-app.distrodomain.com

Verify:

ping -c 1 web-app.distrodomain.com

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:

kubectl get pods -n ingress-nginx -w

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:

kubectl edit deployment ingress-nginx-controller -n ingress-nginx

Search for replicas: (there'll be exactly one match) and change 12. Save and quit.

Verify:

kubectl get pods -n ingress-nginx -o wide

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:

kubectl apply -f web-app.yml
kubectl get all -n web-app -o wide

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:

kubectl apply -f ingress-rule.yml
kubectl describe ingress web-app-ingress -n web-app

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:

kubectl get service -n ingress-nginx

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:

vim /etc/haproxy/haproxy.cfg

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:

systemctl restart haproxy
systemctl status 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:

curl http://web-app.distrodomain.com

…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 — turn http:// into https:// automatically, no manual cert handling. Guide coming.
  • Path-based routingweb-app.distrodomain.com/api/ to one service, /admin/ to another, all on the same hostname. Just more paths: 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 keepalived and 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 ingress output 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 the ingressClassName doesn't match. Check kubectl get ingressclass and confirm nginx is listed.
  • curl returns connection refused — HAProxy isn't running, or its backend port doesn't match the controller's real NodePort. Re-check Phase 6.
  • curl returns 503 Service Temporarily Unavailable — the Ingress rule exists but the controller can't find a healthy backend Pod. Check kubectl get pods -n web-app for Running.
  • curl returns the wrong Nginx page — you're hitting the Ingress Controller's default backend, not your app. Means the Host: header doesn't match the rule. Re-verify /etc/hosts and the host: value in your Ingress rule match exactly.