Emmanuel Akinfulubi

Kubernetes all 4 written

The problem it solves that Compose does not, and real manifests: Pods, Services, ConfigMaps, Secrets, Ingress.

35 The problem Kubernetes solves that Compose does not

Docker Compose starts a defined set of containers and stops when you tell it to -- it has no ongoing opinion about whether they are still supposed to be running five minutes later. Kubernetes's entire job is having that opinion constantly: a Deployment's replica count is not a one-time instruction, it is a fact Kubernetes keeps making true, forever, until you change it.

A plain container that dies stays dead

This is not a flaw in Docker -- docker run was never asked to keep watching. Killing a container is simply the end of its story, and nothing brings it back unless something else is watching and reacts.

docker kill: the end of the story, nothing watching

ran at build time
docker rm -f course-selfheal-demo > /dev/null 2>&1 || true
docker run -d --name course-selfheal-demo nginx:alpine > /dev/null
echo "started a plain container: course-selfheal-demo"

docker kill course-selfheal-demo > /dev/null
echo "killed it"
sleep 2

still_there=$(docker ps --filter name=course-selfheal-demo --format '{{.Names}}')
echo "docker ps for it now: '$still_there' (empty means gone, for good)"

docker rm -f course-selfheal-demo > /dev/null 2>&1 || true

check "a plain killed container does not come back on its own" "$still_there" ""

What it printed, when the build ran it

started a plain container: course-selfheal-demo
killed it
docker ps for it now: '' (empty means gone, for good)
ok   a plain killed container does not come back on its own

The same idea, as a real Kubernetes Deployment on a real cluster

This is not a simulation of Kubernetes -- kind runs an actual control plane, an actual scheduler and an actual etcd, inside a container, sized for a laptop instead of a data centre. The Deployment below is scheduled by a real API server, the same mechanism a production cluster uses at any size.

kubectl delete pod: the same kill, a different outcome

ran at build time
cat > /tmp/cd-course-selfheal.yaml << 'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: selfheal-demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: selfheal-demo
  template:
    metadata:
      labels:
        app: selfheal-demo
    spec:
      containers:
      - name: web
        image: nginx:alpine
YAML

kubectl apply -f /tmp/cd-course-selfheal.yaml
kubectl wait --for=condition=Available deployment/selfheal-demo --timeout=30s

original_pod=$(kubectl get pods -l app=selfheal-demo -o jsonpath='{.items[0].metadata.name}')
echo "the pod actually running: $original_pod"

echo
echo "deleting that exact pod directly -- the same kind of thing that just killed the plain container:"
kubectl delete pod "$original_pod" > /dev/null

replacement=""
for i in 1 2 3 4 5 6 7 8 9 10; do
  sleep 0.5
  candidate=$(kubectl get pods -l app=selfheal-demo -o jsonpath='{.items[0].metadata.name}' 2>/dev/null)
  if [ -n "$candidate" ] && [ "$candidate" != "$original_pod" ]; then
    replacement="$candidate"
    break
  fi
done
echo "what is running now, moments later, with nobody re-running anything: $replacement"

kubectl delete -f /tmp/cd-course-selfheal.yaml > /dev/null
rm -f /tmp/cd-course-selfheal.yaml

check "a replacement pod appeared with no command re-run by hand" "$([ -n "$replacement" ] && echo yes)" "yes"
check "the replacement is genuinely a NEW pod, not the same one restarted in place" \
  "$([ "$replacement" != "$original_pod" ] && echo yes)" "yes"

What it printed, when the build ran it

deployment.apps/selfheal-demo created
deployment.apps/selfheal-demo condition met
the pod actually running: selfheal-demo-584b9cd55c-l5vk7

deleting that exact pod directly -- the same kind of thing that just killed the plain container:
what is running now, moments later, with nobody re-running anything: selfheal-demo-584b9cd55c-5m8mm
ok   a replacement pod appeared with no command re-run by hand
ok   the replacement is genuinely a NEW pod, not the same one restarted in place

What actually did the watching. Nothing about the pod itself came back to life -- that exact process is gone. A separate, always-running controller noticed the Deployment's desired count (1) no longer matched reality (0), and asked the scheduler for a new pod to close the gap. This loop runs constantly, for every Deployment on the cluster, whether or not anyone is watching.

"Desired state" is not a one-time instruction -- changing it is the whole interface

