Lesson 02 · Serving
Deploy your first site
Put a real website on the cluster and reach it from a browser — tracing the whole request path: Pod → Deployment → Service → Ingress. This is the skeleton every site you ever host will use.
The mental model: the request path
One HTTP request from your browser travels right to left through four objects. Read it as a sentence: an Ingress routes a hostname to a Service, which load-balances to Pods, which a Deployment keeps alive.
Pod — the unit that runs
The smallest deployable thing: one (or a few) containers sharing an IP and storage. Pods are cattle, not pets — disposable, and their IP changes when they're recreated.
Deployment — the controller that keeps them running
You don't create Pods directly. You declare a Deployment: "I want 2 replicas of this Pod template." Its controller continuously reconciles reality to that desire — kill a Pod and it makes a new one; change the image and it rolls the change out gradually.
Service — the stable front door
A Service gives a stable virtual IP + in-cluster DNS name. It
finds its Pods by label selector (not by IP) and load-balances
across them. Default type ClusterIP = reachable only inside
the cluster.
NodePort or LoadBalancer to serve
the web. Keep the Service internal (ClusterIP) and let the
Ingress be the single public door. Cleaner, and how real hosting works.Ingress — the HTTP router at the edge
An Ingress is an L7 rule: "requests for host hello.local
go to Service hello-site." It's executed by an ingress controller
(the microk8s enable ingress addon — Traefik on 1.35+, exposed via the
public IngressClass). One controller can route many hostnames → one site each.
Do it — on the machine that talks to your cluster
Turn on the addons you need
$ microk8s enable dns ingress
$ kubectl get ingressclass # confirm a class exists; we'll use "public"
dns (CoreDNS) is what lets a Service be found by name inside
the cluster. Without it, the name-based wiring above silently fails.Write the whole site as one manifest
Save as hello-site.yaml. Three objects, one file, separated by
---. Read it top-to-bottom and predict what each does:
apiVersion: apps/v1
kind: Deployment
metadata: { name: hello-site }
spec:
replicas: 2
selector: { matchLabels: { app: hello-site } }
template:
metadata: { labels: { app: hello-site } } # Pods get this label…
spec:
containers:
- name: web
image: nginx:1.27
ports: [ { containerPort: 80 } ]
---
apiVersion: v1
kind: Service
metadata: { name: hello-site }
spec:
selector: { app: hello-site } # …Service finds Pods by it
ports: [ { port: 80, targetPort: 80 } ]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata: { name: hello-site }
spec:
ingressClassName: public
rules:
- host: hello.local
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: hello-site, port: { number: 80 } }
app: hello-site appears three times: the Pod template
sets it, the Deployment selector watches it, the Service selector targets it. Break
that string in one place and traffic silently goes nowhere. Labels are the glue.Apply it and watch reconciliation happen
$ kubectl apply -f hello-site.yaml
$ kubectl get deploy,pods,svc,ingress # the whole chain at once
$ kubectl rollout status deploy/hello-site
Watch the Deployment bring up 2/2 Pods. You declared desire; the
controller made it real — no create-pod step anywhere.
Serve it to your browser
The ingress controller listens on the node's port 80. Point the hostname at the VM's IP, then visit it.
$ multipass list # grab the VM IP, e.g. 192.168.64.5
# add to /etc/hosts on the machine with the browser:
$ echo "192.168.64.5 hello.local" | sudo tee -a /etc/hosts
$ curl http://hello.local/ # or open it in a browser
The nginx welcome page = a website you hosted on Kubernetes. 🎉
Debug like you mean it
Your mission is to fix things with kubectl, not guess. When a site
is down, walk the chain with these four verbs:
$ kubectl get pods # Running? CrashLoop? Pending?
$ kubectl describe pod <name> # events at the bottom tell the story
$ kubectl logs <pod> # what the app itself printed
$ kubectl get events --sort-by=.lastTimestamp # cluster-wide recent history
describe + logs (app/image
problem). Pod Running but site unreachable → check the
label/selector thread and the Ingress host (wiring problem). Decide which half
you're in first.Why must traffic reach Pods through a Service, never by Pod IP?
- Pod IPs change when recreated
- Pods cannot accept any traffic
- Services run faster than pods
- Pod IPs are always private
Pods are disposable; their IPs are unstable. The Service gives a stable name/IP and finds current Pods by label selector.
To serve a website publicly, which object is the public door?
- The Ingress routes the hostname
- The ClusterIP Service exposes ports
- The Deployment publishes the page
- The Pod opens the firewall itself
Keep the Service ClusterIP (internal); the Ingress is
the single public entry point that maps a hostname to that Service.
From Lesson 01: what makes your kubectl reach this cluster at all?
- The kubeconfig server and creds
- The running ingress addon alone
- The Deployment replica count value
- The node label on the worker
Interleaving check: none of today's objects matter if kubectl can't reach the API server — that's the kubeconfig from Lesson 01.
Kubernetes
— Ingress (official concept page). The authoritative model for the routing layer.
Pair it with the microk8s
ingress addon doc for the public/traefik class specifics.
Good questions now: "What's the difference between
port and
targetPort?", "What actually happens during a rolling update?", "How do I
serve my own HTML instead of the nginx page?" (that's Lesson 03).