Phase 4 · ORCHESTRATION & IAC
Ingress & exposing apps properly
By the end of today
- Explain why NodePort and port-forward don't scale to many apps
- Route two Services by URL path through one Ingress on kind
- Install ingress-nginx and see the controller, not the resource, route traffic
One entry point for many apps: Ingress and its controller
Yesterday’s Kubernetes Chaos mission fixed a broken cluster; today you make a healthy one reachable. So far you’ve exposed a Pod two ways, and both hit a wall the moment you have more than one app.
kubectl port-forward opens a tunnel from your laptop to one Pod — brilliant for a quick look, useless for real traffic: it dies when your terminal closes and serves exactly one person. A NodePort Service opens a high-numbered port (30000–32767) on every node for one Service. Two apps means two odd ports to remember; ten apps means ten, none on 80 or 443, none with a hostname, each a new port to document and firewall. Neither scales.
An Ingress is the fix: one entry point on ports 80/443 that routes incoming HTTP by host and path to many Services behind it. shop.example.com goes to the shop Service, /api to the API Service, everything else to the web Service — all through a single address. It is an L7 (HTTP-aware) router that reads the request’s host header and URL path and forwards accordingly, so you add apps without adding public ports.
Here’s the catch that trips up everyone: an Ingress resource does nothing on its own. It is just a routing table stored in the cluster — desired rules with nothing to enforce them. You also need an Ingress controller: a real Pod, usually a reverse proxy, that watches every Ingress object and reprograms itself to actually route the traffic. No controller, no routing — the rules sit there and curl gets connection refused.
Real world: An Ingress is the front desk of an office tower. Without it you’d cut a separate street door for every team — one for sales, one for support, each on its own odd side-street. The desk gives visitors one entrance and reads the name on the badge to send them to the right floor. But a desk with a directory board and no receptionist helps no one: the board (the Ingress rules) needs a person standing there (the controller) to actually walk you to the lift.
The controller you’ll run is ingress-nginx, the NGINX-based controller maintained by the Kubernetes project itself — the most widely deployed one, and the reference every tutorial reaches for. Installing it on kind takes one manifest; it drops an NGINX reverse proxy into an ingress-nginx namespace, and from then on every Ingress you create rewrites that proxy’s config automatically.
One more piece makes routing precise: pathType. Prefix matches a path and everything beneath it (/app1 also catches /app1/health) — the one you want most of the time. Exact matches only that exact string. ImplementationSpecific hands the decision to the controller. Choose Prefix for a route that fronts a whole app and you sidestep the classic “root works but every sub-page 404s” bug.
So the model is two objects working together: the Ingress says what should route where, and the controller makes it happen. Today you install the controller on a kind cluster wired for ports 80/443, deploy two tiny apps, and route /app1 and /app2 to them through one address.
Hands-On Lab
Budget about 30 minutes. This runs entirely locally on kind (Kubernetes in Docker) on WSL2 — ₹0, no cloud. The plain cluster from Day 67 can’t help here: Ingress needs the node’s port 80 mapped to your host and a node labelled ingress-ready, so you build a fresh cluster from a config first. Pod name suffixes, ReplicaSet hashes, cluster IPs, ages and node versions are unique to each run — yours will differ from the samples.
# 1. kind-ingress.yaml — a cluster that maps host 80/443 onto the node and
# labels it ingress-ready=true, which the ingress-nginx manifest schedules onto.
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
# 2. Create the cluster from that config, then confirm the one node is Ready.
kind create cluster --config kind-ingress.yaml
kubectl get nodes
# Output (trimmed; the node image version tracks your kind release):
# Creating cluster "kind" ...
# ✓ Preparing nodes
# ✓ Starting control-plane
# ✓ Installing CNI
# ✓ Installing StorageClass
# Set kubectl context to "kind-kind"
# NAME STATUS ROLES AGE VERSION
# kind-control-plane Ready control-plane 45s v1.31.4
# 3. Install the ingress-nginx controller with the OFFICIAL kind-specific manifest.
kubectl apply -f https://kubernetes.github.io/ingress-nginx/deploy/static/provider/kind/deploy.yaml
# Output (trimmed to the key objects it creates):
# namespace/ingress-nginx created
# serviceaccount/ingress-nginx created
# configmap/ingress-nginx-controller created
# service/ingress-nginx-controller created
# deployment.apps/ingress-nginx-controller created
# job.batch/ingress-nginx-admission-create created
# ingressclass.networking.k8s.io/nginx created
# validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created
# 4. Wait for the controller Pod to report Ready — the Ingress does nothing until it is.
kubectl wait --namespace ingress-nginx \
--for=condition=ready pod \
--selector=app.kubernetes.io/component=controller \
--timeout=120s
# Output (Pod hash and suffix will differ):
# pod/ingress-nginx-controller-7c6974c4d8-x7k2p condition met
# 5. apps.yaml — two tiny apps, each with a Service. hello-app serves on :8080,
# nginx on :80. Both are public images (stand-ins for your linkstash containers).
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello
spec:
replicas: 1
selector:
matchLabels: { app: hello }
template:
metadata:
labels: { app: hello }
spec:
containers:
- name: hello
image: gcr.io/google-samples/hello-app:1.0 # serves plain text on :8080
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: hello
spec:
selector: { app: hello }
ports:
- port: 8080
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 1
selector:
matchLabels: { app: web }
template:
metadata:
labels: { app: web }
spec:
containers:
- name: web
image: nginx:1.28-alpine # public stand-in for the linkstash web image
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: web
spec:
selector: { app: web }
ports:
- port: 80
targetPort: 80
# 6. Apply both apps and confirm the Pods are Running and the Services exist.
kubectl apply -f apps.yaml
kubectl get pods,svc
# Output (Pod suffixes, cluster IPs and ages will differ; the kubernetes svc omitted):
# NAME READY STATUS RESTARTS AGE
# pod/hello-6b7d9c8f5-q4m8n 1/1 Running 0 18s
# pod/web-5f8c7b9d6-w2p6r 1/1 Running 0 18s
# NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
# service/hello ClusterIP 10.96.121.44 <none> 8080/TCP 18s
# service/web ClusterIP 10.96.87.201 <none> 80/TCP 18s
# 7. ingress.yaml — one entry point, routing by path to the two Services.
# rewrite-target: / makes each backend see a root request, so nginx serves its
# index instead of 404-ing on the /app2 prefix it doesn't recognise.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: linkstash-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /app1
pathType: Prefix
backend:
service:
name: hello
port:
number: 8080
- path: /app2
pathType: Prefix
backend:
service:
name: web
port:
number: 80
# 8. Apply the Ingress and read it back — the controller fills ADDRESS with localhost.
kubectl apply -f ingress.yaml
kubectl get ingress
# Output (ADDRESS may be blank for a few seconds until the controller reports it):
# NAME CLASS HOSTS ADDRESS PORTS AGE
# linkstash-ingress nginx * localhost 80 12s
# 9. Curl the first path — the request goes through the Ingress to the hello Service.
curl -s http://localhost/app1
# Output (the Hostname is the serving Pod — yours will differ):
# Hello, world!
# Version: 1.0.0
# Hostname: hello-6b7d9c8f5-q4m8n
# 10. Curl the second path — same address, same port, different Service behind it.
curl -s http://localhost/app2 | head -4
# Output (nginx's default page — proof /app2 reached the web Service):
# <!DOCTYPE html>
# <html>
# <head>
# <title>Welcome to nginx!</title>
# 11. Clean up — delete the whole cluster so the next day starts fresh.
kind delete cluster
# Output:
# Deleting cluster "kind" ...
# Deleted nodes: ["kind-control-plane"]
Read steps 9 and 10 back to back: one address on port 80, two different apps answering, chosen purely by the URL path. You never opened a NodePort, never memorised a port number, and adding a third app would be one more path: block — not one more public port. That is what an Ingress buys you.
Common Errors & Fixes
These three catch people the first time they put an Ingress in front of real Services on kind. Read the response codes slowly — 404 and 503 point at completely different mistakes.
Common error: Creating an Ingress on a cluster with no controller installed, then trying to reach it:
curl: (7) Failed to connect to localhost port 80 after 0 ms: Connection refusedWhy: An Ingress object is only a routing table — inert data. Without an Ingress controller running, nothing binds port 80 on the node and nothing reads the rules, so the connection has nowhere to land. The tell in the cluster is
kubectl get ingressshowing an emptyADDRESScolumn that never populates.Fix: Install a controller (
kubectl apply -fthe ingress-nginx kind manifest) and wait for its Pod to be Ready. On kind also confirm the node carriesingress-ready=true— the controller’snodeSelectortargets that label, so without it the controller Pod sitsPendingand never binds the port.How you’d spot it in prod: A brand-new Ingress whose
ADDRESSstays blank, withcurlrefused or timing out, almost always means no controller is watching that IngressClass — checkkubectl get pods -n ingress-nginxbefore you touch the rules.
Common error: Using
pathType: Exact(instead ofPrefix) on a route that fronts a whole app, then hitting a sub-path:$ curl -sI http://localhost/app1/health HTTP/1.1 404 Not Found Server: nginxWhy:
Exactmatches only the literal string/app1—/app1/,/app1/healthand every deeper path miss the rule entirely, so the controller falls through to its default backend and returns a 404. The app is healthy; the path matcher is simply too strict.Fix: Use
pathType: Prefixfor any route that should cover a subtree, so/app1also matches everything beneath it. ReserveExactfor a single, specific endpoint you deliberately want to pin.How you’d spot it in prod: The home page loads but every deep link 404s from
Server: nginx(the controller, not your app). That split — root works, sub-pages don’t — is the fingerprint ofExactwhere you meantPrefix.
Common error: Pointing the Ingress backend at a port the Service doesn’t actually expose — here
helloon80when its Service serves8080:$ curl -sI http://localhost/app1 HTTP/1.1 503 Service Temporarily Unavailable Server: nginxWhy: The Ingress is accepted (its syntax is valid), but the controller can’t find any endpoint for
hello:80, because the Service only has a8080port. With no upstream to forward to, ingress-nginx serves its default 503. A wrongservice.nameproduces the same result for the same reason.Fix: Make the
backend.service.nameandport.numberin the Ingress match the Service exactly — check withkubectl get svc hello -o wide, then align the Ingress to the port the Service publishes and re-apply.How you’d spot it in prod: A 503 from the Ingress on a route whose backend Pods are clearly Running means the wire is wrong, not the app — the backend name or port in the Ingress doesn’t match the Service, or the Service’s selector matches no Pods.
Ingress Interview Questions
The NodePort-versus-Ingress reasoning and the resource-versus-controller split below are near-guaranteed Phase-4 Kubernetes screening questions — a calm answer that names where the request goes beats a hand-wave every time. The answer bank renders right after this note. Cover each answer, say your own version out loud first, then compare — recalling before revealing is what makes it stick for interview day.
Go Deeper
Optional extras if you have ~30 more minutes:
- 5 min — Run
kubectl describe ingress linkstash-ingressand read theRulestable and theDefault backendline — exactly what the controller compiled your YAML into. - 10 min — Add host-based routing: give each rule a
host:(likehello.localdev.me), re-apply, and reach it withcurl -H 'Host: hello.localdev.me' http://localhost/— the other half of how Ingress splits traffic. - 10 min — Terminate TLS at the Ingress: create a self-signed
kubernetes.io/tlsSecret, add atls:block referencing it, and hitcurl -k https://localhost/app1to see HTTPS handled at the edge. - 15 min — Skim the Kubernetes Ingress docs and the note on the newer Gateway API — the successor that splits today’s single Ingress object into role-oriented resources.
What problem does an Ingress solve that NodePort and port-forward can't? Both
port-forward is a debug tunnel — it serves one person from one terminal and dies when I close it. A NodePort opens one high port, 30000 and up, per Service on every node, so ten apps means ten odd ports, none on 80 or 443 and none with a hostname. Neither scales to real traffic. An Ingress gives me one entry point on 80/443 that routes by host and path to many Services: shop.example.com to one, /api to another, all behind a single address. I add apps without opening new public ports or handing users port numbers. It's the difference between a dozen side doors and one front desk.
What is the difference between an Ingress and an Ingress controller? Both
The Ingress is just data — a Kubernetes object holding routing rules: this host and path go to that Service. On its own it does absolutely nothing; it's a routing table with nobody reading it. The Ingress controller is the Pod that makes it real — a reverse proxy like ingress-nginx that watches every Ingress object and reprograms its own config to actually forward traffic. So you need both: the resource declares intent, the controller enforces it. The classic beginner bug is creating an Ingress on a cluster with no controller installed — the ADDRESS stays empty and curl gets connection refused, because there's a rulebook but no one enforcing it.
What does pathType do, and when do you use Prefix versus Exact? Service
pathType tells the controller how to match the request path against the rule. Prefix matches the path and everything beneath it — /app1 also matches /app1/health — which is what you want for a route fronting a whole app. Exact matches only that exact string, so /app1 matches but /app1/ and /app1/health don't. ImplementationSpecific hands the decision to the controller's own logic. I reach for Prefix almost always; Exact is for one specific endpoint. The bug I watch for is Exact on an app route — the root loads but every sub-page 404s, so it looks like the app is broken when it's really the path match being too strict.
How would you expose two apps under one domain in Kubernetes? Product
One Ingress with two rules, behind one controller. If the apps share a hostname I split by path — /shop to the shop Service, /api to the api Service — each with pathType Prefix and the right backend service name and port. If they have their own names I split by host instead: shop.example.com and api.example.com as separate rules pointing at their Services. Either way it's a single entry point on 80/443, so I'm not handing users port numbers or opening a NodePort per app. In production I'd also terminate TLS at the Ingress with a tls block referencing a Secret, so both apps get HTTPS from that one place.
Mark Day 74 complete
Tomorrow you stop writing raw YAML for every app — Helm packages a whole Kubernetes app into one versioned, configurable chart you install with a single command.
Stuck on today’s lab? Ask in Mission 90 Q&A