Scaling a Deployment is not a separate command that does something special -- it is editing the one number Kubernetes is continuously trying to make true, the exact same mechanism that just brought the deleted pod back.

Scaling is not a special command -- it is the same reconciliation loop

ran at build time
cat > /tmp/cd-course-scale.yaml << 'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: scale-demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: scale-demo
  template:
    metadata:
      labels:
        app: scale-demo
    spec:
      containers:
      - name: web
        image: nginx:alpine
YAML

kubectl apply -f /tmp/cd-course-scale.yaml
kubectl wait --for=condition=Available deployment/scale-demo --timeout=30s
before=$(kubectl get pods -l app=scale-demo --no-headers | wc -l | tr -d ' ')
echo "replicas: 2 in the file -- pods actually running: $before"

echo
echo "deleting BOTH pods at once, simulating the whole fleet crashing:"
kubectl delete pods -l app=scale-demo > /dev/null

recovered=0
for i in 1 2 3 4 5 6 7 8 9 10; do
  sleep 0.5
  running=$(kubectl get pods -l app=scale-demo --no-headers 2>/dev/null | grep -c Running)
  if [ "$running" = "2" ]; then recovered=2; break; fi
done
echo "running again after the crash, with nobody intervening: $recovered"

echo
echo "now editing the SAME file to replicas: 4, and re-applying it -- this is 'scaling':"
sed -i.bak 's/replicas: 2/replicas: 4/' /tmp/cd-course-scale.yaml
kubectl apply -f /tmp/cd-course-scale.yaml
kubectl wait --for=condition=Available deployment/scale-demo --timeout=30s
after=$(kubectl get pods -l app=scale-demo --no-headers | wc -l | tr -d ' ')
echo "pods running after re-applying with replicas: 4: $after"

kubectl delete -f /tmp/cd-course-scale.yaml > /dev/null
rm -f /tmp/cd-course-scale.yaml /tmp/cd-course-scale.yaml.bak

check "the fleet recovered to full strength after a total crash, unattended" "$recovered" "2"
check "scaling is the exact same file, one number changed, re-applied" "$after" "4"

What it printed, when the build ran it

deployment.apps/scale-demo created
deployment.apps/scale-demo condition met
replicas: 2 in the file -- pods actually running: 2

deleting BOTH pods at once, simulating the whole fleet crashing:
running again after the crash, with nobody intervening: 2

now editing the SAME file to replicas: 4, and re-applying it -- this is 'scaling':
deployment.apps/scale-demo configured
deployment.apps/scale-demo condition met
pods running after re-applying with replicas: 4: 4
ok   the fleet recovered to full strength after a total crash, unattended
ok   scaling is the exact same file, one number changed, re-applied

What this lesson is NOT claiming. This single-node cluster proves the reconciliation loop -- desired state pursued continuously -- which is Kubernetes's actual defining feature. It does not demonstrate scheduling across multiple physical machines, since kind runs one node; that property (a pod landing on whichever machine in a fleet has room for it) is real and important, but requires more than a laptop to show honestly, so this course states it here rather than faking it with one node pretending to be many.

Why this is not "Compose, but fancier"

  • Compose's restart: always restarts a container that exits -- it does not notice a container that was forcibly killed and never exits cleanly in the way Kubernetes's controller loop, checked above, genuinely does.
  • Compose has no concept of "spread these five containers across whichever of my ten machines has room" -- it runs everything on the one machine it was invoked on.
  • Kubernetes's API is the same reconciliation idea for every resource, not just replica count -- lesson 37 uses the identical pattern for configuration, and lesson 38 for how traffic reaches a pod at all.

36 Pods, Deployments and Services, as real manifests

A bare Pod, applied directly, is honest about what it is: one running instance, with no controller replacing it if it dies -- exactly like the plain Docker container in lesson 35. A Deployment exists specifically to put the reconciliation loop in front of Pods. A Service exists to solve a problem neither of those touches: Pods get new IP addresses every time they are replaced, and nothing should ever hardcode one.

A bare Pod: the smallest unit, and its real limitation

A Pod alone: exactly as fragile as a plain container

ran at build time
cat > /tmp/cd-course-bare-pod.yaml << 'YAML'
apiVersion: v1
kind: Pod
metadata:
  name: bare-pod
  labels:
    app: bare-pod
spec:
  containers:
  - name: web
    image: nginx:alpine
YAML

