DEV Community

Cover image for Kubernetes Cookbook Part 6: Ingress, One Front Door for Every App
FlatlinerAtheist | CLI-NERDS
FlatlinerAtheist | CLI-NERDS

Posted on Originally published at clinerds.com

Kubernetes Cookbook Part 6: Ingress, One Front Door for Every App

In Part 5 you gave every app a stable address with a Service. That works, but it leaves you with one exposed NodePort per app and a pile of random high port numbers nobody can remember. This part fixes that: you will put a single front door in front of all your apps with an Ingress, route traffic by path and by hostname, and finish by locking the door with TLS (Transport Layer Security, the encryption behind HTTPS).

What you need

  • Docker, kind, and kubectl installed (from Part 1).
  • Comfort with Pods and Services from Part 2 and Part 5.
  • A terminal where you can edit /etc/hosts (sudo access).
  • curl and openssl, which ship with macOS and most Linux distributions.

One important note up front: Ingress needs the cluster to accept traffic on ports 80 and 443. A plain kind create cluster does not forward those, so this part starts by recreating the cluster with the right port mappings.

Recipe 1: Install a front door (the ingress controller)

Problem: You have three apps and three Services of type NodePort, so users must remember localhost:31234, localhost:30877, and so on. You want one normal entry point on port 80.

Here is the key distinction that trips up every beginner: an Ingress resource is just a set of routing rules. It does nothing by itself. You also need an ingress controller — a real proxy running in the cluster that reads those rules and enforces them. We will use ingress-nginx, the controller maintained by the Kubernetes project, built on the NGINX web server.

First, delete your old cluster and create one that forwards ports 80 and 443 from your machine into the kind node:

kind delete cluster --name clinerds
Enter fullscreen mode Exit fullscreen mode

Create a file named kind-ingress.yaml:

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
    extraPortMappings:
      - containerPort: 80
        hostPort: 80
        protocol: TCP
      - containerPort: 443
        hostPort: 443
        protocol: TCP
Enter fullscreen mode Exit fullscreen mode
kind create cluster --name clinerds --config kind-ingress.yaml
Enter fullscreen mode Exit fullscreen mode

Now install the ingress-nginx controller using the manifest the project publishes specifically for kind:

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
Enter fullscreen mode Exit fullscreen mode

This creates a namespace called ingress-nginx with the controller inside it. Wait until the controller Pod is ready before doing anything else:

kubectl wait --namespace ingress-nginx \
  --for=condition=ready pod \
  --selector=app.kubernetes.io/component=controller \
  --timeout=120s
Enter fullscreen mode Exit fullscreen mode
pod/ingress-nginx-controller-7c9f8d5b4-x2kqp condition met
Enter fullscreen mode Exit fullscreen mode

How it works: The extraPortMappings tell kind to forward port 80 and 443 on your laptop to the same ports on the cluster's node container. The ingress-nginx controller runs as a Pod on that node and binds those ports. When you later create Ingress resources, the controller watches them, translates each rule into NGINX configuration, and reloads itself. So the data flow is: your browser hits localhost:80, kind forwards it to the controller, and the controller looks up its rules to decide which Service gets the request.

Variations:

  • On minikube, skip the config file entirely: minikube addons enable ingress installs the same controller, and minikube tunnel (in a separate terminal) exposes it.
  • There are other controllers (Traefik, HAProxy, cloud load balancers). The rules you write stay the same; only the controller changes.

Recipe 2: Route by path

Problem: Two small apps should live behind the same address, with /one going to the first and /two to the second.

Deploy two copies of our series demo app, each returning different text, plus a ClusterIP Service for each. Save as two-apps.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: app-one
spec:
  replicas: 1
  selector:
    matchLabels:
      app: app-one
  template:
    metadata:
      labels:
        app: app-one
    spec:
      containers:
        - name: web
          image: hashicorp/http-echo
          args:
            - "-text=hello from app one"
          ports:
            - containerPort: 5678
---
apiVersion: v1
kind: Service
metadata:
  name: app-one
