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). -
curlandopenssl, 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 ingressinstalls the same controller, andminikube 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
Hostheader (did you edit/etc/hosts?), the path, andpathType. -
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)