kubectl apply -f /tmp/cd-course-bare-pod.yaml
kubectl wait --for=condition=Ready pod/bare-pod --timeout=30s
echo "the bare pod is running, on its own, with no Deployment above it"

echo
echo "deleting it directly -- the same action lesson 35 showed a Deployment's pod surviving:"
kubectl delete pod bare-pod

still_gone=""
for i in 1 2 3 4 5 6; do
  sleep 1
  if ! kubectl get pod bare-pod > /dev/null 2>&1; then still_gone="yes"; fi
done
echo "still gone after waiting: $still_gone"
rm -f /tmp/cd-course-bare-pod.yaml

check "a bare pod, with nothing watching it, does not come back" "$still_gone" "yes"

What it printed, when the build ran it

pod/bare-pod created
pod/bare-pod condition met
the bare pod is running, on its own, with no Deployment above it

deleting it directly -- the same action lesson 35 showed a Deployment's pod surviving:
pod "bare-pod" deleted from default namespace
still gone after waiting: yes
ok   a bare pod, with nothing watching it, does not come back

A Deployment: the same Pod spec, now with the loop from lesson 35 in front of it

A Deployment's YAML is not a different kind of container definition -- the template: block is a Pod spec, verbatim. What a Deployment adds is everything wrapped around that spec: the replica count, and the controller enforcing it.

A Deployment: the same Pod spec, replicated and supervised

ran at build time
cat > /tmp/cd-course-deployment.yaml << 'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-deployment
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web-deployment
  template:
    metadata:
      labels:
        app: web-deployment
    spec:
      containers:
      - name: web
        image: nginx:alpine
YAML

kubectl apply -f /tmp/cd-course-deployment.yaml
kubectl wait --for=condition=Available deployment/web-deployment --timeout=30s

pod_count=$(kubectl get pods -l app=web-deployment --no-headers | wc -l | tr -d ' ')
echo "pods actually running for 1 Deployment, 3 replicas: $pod_count"

echo
echo "each one is a genuinely separate Pod, with its own name and IP:"
kubectl get pods -l app=web-deployment -o custom-columns=NAME:.metadata.name,IP:.status.podIP

check "the Deployment produced exactly the requested number of real pods" "$pod_count" "3"

What it printed, when the build ran it

deployment.apps/web-deployment created
deployment.apps/web-deployment condition met
pods actually running for 1 Deployment, 3 replicas: 3

each one is a genuinely separate Pod, with its own name and IP:
NAME                              IP
web-deployment-7c67c58c7f-btw85   10.244.0.14
web-deployment-7c67c58c7f-kfptl   10.244.0.15
web-deployment-7c67c58c7f-mblv9   10.244.0.16
ok   the Deployment produced exactly the requested number of real pods

A Service: the address that survives every Pod it points to being replaced

Nothing should ever hardcode a Pod's own IP -- it is reassigned every time that Pod is replaced, which lesson 35 already showed happens whenever one dies. A Service gets one stable address and spreads real traffic across whichever Pods currently match its selector, live.

One address, load-balanced across every matching Pod, live

ran at build time
kubectl delete deployment web-deployment > /dev/null 2>&1 || true
cat > /tmp/cd-course-svc-demo.yaml << 'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: whoami-deployment
spec:
  replicas: 3
  selector:
    matchLabels:
      app: whoami-deployment
  template:
    metadata:
      labels:
        app: whoami-deployment
    spec:
      containers:
      - name: whoami
        image: traefik/whoami
---
apiVersion: v1
kind: Service
metadata:
  name: whoami-deployment
spec:
  selector:
    app: whoami-deployment
  ports:
  - port: 80
YAML

kubectl apply -f /tmp/cd-course-svc-demo.yaml
kubectl wait --for=condition=Available deployment/whoami-deployment --timeout=30s

cluster_ip=$(kubectl get svc whoami-deployment -o jsonpath='{.spec.clusterIP}')
echo "the Service's own, stable address: $cluster_ip"

echo
echo "a throwaway pod, from inside the cluster, hitting that ONE address repeatedly:"
echo "(whoami reports its own pod's hostname in every response body, which is how we can tell them apart)"
kubectl run curler --image=curlimages/curl --restart=Never --command -- sleep 300 > /dev/null
kubectl wait --for=condition=Ready pod/curler --timeout=30s > /dev/null