spec:
  selector:
    app: app-one
  ports:
    - port: 80
      targetPort: 5678
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app-two
spec:
  replicas: 1
  selector:
    matchLabels:
      app: app-two
  template:
    metadata:
      labels:
        app: app-two
    spec:
      containers:
        - name: web
          image: hashicorp/http-echo
          args:
            - "-text=hello from app two"
          ports:
            - containerPort: 5678
---
apiVersion: v1
kind: Service
metadata:
  name: app-two
spec:
  selector:
    app: app-two
  ports:
    - port: 80
      targetPort: 5678
Enter fullscreen mode Exit fullscreen mode
kubectl apply -f two-apps.yaml
kubectl wait --for=condition=available deploy/app-one deploy/app-two --timeout=90s
Enter fullscreen mode Exit fullscreen mode

Now the Ingress with the routing rules, saved as ingress-paths.yaml:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: clinerds-demo
spec:
  ingressClassName: nginx
  rules:
    - http:
        paths:
          - path: /one
            pathType: Prefix
            backend:
              service:
                name: app-one
                port:
                  number: 80
          - path: /two
            pathType: Prefix
            backend:
              service:
                name: app-two
                port:
                  number: 80
Enter fullscreen mode Exit fullscreen mode
kubectl apply -f ingress-paths.yaml
Enter fullscreen mode Exit fullscreen mode

Give the controller a few seconds to pick up the rules, then test both paths:

curl http://localhost/one
curl http://localhost/two
Enter fullscreen mode Exit fullscreen mode
hello from app one
hello from app two
Enter fullscreen mode Exit fullscreen mode

How it works: Each rule maps a path to a backend Service. pathType: Prefix means /one also matches /one/anything, which is usually what you want. Our demo app (hashicorp/http-echo) answers with its text no matter which path you request, so the request can be forwarded unchanged. ingressClassName: nginx tells Kubernetes which controller should honor this Ingress — on kind, ingress-nginx is the default class, but writing it explicitly saves you pain later (see Recipe 5). Note that the backend points at a Service name and port, never at a Pod directly: the Ingress reuses the stable Service layer from Part 5.

Recipe 3: Route by hostname

Problem: Path prefixes are fine, but real products want api.example.com and blog.example.com — different hostnames on the same IP address.

Your laptop does not know any .local names, so teach it two of them by adding this line to /etc/hosts:

127.0.0.1 one.clinerds.local two.clinerds.local
Enter fullscreen mode Exit fullscreen mode

Replace the Ingress with host-based rules, saved as ingress-hosts.yaml:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: clinerds-demo
spec:
  ingressClassName: nginx
  rules:
    - host: one.clinerds.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app-one
                port:
                  number: 80
    - host: two.clinerds.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app-two
                port:
                  number: 80
Enter fullscreen mode Exit fullscreen mode
kubectl apply -f ingress-hosts.yaml
curl http://one.clinerds.local
curl http://two.clinerds.local
Enter fullscreen mode Exit fullscreen mode
hello from app one
hello from app two
Enter fullscreen mode Exit fullscreen mode

How it works: Every HTTP request carries a Host header with the hostname the browser asked for. The controller reads that header and matches it against the host field of each rule, then applies the path rules inside. This is exactly how one web server can host dozens of unrelated sites on a single IP. On the internet you would create real Domain Name System (DNS) records pointing at your cluster's load balancer IP instead of editing /etc/hosts; everything else stays identical.

Recipe 4: Put TLS on the door

Problem: Traffic to one.clinerds.local is plain HTTP. You want HTTPS with a certificate, without touching the apps at all.

Generate a self-signed certificate for the hostname. It is not trusted by browsers, but it exercises the full TLS machinery:

openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout tls.key -out tls.crt -days 365 \
  -subj "/CN=one.clinerds.local" \
  -addext "subjectAltName=DNS:one.clinerds.local"
Enter fullscreen mode Exit fullscreen mode

Store the certificate and key in a TLS Secret. The tls Secret type is a fixed convention — the controller looks for exactly these two keys:

