How to Install the NGINX Ingress Controller on Bare-Metal Kubernetes

Written by: Bagus Facsi Aginsa
Published at: 18 Jun 2026


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:

  1. A request arrives at the controller’s external IP (handed out by MetalLB).
  2. The controller looks at the Host header and the URL path.
  3. It matches that against your Ingress rules.
  4. 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 kubectl configured and able to reach it. Confirm with kubectl get nodes.
  • MetalLB installed and working, with a free IP pool configured. The controller needs a LoadBalancer service to get an external IP, and on bare metal that means MetalLB.
  • Helm 3 installed. Check with helm version and confirm it starts with v3.
  • A way to map hostnames to the controller’s IP. In production this is real DNS. For testing, editing your local /etc/hosts file 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: nginx tells Kubernetes which controller should handle this Ingress. The ingress-nginx chart registers an IngressClass named nginx. If you skip this line, no controller may claim the Ingress and nothing happens. This is a frequent beginner mistake.
  • Each host block is a separate routing rule. The controller inspects the incoming Host header and matches it here.
  • pathType: Prefix with path: / means “match everything under this host.” For more granular routing you could add multiple paths per host, such as /api and /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.