seen_hosts=""
for i in 1 2 3 4 5 6; do
  answer=$(kubectl exec curler -- curl -s "http://whoami-deployment/" 2>/dev/null)
  host=$(echo "$answer" | grep -o 'Hostname: [^ ]*' | cut -d' ' -f2)
  seen_hosts="$seen_hosts $host"
done
echo "pod hostnames that answered, across 6 requests to the SAME address:"
echo "$seen_hosts" | tr ' ' '\n' | sort -u

distinct=$(echo "$seen_hosts" | tr ' ' '\n' | sort -u | grep -c '^whoami-deployment')

kubectl delete pod curler --now > /dev/null 2>&1
kubectl delete -f /tmp/cd-course-svc-demo.yaml
rm -f /tmp/cd-course-deployment.yaml /tmp/cd-course-svc-demo.yaml

check "more than one distinct pod answered through the single Service address" \
  "$([ "$distinct" -gt 1 ] && echo yes)" "yes"

What it printed, when the build ran it

deployment.apps/whoami-deployment created
service/whoami-deployment created
deployment.apps/whoami-deployment condition met
the Service's own, stable address: 10.96.225.152

a throwaway pod, from inside the cluster, hitting that ONE address repeatedly:
(whoami reports its own pod's hostname in every response body, which is how we can tell them apart)
pod hostnames that answered, across 6 requests to the SAME address:

whoami-deployment-6676498684-8kwm7
whoami-deployment-6676498684-lchqp
whoami-deployment-6676498684-mk5zq
deployment.apps "whoami-deployment" deleted from default namespace
service "whoami-deployment" deleted from default namespace
ok   more than one distinct pod answered through the single Service address

This is the exact mechanism from Part 6's load balancer lesson, one layer down. An ALB's target group tracks which instances are currently healthy and spreads traffic across them; a Service does the identical job for Pods, using a label selector instead of a manual register call. Neither one cares how the backends came to exist or how many there currently are -- that is precisely what makes them stable addresses.

The three objects, and which job each one actually does

  • Pod: one or more containers that are always scheduled together, sharing a network namespace. The unit Kubernetes actually runs -- everything else exists to manage Pods, not to replace the idea of one.
  • Deployment: a Pod template plus a replica count plus the reconciliation loop from lesson 35. What almost every real workload is wrapped in, specifically so a Pod dying is a non-event instead of an outage.
  • Service: a stable address for a moving set of Pods, selected by label, not by remembering any individual Pod's identity -- which by design is not supposed to be remembered at all.

37 ConfigMaps, Secrets and the twelve-factor config split

A container image should never need to be rebuilt because a database hostname changed between staging and production -- that single idea is what "config outside the code" means, and Kubernetes gives it two real objects: ConfigMaps for values that are not sensitive, Secrets for values that are, even though (checked below) the difference between them is thinner than the names suggest.

The exact same image, behaving differently, because the config differs

Nothing about the container image below knows what environment it is in. The only thing that differs between these two pods is which ConfigMap is wired to it.

One image, two ConfigMaps, two genuinely different outcomes

ran at build time
cat > /tmp/cd-course-envs.yaml << 'YAML'
apiVersion: v1
kind: ConfigMap
metadata:
  name: staging-config
data:
  ENVIRONMENT: staging
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: production-config
data:
  ENVIRONMENT: production
---
apiVersion: v1
kind: Pod
metadata:
  name: staging-pod
spec:
  containers:
  - name: app
    image: alpine
    command: ["sh", "-c", "echo running as $ENVIRONMENT; sleep 300"]
    envFrom:
    - configMapRef:
        name: staging-config
---
apiVersion: v1
kind: Pod
metadata:
  name: production-pod
spec:
  containers:
  - name: app
    image: alpine
    command: ["sh", "-c", "echo running as $ENVIRONMENT; sleep 300"]
    envFrom:
    - configMapRef:
        name: production-config
YAML

kubectl apply -f /tmp/cd-course-envs.yaml
kubectl wait --for=condition=Ready pod/staging-pod pod/production-pod --timeout=30s

echo "the exact same image and command, in the pod labelled staging:"
staging_log=$(kubectl logs staging-pod)
echo "$staging_log"

echo
echo "the exact same image and command, in the pod labelled production:"
production_log=$(kubectl logs production-pod)
echo "$production_log"

kubectl delete -f /tmp/cd-course-envs.yaml --grace-period=0 --force > /dev/null 2>&1
rm -f /tmp/cd-course-envs.yaml

check "the staging pod genuinely reported staging, from its injected config" "$staging_log" "running as staging"
check "the production pod genuinely reported production -- same image, different result" \
  "$production_log" "running as production"

What it printed, when the build ran it

configmap/staging-config created
configmap/production-config created
pod/staging-pod created
pod/production-pod created
pod/staging-pod condition met
pod/production-pod condition met
the exact same image and command, in the pod labelled staging:
running as staging

the exact same image and command, in the pod labelled production:
running as production
ok   the staging pod genuinely reported staging, from its injected config
ok   the production pod genuinely reported production -- same image, different result

What would have to change to add a THIRD environment. Nothing about the image, and nothing about the command inside it -- only a third ConfigMap. This is the concrete payoff of keeping config out of the image: adding an environment is a new YAML file, not a new build.

A Secret, read back honestly: base64, not encryption

The name "Secret" implies protection the object itself does not provide. What it actually stores is base64-encoded text -- reversible by anyone with read access to the object, with no key involved at all.

A Secret's real content, decoded with nothing but base64

ran at build time
kubectl create secret generic db-credentials --from-literal=DB_PASSWORD=hunter2-but-real

echo "the Secret, exactly as Kubernetes stores it:"
stored=$(kubectl get secret db-credentials -o jsonpath='{.data.DB_PASSWORD}')
echo "$stored"

echo
echo "decoding it -- no key, no password, just base64, the same encoding curl or email attachments use:"
decoded=$(echo "$stored" | base64 --decode)
echo "$decoded"

kubectl delete secret db-credentials

check "what is stored is reversible base64, confirmed by decoding it back to the original" \
  "$decoded" "hunter2-but-real"

What it printed, when the build ran it

secret/db-credentials created
the Secret, exactly as Kubernetes stores it:
aHVudGVyMi1idXQtcmVhbA==

decoding it -- no key, no password, just base64, the same encoding curl or email attachments use:
hunter2-but-real
secret "db-credentials" deleted from default namespace
ok   what is stored is reversible base64, confirmed by decoding it back to the original

What a Secret actually protects against, and what it does not. A Secret is NOT encrypted at rest by default, and base64 is not a security boundary -- anyone who can run kubectl get secret -o jsonpath against this cluster (the exact command just run) can read it in one line. What a Secret DOES do differently from a ConfigMap: Kubernetes avoids logging its value in places a ConfigMap's value might appear, and RBAC (Part 13's IAM equivalent for Kubernetes) can restrict who is allowed to get Secret objects specifically. Real protection -- encryption at rest, short-lived credentials -- needs more than a Secret object alone; Part 13 returns to this with the further, real consequences.