kubectl create secret tls one-clinerds-tls --cert=tls.crt --key=tls.key
Enter fullscreen mode Exit fullscreen mode

Now add a tls block to the host rule:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: clinerds-demo
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - one.clinerds.local
      secretName: one-clinerds-tls
  rules:
    - host: one.clinerds.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app-one
                port:
                  number: 80
Enter fullscreen mode Exit fullscreen mode

Apply it and call the HTTPS endpoint with -v (verbose) and -k (accept the self-signed certificate):

kubectl apply -f ingress-hosts.yaml
curl -vk https://one.clinerds.local
Enter fullscreen mode Exit fullscreen mode

In the verbose output you can watch the handshake happen at the controller:

*   Trying 127.0.0.1:443...
* SSL connection using TLSv1.3
* Server certificate:
*  subject: CN=one.clinerds.local
> GET / HTTP/1.1
> Host: one.clinerds.local
< HTTP/1.1 200 OK
hello from app one
Enter fullscreen mode Exit fullscreen mode

How it works: This is TLS termination at the edge. The controller holds the certificate, performs the encrypted handshake with the client, decrypts the request, applies its routing rules, and forwards plain HTTP to your Pod inside the cluster network. Your app never sees a certificate, which is why the same unmodified http-echo container now serves HTTPS. The tls block ties a hostname to a Secret; when the handshake for one.clinerds.local arrives, the controller presents that certificate.

Variations:

  • For real, browser-trusted certificates, look at cert-manager. It automates issuing and renewing certificates (for example from Let's Encrypt) and writes them into TLS Secrets just like the one you created by hand.
  • Self-signed certs are fine for local development, but any fleet-wide rollout should come from cert-manager so renewals never become a 3 a.m. pager event.

Recipe 5: Debug the front door

Problem: You applied an Ingress and get an error page. Where do you even look?

Three places, in this order.

First, ask Kubernetes what it thinks of the rules:

kubectl describe ingress clinerds-demo
Enter fullscreen mode Exit fullscreen mode
Name:             clinerds-demo
Ingress Class:    nginx
Rules:
  Host                 Path  Backends
  ----                 ----  --------
  one.clinerds.local
                       /   app-one:80 (10.244.0.12:5678)
Events:
  Type    Reason  Age   Message
  ----    ------  ----  -------
  Normal  Sync    12s   Scheduled for sync
Enter fullscreen mode Exit fullscreen mode

The Events section tells you whether a controller accepted the Ingress. No Sync event at all is your first clue that no controller is watching this object — almost always a class mismatch.

Second, read the controller's own logs:

kubectl logs -n ingress-nginx -l app.kubernetes.io/component=controller --tail=20
Enter fullscreen mode Exit fullscreen mode

Every request the controller handles shows up here with the status code it returned, which lets you see whether traffic even reached the door.

Third, interpret the status code:

  • 404 from NGINX means the controller answered but no rule matched. Check the Host header (did you edit /etc/hosts?), the path, and pathType.
  • 502 Bad Gateway means a rule matched but the backend is broken: wrong Service name, wrong port, or zero ready Pods behind the Service. Check with kubectl get endpoints app-one — an empty list means the Service selector matches nothing.
  • Nothing at all (connection refused) means the controller itself is down or the port mapping is missing. Revisit Recipe 1.

How it works: Debugging Ingress is just untangling three layers: the client (is the hostname right?), the controller (did it sync the rules?), and the backend (does the Service resolve to healthy Pod IPs?). The classic beginner trap is ingressClassName: older tutorials omit it, but if your cluster has several controllers — or none marked as default — a classless Ingress is silently ignored. Always set it explicitly.

Where to go next

Your apps now have one clean, encrypted front door. In Part 7 you will stop baking configuration like -text=... into your manifests and learn how ConfigMaps and Secrets feed settings and sensitive values into Pods. For the full reference on rule types and controllers, see the official Kubernetes Ingress documentation. All parts of the series live at /series/kubernetes-cookbook.

Top comments (0)