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 

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 
kind create cluster --name clinerds --config kind-ingress.yaml 

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 

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 
pod/ingress-nginx-controller-7c9f8d5b4-x2kqp condition met 

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 
kubectl apply -f two-apps.yaml kubectl wait --for=condition=available deploy/app-one deploy/app-two --timeout=90s 

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 
kubectl apply -f ingress-paths.yaml 

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

curl http://localhost/one curl http://localhost/two 
hello from app one hello from app two 

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 

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 
kubectl apply -f ingress-hosts.yaml curl http://one.clinerds.local curl http://two.clinerds.local 
hello from app one hello from app two 

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" 

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 

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 

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 

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 

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 
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 

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 

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.