Same object, same mechanism, injected the same way -- on purpose

A Secret and a ConfigMap are consumed identically by a Pod (envFrom, exactly as used above) -- Kubernetes deliberately does not make sensitive config harder to wire up than non-sensitive config, because making the secure path the annoying path is how secrets end up hardcoded instead.

ConfigMap and Secret, wired into one real pod the same way

ran at build time
cat > /tmp/cd-course-both.yaml << 'YAML'
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-settings
data:
  APP_MODE: production
  LOG_LEVEL: info
---
apiVersion: v1
kind: Secret
metadata:
  name: app-credentials
type: Opaque
stringData:
  DB_PASSWORD: super-secret-db-pass
---
apiVersion: v1
kind: Pod
metadata:
  name: config-and-secret-consumer
spec:
  containers:
  - name: app
    image: alpine
    command: ["sh", "-c", "env | grep -E 'APP_MODE|LOG_LEVEL|DB_PASSWORD' | sort; sleep 300"]
    envFrom:
    - configMapRef:
        name: app-settings
    - secretRef:
        name: app-credentials
YAML

kubectl apply -f /tmp/cd-course-both.yaml
kubectl wait --for=condition=Ready pod/config-and-secret-consumer --timeout=30s

echo "what the running application actually sees, from BOTH sources, injected the same way:"
seen=$(kubectl logs config-and-secret-consumer)
echo "$seen"

kubectl delete -f /tmp/cd-course-both.yaml --grace-period=0 --force > /dev/null 2>&1
rm -f /tmp/cd-course-both.yaml

