Exposing Apps with Kubernetes Ingress Link to heading
Imagine you’ve built a small HTTP API. Nothing fancy: it returns the hostname of the pod that served the request and a running hit counter it keeps in Redis. Locally it works great. You containerize it, push it to a registry, and deploy it to your Kubernetes cluster. The pods come up, the logs look healthy, Redis is connected.
Then someone asks the obvious question: “Okay, what’s the URL?”
And you realize your app is running happily inside the cluster, reachable by other pods, but completely invisible to the outside world. There’s no address you can type into a browser, no hostname a teammate can curl. The app exists, but nobody can reach it.
This is the problem Ingress solves. You want http://api.hostname.test/ to land on your app, and you want the cluster to route that request by hostname (and maybe by path) to the right Service.
Ingress gives you one HTTP entry point that routes traffic into the cluster based on rules you define. In a cloud cluster, that entry point may still sit behind a cloud load balancer, but that load balancer is shared so that you do not need to create a separate one for every application Service you deploy.
What Ingress actually is Link to heading
An Ingress is a Kubernetes API object that describes rules for routing external HTTP(S) traffic to Services inside your cluster. The Ingress object itself doesn’t move a single packet. It’s a declaration: “requests for host api.hostname.test on path / should go to the hostname-app-service Service on port 7070.” It’s configuration, not a running process.
That distinction trips up almost everyone the first time, so let’s make it concrete before we touch any YAML.
Ingress object vs. Ingress controller Link to heading
Two separate things here, easy to confuse because they share a name:
- The Ingress object is the YAML you write. It’s the intent: the host, the path, the backend Service. It lives in a namespace, just like a Deployment or a Service.
- The Ingress controller is the actual program that reads those objects and does the work. It’s a real pod running in your cluster, usually a reverse proxy like NGINX. It watches the Kubernetes API for Ingress objects, translates every one it finds into its own configuration (for NGINX, literally an
nginx.conf), and reloads itself. That is the thing accepting TCP connections on ports 80 and 443 and proxying them to your pods.
So the flow is: you write an Ingress object → the controller notices it → the controller reconfigures its proxy → traffic flows. Without a controller running, your Ingress objects are just inert records. You can kubectl apply them all day and nothing will ever be routed.
In this guide we’ll be working with a local cluster bootstrapped by minikube, with the NGINX Ingress controller running as an addon. You only need to enable it once:
minikube addons enable ingress
That drops the controller into its own namespace, ingress-nginx. You can see it running:
kubectl get po -n ingress-nginx
NAME READY STATUS RESTARTS AGE
ingress-nginx-admission-create-6ddfx 0/1 Completed 0 161m
ingress-nginx-admission-patch-bf9ld 0/1 Completed 2 161m
ingress-nginx-controller-56d7c84fd4-cpt9x 1/1 Running 0 161m
The two Completed pods are one-shot Jobs that set up the admission webhook (more on that webhook later; it’s the thing that’s going to bite us). The pod that matters is ingress-nginx-controller-..., which is Running. That’s our reverse proxy.
One naming note before we continue: the examples use api.hostname.test because .test is reserved for examples and local testing.
The deployment we’re exposing Link to heading
Before the Ingress makes sense, here’s the stack it sits in front of. We apply the manifests in order, and each one builds on the last. Follow along; every command is runnable.
1. The namespace Link to heading
The whole stack lives in one dedicated namespace, hostname-app, which keeps it easy to reason about and easy to tear down in one shot.
| |
kubectl apply -f 0-namespace.yaml
2. Storage for Redis Link to heading
Redis keeps the hit counter, and we want it to survive a pod restart, so we give it a PersistentVolumeClaim.
| |
kubectl apply -f 1-persistentvolumeclaim.yaml
3. Configuration Link to heading
We split configuration across two ConfigMaps. The first holds the app’s environment variables. The second holds a redis.conf. Redis doesn’t read environment variables on its own, so we hand it a real config file and mount it.
| |
kubectl apply -f 2-configmap.yaml
4. The deployments Link to heading
Next come the two Deployments, Redis and the app. The app pulls its config in with envFrom so every key in the ConfigMap becomes an environment variable. One wrinkle: our app expects a variable named PORT, but the ConfigMap key is APP_PORT, and envFrom copies key names verbatim (it can’t rename), so we add an explicit env entry that maps APP_PORT to PORT. (Note that an explicit env entry always wins over envFrom if they ever collide on the same name.)
Note: In production, Redis is usually a StatefulSet or a managed service. Here it’s a simple Deployment so we can keep the focus on Ingress.
The app also waits for Redis to be reachable before it starts, via an init container.
| |
kubectl apply -f 3-deployment.yaml
5. The services Link to heading
We create two Services, both of type ClusterIP by default. The redis-service lets the app find Redis by name. The hostname-app-service is the one our Ingress will point at; it fronts the three app replicas on port 7070 and forwards to the container’s port 30200.
| |
kubectl apply -f 4-service.yaml
Notice these Services are of type ClusterIP; they’re only reachable from inside the cluster. That’s deliberate. We don’t want to expose each one individually. The Ingress is going to be the single front door.
Finally, the Ingress Link to heading
Here’s the object that ties it all together:
| |
The rule says: when a request comes in for api.hostname.test and the path starts with /, send it to hostname-app-service on port 7070. From there, the Service picks one of the app pods.
Three things to call out here though:
ingressClassName: nginxtells Kubernetes which controller should handle this Ingress. That matters in clusters with more than one controller, and it keeps the manifest explicit.- The Ingress lives in the
hostname-appnamespace, the same namespace as its backend Service. An Ingress can only route to Services in its own namespace; there’s no cross-namespace reference here. Get this wrong and the controller can’t find your backend. pathType: Prefixwithpath: /means “everything.” Every request to that host matches. You could add more specific paths (/api,/admin) pointing to different Services if you wanted to fan out.
Apply it:
kubectl apply -f 5-ingress.yaml
When Ingress rules collide Link to heading
I hit this while testing the manifests. Earlier I’d applied a separate Ingress, my-ingress in the default namespace, for a different app that happened to claim the same api.hostname.test host. So the moment I applied this one, the API server rejected it:
Error from server (BadRequest): error when creating "5-ingress.yaml": admission webhook "validate.nginx.ingress.kubernetes.io" denied the request: host "api.hostname.test" and path "/" is already defined in ingress default/my-ingress
My first instinct was, “but my Ingress is in hostname-app, and the other one is in default. Different namespaces, so what’s the problem?”
The problem is a subtle but important detail about how Ingress works.
The Ingress object is namespaced, but the routing it produces is cluster-wide.
Think about it from the controller’s point of view. In the default minikube setup, there’s one NGINX controller for the whole cluster. It watches Ingress objects in every namespace and merges them into a single NGINX configuration. In that merged config, a host + path pair is the routing key. If two Ingresses both claim api.hostname.test and /, a request for that host and path has no namespace attached to help the controller choose a backend.
So even though namespace isolates the two Ingress objects at the Kubernetes API level, the traffic rules they generate still share one routing table for that controller. The ingress-nginx admission webhook (remember those Completed admission Jobs from earlier?) catches the collision at apply time and rejects the duplicate before it can ever confuse the proxy.
This makes sense if you step back. A hostname is a DNS/L7 concept; it’s global by nature. Whether real DNS or a local curl --resolve rule points api.hostname.test at the controller, the incoming HTTP request carries a Host header and a path and nothing about Kubernetes namespaces. The controller has to pick one backend from that, with no room to guess. Namespaces are an internal Kubernetes API boundary; the outside HTTP world has never heard of them.
So the fix is one of two things:
Delete the conflicting Ingress if it’s leftover cruft (mine was;
my-ingressindefaultwas from an earlier experiment):kubectl delete ingress my-ingress -n default kubectl apply -f 5-ingress.yamlGive your Ingress a different host or path so there’s no collision and both can coexist.
Rule for ingress-nginx: one
host + pathmaps to exactly one Ingress for that controller. The namespace on the object doesn’t buy you a separate public routing space.
Inspecting what you created Link to heading
Once it applies cleanly, look at it:
kubectl get ingress -n hostname-app
NAME CLASS HOSTS ADDRESS PORTS AGE
hostname-ingress nginx api.hostname.test 192.168.49.2 80 30s
And for the full picture (rules, backend, events):
kubectl describe ingress hostname-ingress -n hostname-app
It’s normal for the ADDRESS column to be empty for a while, because it takes a moment for the controller to assign one. If a backend isn’t wiring up, run that same kubectl describe ingress hostname-ingress -n hostname-app again: it prints the resolved backend Service and an Events section at the bottom that usually tells you exactly what’s wrong.
Reaching it from your machine Link to heading
The Ingress is live inside the cluster, but on minikube we still need a way for traffic from our laptop to reach the controller. Two common ways to do that:
Option A: minikube tunnel
Link to heading
On macOS and Windows with the Docker driver, minikube tunnel is the simplest path: it exposes the Ingress on your host’s 127.0.0.1 on the privileged ports 80 and 443, which is why it asks for sudo:
minikube tunnel
✅ Tunnel successfully started
📌 NOTE: Please do not close this terminal as this process must stay alive for the tunnel to be accessible ...
❗ The service/ingress hostname-ingress requires privileged ports to be exposed: [80 443]
🔑 sudo permission will be asked for it.
🏃 Starting tunnel for service hostname-ingress.
Password:
Leave that terminal running. Now, since api.hostname.test isn’t real DNS, we tell curl to resolve it to localhost ourselves with --resolve. That sends the right Host header (so the Ingress matches) while pointing the actual connection at 127.0.0.1:
curl --resolve "api.hostname.test:80:127.0.0.1" -i http://api.hostname.test/
HTTP/1.1 200 OK
Date: Sat, 07 Sep 2024 01:16:00 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 70
Connection: keep-alive
X-Powered-By: Express
ETag: W/"46-NsrNpNW7srNtwa2ZnDK919NThM4"
{"hits":2,"hostName":"my-hostname-api-7c947bc45-sv84z","success":true}
There it is: a 200 OK, the JSON body from our app, hits incrementing in Redis, and hostName showing which pod served the request. Hit it a few more times and you may see the pod name change as the Service balances traffic across replicas. The -i flag prints the response headers, which is handy when you’re checking exactly what came back.
Option B: port-forward the controller pod Link to heading
If you don’t want to deal with sudo and privileged ports, you can port-forward straight to the controller pod instead. First find it:
kubectl get po -n ingress-nginx
NAME READY STATUS RESTARTS AGE
ingress-nginx-admission-create-6ddfx 0/1 Completed 0 161m
ingress-nginx-admission-patch-bf9ld 0/1 Completed 2 161m
ingress-nginx-controller-56d7c84fd4-cpt9x 1/1 Running 0 161m
Then forward a local port to that pod’s HTTP port (80):
kubectl port-forward ingress-nginx-controller-56d7c84fd4-cpt9x 3000:80 -n ingress-nginx
Forwarding from 127.0.0.1:3000 -> 80
Forwarding from [::1]:3000 -> 80
Handling connection for 3000
Then curl it the same way, just on port 3000:
curl --resolve "api.hostname.test:3000:127.0.0.1" -i http://api.hostname.test:3000/
HTTP/1.1 200 OK
Date: Sat, 07 Sep 2024 02:50:03 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 70
Connection: keep-alive
X-Powered-By: Express
ETag: W/"46-XMOEoQobl7RlbvuT3cR7Lhv072g"
{"hits":3,"hostName":"my-hostname-api-7c947bc45-9rgjb","success":true}
Either way, the key trick is --resolve: the Host header has to be api.hostname.test so the Ingress rule matches, while the connection itself goes to wherever you’ve exposed the controller. If you forget that and just curl http://127.0.0.1/, NGINX won’t find a matching rule and you’ll get a 404 from the default backend, a classic “but my app is running!” moment.
Wrapping up Link to heading
Let’s recap the mental model, because it’s the part that’s actually worth keeping:
- An Ingress object is declarative routing config: host → path → backend Service. It does nothing on its own.
- An Ingress controller is the real proxy that reads matching Ingresses and turns them into live routing.
- The object is namespaced, but the routing it generates is shared by the controller. Two Ingresses in different namespaces can still collide if they claim the same
host + path. - Your backend Service must be in the same namespace as the Ingress.
- To test a host that isn’t in DNS, use
curl --resolveto fake theHostheader while pointing at wherever you’ve exposed the controller.
Once it clicks that the Ingress is just intent and the controller is the muscle, the whole thing stops being mysterious. You write the rules; the controller makes them real.