Once you have a working Kubernetes cluster and a way to get external IPs onto your services, you quickly hit a new problem: IP addresses do not scale. If every app needs its own LoadBalancer service, you burn one IP per app. Ten apps means ten IPs, ten DNS records, and a mess of ports to remember. That is wasteful, and on a homelab or small bare-metal cluster you probably do not have ten spare IPs to give away in the first place.
An Ingress controller fixes this. It lets you put a single entry point in front of your cluster and route traffic to many different services based on the hostname or URL path in the request. One IP, one load balancer, dozens of apps behind it. You point app1.example.com and app2.example.com at the same address, and the controller figures out where each request should go.
In this tutorial you will install the ingress-nginx controller (the official Kubernetes-maintained one, not to be confused with NGINX Inc.’s commercial product) on a bare-metal cluster using Helm. Then you will deploy two small apps and route to both of them by hostname through a single external IP, and finally add TLS so the whole thing serves HTTPS.
This guide is for developers, sysadmins, and DevOps engineers running self-hosted Kubernetes. It builds directly on two earlier posts: installing MetalLB so the controller can get a real external IP, and getting started with Helm, which is the cleanest way to install the controller. If you have not set those up yet, do that first.
Conceptual Overview
Let us get the vocabulary straight, because Kubernetes uses two similar-sounding words for two very different things.
An Ingress is a Kubernetes object. It is a set of routing rules written in YAML, things like “send requests for shop.example.com to the shop service on port 80.” On its own, an Ingress object does nothing. It is just a description of what you want to happen.
An Ingress controller is the actual program that reads those Ingress objects and makes them real. The ingress-nginx controller runs NGINX inside a pod, watches the Kubernetes API for Ingress objects, and rewrites its NGINX configuration on the fly to match. When you create an Ingress rule, the controller notices within seconds and starts routing accordingly.
So the flow looks like this:
- A request arrives at the controller’s external IP (handed out by MetalLB).
- The controller looks at the
Hostheader and the URL path. - It matches that against your Ingress rules.
- It forwards the request to the right internal Service, which sends it to a pod.
This is why one IP can serve many apps. The routing decision happens at the hostname and path level, inside the controller, long after the packet has already arrived. If you want a deeper comparison of Ingress versus the newer approach, see my post on the Kubernetes Gateway API vs Ingress. For this guide, classic Ingress is exactly what you want, because it is mature, widely supported, and works everywhere.
Prerequisites
Before starting, make sure you have:
- A running Kubernetes cluster (kubeadm, k3s, or similar) with
kubectlconfigured and able to reach it. Confirm withkubectl get nodes. - MetalLB installed and working, with a free IP pool configured. The controller needs a
LoadBalancerservice to get an external IP, and on bare metal that means MetalLB. - Helm 3 installed. Check with
helm versionand confirm it starts withv3. - A way to map hostnames to the controller’s IP. In production this is real DNS. For testing, editing your local
/etc/hostsfile is enough, and I will show that. - Basic comfort with the Linux command line and editing YAML.
In this guide the cluster lives on the 192.168.1.0/24 subnet, and MetalLB hands out addresses from 192.168.1.240 to 192.168.1.250. Adjust these to match your own network.
Quick sanity check that your tooling works:
kubectl get nodes
helm version
Both should succeed before you continue.
Step 1: Add the ingress-nginx Helm Repository
The ingress-nginx project publishes an official Helm chart, which is the maintained, recommended way to install it. Add the repo and refresh your local cache:
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
You can confirm the chart is available:
helm search repo ingress-nginx/ingress-nginx
NAME CHART VERSION APP VERSION DESCRIPTION
ingress-nginx/ingress-nginx 4.11.2 1.11.2 Ingress controller for Kubernetes...
As always with Helm, remember the two version columns mean different things. The chart version is the package version, the app version is the NGINX controller version inside it.
Step 2: Install the Controller
We will install the controller into its own namespace to keep it isolated from your apps. The one setting that matters most on bare metal is making the controller’s service a LoadBalancer, so MetalLB gives it an external IP.
helm install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx \
--create-namespace \
--set controller.service.type=LoadBalancer
The --create-namespace flag saves you a separate kubectl create namespace step. The --set controller.service.type=LoadBalancer line is the important one. On a cloud provider this is the default anyway, but being explicit never hurts, and it documents intent.
Give the controller a moment to start, then watch its pods:
kubectl get pods -n ingress-nginx
NAME READY STATUS RESTARTS AGE
ingress-nginx-controller-6d8f9c7b5d-x8k2p 1/1 Running 0 40s
Wait until the controller pod reports Running and 1/1. Now check the service, which is where MetalLB comes in:
kubectl get svc -n ingress-nginx
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
ingress-nginx-controller LoadBalancer 10.96.215.40 192.168.1.240 80:31234/TCP,443:30912/TCP 55s
That EXTERNAL-IP of 192.168.1.240 is the single address every app will share. If it shows <pending> instead, MetalLB is not assigning IPs, and you should revisit the MetalLB guide before going further. This is the most common stumbling block, and it is almost always a MetalLB configuration issue, not an ingress-nginx one.
Make a note of that IP. We will route everything through it.
Step 3: Deploy Two Test Apps
To prove that hostname routing works, we need two distinct apps so we can tell which one answered. We will deploy two tiny web servers that each return a different message.
Create a file named apps.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: apple-app
spec:
replicas: 1
selector:
matchLabels:
app: apple
template:
metadata:
labels:
app: apple
spec:
containers:
- name: app
image: hashicorp/http-echo
args:
- "-text=Hello from the APPLE app"
ports:
- containerPort: 5678
---
apiVersion: v1
kind: Service
metadata:
name: apple-service
spec:
selector:
app: apple
ports:
- port: 80
targetPort: 5678
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: banana-app
spec:
replicas: 1
selector:
matchLabels:
app: banana
template:
metadata:
labels:
app: banana
spec:
containers:
- name: app
image: hashicorp/http-echo
args:
- "-text=Hello from the BANANA app"
ports:
- containerPort: 5678
---
apiVersion: v1
kind: Service
metadata:
name: banana-service
spec:
selector:
app: banana
ports:
- port: 80
targetPort: 5678
The hashicorp/http-echo image is a tiny server that just returns whatever text you give it, which makes it perfect for this kind of test. Each app listens on container port 5678, and each Service exposes it on port 80 internally.
Apply it:
kubectl apply -f apps.yaml
Confirm both pods are running:
kubectl get pods
NAME READY STATUS RESTARTS AGE
apple-app-7d9f8c6b4d-2xk9p 1/1 Running 0 20s
banana-app-6c8d7f5b9c-mn4qt 1/1 Running 0 20s
Note that these are plain ClusterIP services. They are not exposed outside the cluster at all. The whole point is that the Ingress controller, not the services, will be the public entry point.
Step 4: Create the Ingress Rules
Now the interesting part. We will tell the controller to route apple.example.com to the apple service and banana.example.com to the banana service.
Create ingress.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: fruit-ingress
spec:
ingressClassName: nginx
rules:
- host: apple.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: apple-service
port:
number: 80
- host: banana.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: banana-service
port:
number: 80
A few things deserve explanation:
ingressClassName: nginxtells Kubernetes which controller should handle this Ingress. The ingress-nginx chart registers an IngressClass namednginx. If you skip this line, no controller may claim the Ingress and nothing happens. This is a frequent beginner mistake.- Each
hostblock is a separate routing rule. The controller inspects the incomingHostheader and matches it here. pathType: Prefixwithpath: /means “match everything under this host.” For more granular routing you could add multiple paths per host, such as/apiand/web.
Apply it:
kubectl apply -f ingress.yaml
Check that the Ingress was accepted and picked up the controller’s address:
kubectl get ingress
NAME CLASS HOSTS ADDRESS PORTS AGE
fruit-ingress nginx apple.example.com,banana.example.com 192.168.1.240 80 30s
That ADDRESS column showing the MetalLB IP is a good sign. It means the controller has accepted the rules and is ready to route.
Step 5: Test the Routing
Since apple.example.com and banana.example.com are not real domains, we need to make our test machine resolve them to the controller’s IP. The simplest way is to edit /etc/hosts:
echo "192.168.1.240 apple.example.com banana.example.com" | sudo tee -a /etc/hosts
Now make requests to each hostname:
curl http://apple.example.com
Hello from the APPLE app
And the other one:
curl http://banana.example.com
Hello from the BANANA app
Two different responses from the same IP address. That is the entire value of an Ingress controller in one demo. The controller read the Host header on each request and sent it to the correct backend service.
If you want to confirm it really is the hostname doing the work, try hitting the IP directly without a hostname:
curl http://192.168.1.240
404 Not Found
The controller has no rule for a request with no matching host, so it returns its default 404 backend. That is expected behavior, not an error.
Step 6: Add TLS for HTTPS
Serving plain HTTP is fine for a demo, but real services need HTTPS. The ingress-nginx controller terminates TLS for you, meaning it handles the encryption so your backend apps can stay simple HTTP. You give it a certificate stored as a Kubernetes Secret, and it does the rest.
For a quick test we will generate a self-signed certificate. In production you would use a trusted certificate, most commonly issued automatically by cert-manager and Let’s Encrypt, but a self-signed cert proves the mechanism works.
Generate the cert and key for apple.example.com:
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout tls.key -out tls.crt \
-subj "/CN=apple.example.com/O=apple"
Store it as a TLS Secret in the cluster:
kubectl create secret tls apple-tls \
--cert=tls.crt --key=tls.key
If you want a refresher on how secrets work, see my guide on creating Kubernetes secrets.
Now update the Ingress to use it. Edit ingress.yaml and add a tls section to the spec, just above rules:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: fruit-ingress
spec:
ingressClassName: nginx
tls:
- hosts:
- apple.example.com
secretName: apple-tls
rules:
- host: apple.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: apple-service
port:
number: 80
- host: banana.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: banana-service
port:
number: 80
Apply the change:
kubectl apply -f ingress.yaml
Now test over HTTPS. Because the certificate is self-signed, curl will not trust it, so we pass -k to skip verification for this test:
curl -k https://apple.example.com
Hello from the APPLE app
You are now serving the app over TLS. With a real certificate from cert-manager, you would drop the -k and browsers would show the padlock with no warnings.
Common Mistakes and Troubleshooting
The controller service stays <pending>. The controller never gets an external IP. This is a MetalLB problem, not an ingress-nginx one. Confirm MetalLB pods are running and that you applied both an IPAddressPool and an L2Advertisement. The MetalLB guide covers this in detail.
Every request returns 404. First check that your Ingress has ingressClassName: nginx. Without it, the controller ignores your rules entirely. Second, confirm the Host header actually matches a rule. A curl to the bare IP returns 404 by design, because there is no hostname to match. Always test with the hostname.
502 Bad Gateway. The controller found a matching rule but could not reach the backend. Usually the Service name or port in the Ingress does not match the actual Service, or the backend pods are not ready. Check kubectl get endpoints apple-service. If it shows no endpoints, the Service selector does not match any running pods.
The hostname does not resolve. If curl says “Could not resolve host,” your DNS or /etc/hosts entry is missing or wrong. Double-check the IP and spelling in /etc/hosts.
Reading the controller logs. When in doubt, the controller logs tell you exactly what it is doing with each request:
kubectl logs -n ingress-nginx -l app.kubernetes.io/component=controller
This shows incoming requests, matched rules, and any configuration reload errors. It is the first place to look when routing behaves unexpectedly.
Best Practices
A few habits will keep your Ingress setup clean and production-ready.
Always set ingressClassName. It is technically optional in some setups, but being explicit avoids ambiguity, especially if you ever run more than one controller in a cluster.
Use cert-manager for real certificates. Self-signed certs are fine for testing, but in production you want automatic, trusted certificates. Cert-manager integrates with Ingress to request and renew Let’s Encrypt certificates for you, with no manual openssl steps. This is the standard pattern and well worth setting up next.
Pin the chart version in production. Install with --version so a future helm upgrade does not silently pull a new controller with changed behavior. As I covered in the Helm guide, reproducibility beats convenience.
Set resource requests and limits on the controller. The controller handles all your inbound traffic, so it deserves guaranteed resources. The chart accepts these through values like controller.resources.
Run more than one controller replica for high availability. A single controller pod is a single point of failure. In production, set controller.replicaCount to 2 or more and use a LoadBalancer IP that survives a node failure.
Keep apps as ClusterIP. Your backend services should stay ClusterIP and never be exposed directly. The controller is your one front door, which keeps your attack surface small and your IP usage minimal.
Group related routes in one Ingress, but split unrelated apps. It is fine to put several hosts in a single Ingress object, but for unrelated teams or apps, separate Ingress objects keep ownership and changes clean.
Conclusion
You have installed the ingress-nginx controller on a bare-metal Kubernetes cluster with Helm, gave it a single external IP through MetalLB, and routed two separate apps by hostname through that one address. Then you added TLS so the controller serves HTTPS and terminates encryption on behalf of your backends.
This is the pattern nearly every real Kubernetes deployment uses. Instead of one IP per service, you get one front door for the entire cluster, with clean hostname and path routing behind it. It is efficient, it scales to dozens of apps, and it is exactly how managed Kubernetes platforms expose workloads too.
From here, the natural next steps are: install cert-manager to issue and auto-renew real Let’s Encrypt certificates so you can drop the self-signed workaround, explore path-based routing to serve multiple apps under one hostname, and look into annotations that tune the controller’s behavior, such as rate limiting, request size limits, and custom timeouts. Once the Ingress controller is in place, the rest of your platform starts to feel like a proper hosting environment, which is exactly the goal.