check "the non-sensitive setting from the ConfigMap reached the app" "$(echo "$seen" | grep -c 'APP_MODE=production')" "1"
check "the sensitive value from the Secret reached the app through the identical mechanism" \
  "$(echo "$seen" | grep -c 'DB_PASSWORD=super-secret-db-pass')" "1"

What it printed, when the build ran it

configmap/app-settings created
secret/app-credentials created
pod/config-and-secret-consumer created
pod/config-and-secret-consumer condition met
what the running application actually sees, from BOTH sources, injected the same way:
APP_MODE=production
DB_PASSWORD=super-secret-db-pass
LOG_LEVEL=info
ok   the non-sensitive setting from the ConfigMap reached the app
ok   the sensitive value from the Secret reached the app through the identical mechanism

What this actually replaces

  • A config value baked into the image at build time -- meaning a different image per environment, and a rebuild for every config change.
  • A password committed to a repo because "it's just for staging" -- Part 13 returns to why that specific habit is one of the most common real breach causes, not a theoretical risk.
  • The distinction that matters is not "ConfigMap vs Secret" mechanically -- it is which category of value you are handling, and Part 13's least-privilege lesson is where that distinction actually earns its keep.

38 Ingress: exposing a service to the world

Every Service used in lesson 36 had an address only reachable from inside the cluster -- correct for one microservice talking to another, useless for an actual visitor. Ingress is a real HTTP router sitting at the cluster's edge, deciding, by hostname (and optionally path), which Service a request should reach.

Why this lesson installs Traefik, not the ingress controller most older tutorials use. The community ingress-nginx project -- for years the default choice bundled into countless getting-started guides -- was retired by Kubernetes SIG Network in March 2026 and stopped receiving security patches. This course will not teach installing software with no path to a fix for its next CVE. Traefik is one of several currently maintained alternatives that speak the same Ingress API.

Installing a real ingress controller with Helm

