Skip to content

Phase 4 · ORCHESTRATION & IAC

Ingress & exposing apps properly

Day 74 of 90 ~55 min 0/20 in phase Builds on Day 73

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

Section 1 of 5 · ~3 min

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.

One Ingress entry point on port 80: the ingress-nginx controller receives every request and routes by URL path — /app1 to the hello Service and its Pod, /app2 to the web Service and its Pod. curl localhost :80 Ingress controller ingress-nginx /app1 /app2 Service hello :8080 Pod hello-app:1.0 Service web :80 Pod nginx
One address, split two ways — the controller reads each request's path and forwards it to the matching Service.

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

Section 2 of 5 · ~4 min

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

Section 3 of 5 · ~3 min

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 refused

Why: 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 ingress showing an empty ADDRESS column that never populates.

Fix: Install a controller (kubectl apply -f the ingress-nginx kind manifest) and wait for its Pod to be Ready. On kind also confirm the node carries ingress-ready=true — the controller’s nodeSelector targets that label, so without it the controller Pod sits Pending and never binds the port.

How you’d spot it in prod: A brand-new Ingress whose ADDRESS stays blank, with curl refused or timing out, almost always means no controller is watching that IngressClass — check kubectl get pods -n ingress-nginx before you touch the rules.

Common error: Using pathType: Exact (instead of Prefix) 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: nginx

Why: Exact matches only the literal string /app1/app1/, /app1/health and 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: Prefix for any route that should cover a subtree, so /app1 also matches everything beneath it. Reserve Exact for 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 of Exact where you meant Prefix.

Common error: Pointing the Ingress backend at a port the Service doesn’t actually expose — here hello on 80 when its Service serves 8080:

$ curl -sI http://localhost/app1
HTTP/1.1 503 Service Temporarily Unavailable
Server: nginx

Why: The Ingress is accepted (its syntax is valid), but the controller can’t find any endpoint for hello:80, because the Service only has a 8080 port. With no upstream to forward to, ingress-nginx serves its default 503. A wrong service.name produces the same result for the same reason.

Fix: Make the backend.service.name and port.number in the Ingress match the Service exactly — check with kubectl 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

Section 4 of 5 · ~1 min

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

Section 5 of 5 · ~1 min

Optional extras if you have ~30 more minutes:

  • 5 min — Run kubectl describe ingress linkstash-ingress and read the Rules table and the Default backend line — exactly what the controller compiled your YAML into.
  • 10 min — Add host-based routing: give each rule a host: (like hello.localdev.me), re-apply, and reach it with curl -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/tls Secret, add a tls: block referencing it, and hit curl -k https://localhost/app1 to 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