click the screen · press Enter
← back to blog
Kubernetes Journey · Topic 15

Kubernetes Ingress: One URL and One Cert for Many Services

Kubernetes Ingress explained - Kubernetes Journey

You've built an app, shipped it into the cluster, and now you want people to actually reach it. Not at http://34.12.9.5:38080 like a hostage note, but at myshop.com, over HTTPS, like a real website. And when you add a second app next month, you really don't want to pay for a second cloud load balancer just to route a URL. That whole problem, one clean front door for many services, is what Ingress solves. This is the topic that finally made Kubernetes networking feel usable to me.

I'll build up to it the way it actually clicked: start with how you expose an app, hit the wall, then let Ingress solve it. What, how, why, takeaway, as always.

Getting to the wall: how you expose an app

Quick recap, because Ingress only makes sense once you feel the pain it removes. There are three ways to expose an app, and each is a step up:

ClusterIP

Internal only. Great for a database or backend that other pods talk to, but nobody outside can reach it.

NodePort

Opens a high port (like 38080) on every node, so users hit node-IP:38080. It works, but the port is ugly and you're handing out raw node IPs.

LoadBalancer

In the cloud, Kubernetes asks the provider for a real external load balancer with its own public IP. Clean, but you get (and pay for) one per service.

This was Topic 12 territory. Each step is nicer, but none of them solves the multi-app problem.

Now the wall. Say your shop lives at myshop.com/wear, and you add a video section at myshop.com/watch. They're separate deployments with separate services. With the tools above, each needs its own load balancer, its own public IP, and its own SSL certificate to manage. Two apps, two load balancers. Ten apps, ten. That's expensive, fiddly, and there's no single place to say "route /wear here and /watch there". You need something that sits out front and makes routing decisions based on the URL.

What Ingress actually is

Ingress is a single entry point that routes incoming HTTP requests to different services based on the URL path or the hostname, terminating SSL along the way. That's what separates it from a plain LoadBalancer, which works at the network level: it forwards a port and understands nothing about URLs. Ingress works at layer 7, meaning it reads the actual HTTP request, the host and the path, so it can make routing decisions on what it sees. Think of it as a reverse proxy (NGINX, HAProxy, Traefik) that you configure with normal Kubernetes objects instead of hand-editing config files.

What you get out of it is one public IP, one place for SSL certificates, and all your URL routing in version-controlled YAML. Before Ingress you'd deploy and babysit those reverse proxies yourself. Now Kubernetes runs the proxy and you just declare the rules.

One door, routed by URL
INGRESS (one IP, one cert)
myshop.com/wear → wear-service
myshop.com/watch → watch-service
myshop.com/support → support-service
no match → default backend (404)
The request comes in once, Ingress reads the path or host, and forwards it to the right internal service.

The two parts everyone conflates

This is the bit that confused me at first, so let me split it cleanly. "Ingress" is really two things:

Ingress controller

The actual proxy doing the work, a real program (ingress-nginx, Traefik, HAProxy, Istio) running as pods. It watches the cluster for rules and configures itself.

Ingress resource

Just the routing rules, written as a Kubernetes object (kind: Ingress). It's data, not a program. It does nothing on its own.

Controller = the engine. Resource = the instructions. You need both.
The gotcha that wastes an afternoon Kubernetes does not ship an Ingress controller by default. If you create an Ingress resource on a bare cluster, it's accepted and then completely ignored, no error, no routing, nothing. You have to install a controller first (ingress-nginx is the usual pick). This is the same "accepted but silently does nothing" trap as the network policy CNI issue from last time. Kubernetes loves those.

Deploying the controller (the shape of it)

You normally install a controller from its official manifest or a Helm chart rather than hand-writing it, but it helps to know what's inside, because it's just Kubernetes objects you already know:

  • A Deployment running the controller image (the NGINX proxy that watches for Ingress rules).
  • A Service (NodePort or LoadBalancer) to actually expose the controller to the outside world on ports 80 and 443.
  • A ConfigMap to hold proxy settings (timeouts, SSL options, log format) separately from the image.
  • A ServiceAccount (plus RBAC) so the controller has permission to read Ingress resources across the cluster.
A note on old examples If you follow older tutorials you'll see apiVersion: extensions/v1beta1 and backend.serviceName. That syntax is removed in modern Kubernetes. The current API is networking.k8s.io/v1, which is what I'm using below. If your YAML errors on a recent cluster, this mismatch is usually why.

Routing by path: one host, many services

Here's the first real Ingress resource. One hostname, and the path decides which service gets the request. Note the modern networking.k8s.io/v1 shape: each path has a pathType and points at a service by name and port number.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: store-ingress
spec:
  ingressClassName: nginx        # which controller handles this
  rules:
  - http:
      paths:
      - path: /wear
        pathType: Prefix
        backend:
          service:
            name: wear-service
            port:
              number: 80
      - path: /watch
        pathType: Prefix
        backend:
          service:
            name: watch-service
            port:
              number: 80

Reading it plainly: any request to this ingress starting with /wear goes to wear-service, and /watch goes to watch-service, both on port 80. One rule, two paths, one public IP out front. Apply it with kubectl apply -f and check it landed:

kubectl describe ingress store-ingress
Name: store-ingress Rules: Host Path Backends ---- ---- -------- * /wear wear-service:80 * /watch watch-service:80 # Host is * because we didn't pin a hostname, any host, routed by path
kubectl describe ingress shows exactly how traffic will be split. Read it before you trust it.

Routing by host: many domains, one ingress

The other way to split traffic is by hostname instead of path. Same idea, but now wear.myshop.com and watch.myshop.com are separate rules, each sending everything to its own service:

spec:
  ingressClassName: nginx
  rules:
  - host: wear.myshop.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: wear-service
            port:
              number: 80
  - host: watch.myshop.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: watch-service
            port:
              number: 80

So it comes down to how you want to carve things up:

Path routing vs host routing
 Path-basedHost-based
Splits onthe URL path (/wear, /watch)the hostname (wear.x.com)
Looks likemyshop.com/wearwear.myshop.com
Rulesone rule, many pathsone rule per host
Needsone DNS recorda DNS record per subdomain
Same engine, two ways to decide. You can even mix them, paths under each host.

One more piece: the default backend. If a request matches no rule (someone hits myshop.com/listen and you never defined it), you can point spec.defaultBackend at a service that serves a friendly 404 instead of a blank error. Small thing, nice touch.

Try it: a 5-minute routing lab

Enough theory, let's route real traffic. You need Docker plus minikube (which bundles an ingress addon), or kind. Everything runs locally.

Step 1. Cluster + controller. minikube can switch the controller on with one command, no manifests to hunt down:

minikube start
minikube addons enable ingress     # installs ingress-nginx for you

Step 2. Two tiny apps. Deploy two demo web servers and expose each as a service:

kubectl create deployment wear --image=hashicorp/http-echo -- -text="WEAR"
kubectl create deployment watch --image=hashicorp/http-echo -- -text="WATCH"
kubectl expose deployment wear --port=5678
kubectl expose deployment watch --port=5678

Step 3. The Ingress. Apply a path-based rule sending /wear and /watch to each service (using port 5678, what http-echo listens on). Step 4. Verify. Curl the ingress with each path and watch it route:

route by path, proven with curl
$ IP=$(minikube ip) $ curl http://$IP/wear WEAR $ curl http://$IP/watch WATCH # same IP, same port, different path -> different service. that's ingress.
Verify step: one address, and the path alone decides which app answers. If both return their label, it works.
Gotcha I hit Two things. If curl hangs, the ingress controller pod probably isn't ready yet, check kubectl get pods -n ingress-nginx. And for host-based rules on a lab with no real DNS, you fake the hostname with curl -H "Host: wear.myshop.com" http://$IP/ so the controller sees the host header.

The security angle

As a pentester, the ingress is the bit I'd look at hard, because it's literally the front door to everything. A few things worth keeping in mind. It terminates TLS, so your certificates and private keys live here, guard them. Path rules can be got wrong: a sloppy / catch-all or a path that accidentally exposes an internal admin service is a real finding. And the controller is software like anything else, ingress-nginx has had its share of CVEs, so it's on the patch list too. Ingress is powerful, and power at the front door is exactly where you want to be careful.

Defender checklist Only route paths you mean to expose (no accidental catch-alls to internal services). Keep the ingress controller patched. Terminate TLS and redirect HTTP to HTTPS. And pair ingress with network policies so that even if someone reaches a service through the ingress, it can't freely talk to everything else.

When the networking chain joined up

This is the one where Kubernetes networking stopped feeling like a pile of disconnected objects. The chain finally joins up: a pod runs the app, a service gives it a stable internal address, and ingress puts a single clean, routed, HTTPS front door on top of it all. The split that took me a beat to get was controller versus resource, the rules are just data, and nothing happens until a controller is there to act on them. I've done the minikube version but not wired up real DNS and Let's Encrypt certs on a live domain yet, so that's my next weekend job, and I'm sure there's TLS-annotation pain waiting for me. Next in the series I want to look at securing the ingress properly, TLS, and where it fits with everything else. If you've run ingress-nginx in anger, tell me what bit you first.

If this made ingress click, come say hi on LinkedIn or the contact page, and tell me what to break down next. More in the Kubernetes Journey.

Further reading

FAQ

What is Kubernetes Ingress?

Ingress is a Kubernetes object that gives you one external entry point which routes HTTP traffic to different services by URL path or hostname, and handles SSL. It is a layer-7 (HTTP-aware) load balancer configured with native Kubernetes objects instead of many separate cloud load balancers.

What is the difference between an Ingress controller and an Ingress resource?

The Ingress controller is the actual proxy that does the routing, like NGINX or Traefik, running as pods in the cluster. The Ingress resource is just the routing rules written as a Kubernetes object. Rules do nothing without a controller to read and enforce them.

Why is my Kubernetes Ingress not working?

The most common reason is that no Ingress controller is installed. Kubernetes does not ship one by default, so an Ingress resource on its own is ignored. Install a controller such as ingress-nginx, and make sure your Ingress references the right ingressClassName.