Helm is to a Kubernetes application what a package manager is to an OS: a chart packages a set of manifests (here, Traefik's own Deployment, Service, RBAC rules and CRDs) so installing it is one real command instead of hand-assembling a dozen YAML files.

A real ingress controller, installed with Helm

ran at build time
helm repo add traefik https://traefik.github.io/charts > /dev/null
helm repo update > /dev/null
echo "chart repository added"

helm install traefik traefik/traefik --wait --timeout 90s

echo
echo "confirming it is a real, running workload on the cluster -- not just a Helm record:"
kubectl get pods -l app.kubernetes.io/name=traefik
kubectl get ingressclass

check "Traefik installed and reports as deployed" "$(helm list --short | grep -c traefik)" "1"
check "installing it registered a real IngressClass Ingress objects can reference" \
  "$(kubectl get ingressclass -o name | grep -c traefik)" "1"

What it printed, when the build ran it

chart repository added
NAME: traefik
LAST DEPLOYED: Tue Sep 15 21:27:39 2026
NAMESPACE: default
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
TEST SUITE: None
NOTES:
traefik with docker.io/traefik:v3.7.13 has been deployed successfully on default namespace!

confirming it is a real, running workload on the cluster -- not just a Helm record:
NAME                       READY   STATUS    RESTARTS   AGE
traefik-768cc4d6c9-ttnkf   1/1     Running   0          28s
NAME                CONTROLLER                      PARAMETERS   AGE
traefik (default)   traefik.io/ingress-controller   <none>       28s
ok   Traefik installed and reports as deployed
ok   installing it registered a real IngressClass Ingress objects can reference

Two backends, one entry point, routed by hostname -- proven with real HTTP requests

Two completely separate Deployments and Services exist below. The only thing making them reachable from outside the cluster is one Ingress object each, naming a different host. What follows is not reading the YAML back -- it is sending real HTTP requests through Traefik and checking which backend actually answered.

Same address, different Host header, genuinely different real backend

ran at build time
cat > /tmp/cd-course-ingress.yaml << 'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: site-a
spec:
  replicas: 1
  selector:
    matchLabels:
      app: site-a
  template:
    metadata:
      labels:
        app: site-a
    spec:
      containers:
      - name: whoami
        image: traefik/whoami
        args: ["-name=site-a"]
---
apiVersion: v1
kind: Service
metadata:
  name: site-a
spec:
  selector:
    app: site-a
  ports:
  - port: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: site-a
spec:
  ingressClassName: traefik
  rules:
  - host: site-a.localhost
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: site-a
            port:
              number: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: site-b
spec:
  replicas: 1
  selector:
    matchLabels:
      app: site-b
  template:
    metadata:
      labels:
        app: site-b
    spec:
      containers:
      - name: whoami
        image: traefik/whoami
        args: ["-name=site-b"]
---
apiVersion: v1
kind: Service
metadata:
  name: site-b
spec:
  selector:
    app: site-b
  ports:
  - port: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: site-b
spec:
  ingressClassName: traefik
  rules:
  - host: site-b.localhost
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: site-b
            port:
              number: 80
YAML

kubectl apply -f /tmp/cd-course-ingress.yaml
kubectl wait --for=condition=Available deployment/site-a deployment/site-b --timeout=30s

echo
echo "reaching Traefik itself, from inside the cluster, the same edge a real visitor's request hits:"
kubectl run curler --image=curlimages/curl --restart=Never --command -- sleep 300 > /dev/null
kubectl wait --for=condition=Ready pod/curler --timeout=30s > /dev/null

echo
echo "a request with Host: site-a.localhost:"
a_response=$(kubectl exec curler -- curl -s -H "Host: site-a.localhost" http://traefik/)
echo "$a_response" | head -2

echo
echo "the SAME address, a DIFFERENT Host header -- site-b.localhost:"
b_response=$(kubectl exec curler -- curl -s -H "Host: site-b.localhost" http://traefik/)
echo "$b_response" | head -2

echo
echo "a host neither Ingress named at all:"
unmatched_code=$(kubectl exec curler -- curl -s -o /dev/null -w '%{http_code}' -H "Host: nowhere.localhost" http://traefik/)
echo "status code for an unmatched host: $unmatched_code"

kubectl delete pod curler --grace-period=0 --force > /dev/null 2>&1
kubectl delete -f /tmp/cd-course-ingress.yaml > /dev/null 2>&1
rm -f /tmp/cd-course-ingress.yaml

check "the site-a host reached the site-a backend specifically" "$(echo "$a_response" | grep -c 'Name: site-a')" "1"
check "the site-b host, same address, reached a DIFFERENT backend" "$(echo "$b_response" | grep -c 'Name: site-b')" "1"
check "an unrecognised host is refused, not silently routed somewhere" "$unmatched_code" "404"

What it printed, when the build ran it

deployment.apps/site-a created
service/site-a created
ingress.networking.k8s.io/site-a created
deployment.apps/site-b created
service/site-b created
ingress.networking.k8s.io/site-b created
deployment.apps/site-a condition met
deployment.apps/site-b condition met

reaching Traefik itself, from inside the cluster, the same edge a real visitor's request hits:

a request with Host: site-a.localhost:
Name: site-a
Hostname: site-a-6cb9b55b6c-rstqd

the SAME address, a DIFFERENT Host header -- site-b.localhost:
Name: site-b
Hostname: site-b-55bc7d84cc-4xdqd

a host neither Ingress named at all:
status code for an unmatched host: 404
ok   the site-a host reached the site-a backend specifically
ok   the site-b host, same address, reached a DIFFERENT backend
ok   an unrecognised host is refused, not silently routed somewhere

What just proved itself, concretely. Two isolated Deployments never exchanged any information -- the ONLY thing connecting a request to a specific backend was the Host header Traefik read and matched against each Ingress's host: field. This is the real mechanism behind hosting many unrelated applications behind one load balancer address, which is exactly how a single ALB in Part 6 could plausibly front an entire company's set of services.

What Ingress replaces, and what it does not

  • Without Ingress: every Service needing external traffic would need its own cloud load balancer (a real, per-service cost) or a manual port-forward -- not the pattern this lesson just proved is possible with one address.
  • With Ingress: one entry point, one IP or DNS name, routing by hostname and path to as many backends as there are Ingress objects -- each team owning their own Ingress resource without touching anyone else's.
  • What this lesson did NOT cover: TLS termination (a real Ingress also handles HTTPS certificates, commonly via cert-manager) and the Gateway API, the newer, more expressive standard SIG Network is steering new projects toward -- both real and worth knowing exist, outside this course's scope.

Part 11, end to end

A Deployment keeps a Pod's replica count true (lesson 35). A Service gives a moving set of Pods one stable, internal address (lesson 36). A ConfigMap or Secret lets the exact same image behave differently per environment (lesson 37). An Ingress takes one of those Services and makes it reachable from outside at all (this lesson). None of the four exists without the others actually mattering.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.