You will define a Deployment and a Service in YAML, apply them, then perform a rolling update and rollback. By the end you will manage workloads declaratively, the way real clusters are operated under GitOps.

In the Kubernetes Basics lab you created workloads imperatively with kubectl create. That works for learning, but it leaves no record of what the cluster is supposed to look like. In this lab you switch to the declarative model: you describe the desired state in manifest files, hand them to the API server with kubectl apply, and let the controllers converge the cluster toward that state. This is the workflow used in production, manifests live in Git, changes are reviewed as pull requests, and a CI/CD pipeline (or a GitOps controller such as Argo CD or Flux) applies them.

Concretely, you will deploy a 3-replica nginx workload, front it with a stable ClusterIP Service, push a new image version through a zero-downtime rolling update, and then roll the release back in a single command.

Learning Objectives

  • Write Deployment and Service manifests.
  • Perform a zero-downtime rolling update.
  • Roll back a bad release in one command.
  • Verify Service endpoints and understand how label selectors wire everything together.
  • Time: ~3 hours · Difficulty: Intermediate · Prereqs: a running cluster from the basics lab.

Architecture Overview

Three object types cooperate here, and it is worth being precise about the division of labour:

  • Deployment, the object you manage. It declares the desired replica count, the pod template, and the update strategy. It never runs containers itself.
  • ReplicaSet, created and owned by the Deployment. Its only job is to keep N identical pods running. During a rolling update you briefly have two ReplicaSets: the old one scaling down and the new one scaling up.
  • Pod, the actual running unit, one or more containers sharing a network namespace. Pods are ephemeral and get new IPs every time they are recreated, which is exactly why you never address them directly.
  • Service, a stable virtual IP (ClusterIP) and DNS name that load-balances across every pod whose labels match its selector. Because it matches on labels, pods can be killed and recreated freely and the Service keeps working.

The glue in the whole system is the label app: web: the Deployment selector matches pod template labels, and the Service selector matches the same labels on the running pods. If any of those three label sets drift apart, the workload either becomes unmanaged or loses its endpoints, this is the single most common mistake in this lab.

Notice what is deliberately absent from this design: you never create a bare Pod manifest for a real workload. A standalone pod is scheduled exactly once, if its node dies or the pod is evicted, nothing recreates it. The Deployment/ReplicaSet layering exists to make pods disposable: any pod can be deleted at any moment and the controller replaces it from the template, which is what the self-healing check in the validation section demonstrates.

On the traffic side, the Service object itself is only a control-plane record. The actual packet forwarding is done by kube-proxy on each node, which programs iptables/IPVS rules mapping the ClusterIP to the live endpoint IPs. That is why kubectl get endpoints is such a useful debugging view: it shows exactly which backends kube-proxy will pick from.

Environment Setup

Prerequisites

  • A running cluster (kind or AKS) with kubectl configured.
  • A container image you can version (any registry you can push to, or a public image with multiple tags).
  • Assumed skills: YAML indentation and Kubernetes basics (pods, ReplicaSets).

You will need: a running cluster and kubectl context set.

Before you begin: keep manifests in a directory so you can version them in Git.

Confirm kubectl is talking to the cluster you expect before touching anything. Applying a manifest against the wrong context is a classic way to deploy a test workload into a shared cluster.

kubectl config current-context && kubectl cluster-info
Should print your local context (e.g. kind-lab) and the control plane address.

Confirm at least one node is Ready to accept pods before you deploy anything:

$ kubectl get nodes
NAME STATUS ROLES AGE VERSION lab-control-plane Ready control-plane 2d v1.29.2

If the cluster from the basics lab is gone (kind clusters do not survive a Docker restart), recreate it with kind create cluster --name lab and re-check the two commands above.

Create a working directory for the manifests and, if you plan to commit them, initialise it as a Git repository now rather than later, the whole point of this lab is that the YAML is the source of truth.

mkdir -p k8s-lab/manifests && cd k8s-lab/manifests && git init
All kubectl commands below are run from this directory.

Everything in this lab runs in the default namespace to keep the commands short. On a real cluster you would create a dedicated namespace per app or team and scope your work with -n or kubectl config set-context --current --namespace=....

Step-by-Step Execution

01
Write the Deployment + Service

The manifest below declares both objects in one file, separated by --- (a standard multi-document YAML separator). Save it as web.yaml. A Deployment manifest has four parts worth understanding line by line:

  • apiVersion: apps/v1 and kind: Deployment, which API group and object type you are declaring. Deployments live in the apps group, not the core v1 group.
  • spec.replicas: 3, the desired state. The ReplicaSet controller will create pods until three are running, and recreate any that die.
  • spec.selector.matchLabels, how the Deployment knows which pods belong to it. This must match spec.template.metadata.labels exactly, or the API server rejects the manifest.
  • spec.template, the pod blueprint. Every pod the ReplicaSet creates is stamped from this template, including the pinned image nginx:1.25.
# web.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: web }
spec:
  replicas: 3
  selector: { matchLabels: { app: web } }
  template:
    metadata: { labels: { app: web } }
    spec:
      containers:
        - name: web
          image: nginx:1.25
---
apiVersion: v1
kind: Service
metadata: { name: web }
spec:
  selector: { app: web }
  ports: [{ port: 80 }]

Expected output:

$ kubectl apply --dry-run=client -f web.yaml
deployment.apps/web created (dry run)
service/web created (dry run)

The Service is deliberately minimal: selector: { app: web } tells it to send traffic to every pod carrying that label, and port: 80 is the port the Service itself listens on. Because no type is given, it defaults to ClusterIP, reachable only from inside the cluster. No targetPort is set either, so traffic is forwarded to the same port number (80) on the pods, which is where nginx listens by default.

Common mistake: writing the Service selector as app: web-svc or the pod label as app: frontend "to be descriptive". The objects are wired together purely by these strings, they must match character for character. If you are ever unsure of a field, kubectl explain deployment.spec.template prints the API documentation for any field path.

Finally, validate the file before it ever touches the cluster. A client-side dry run catches schema errors (bad indentation, a wrong apiVersion) instantly, and once the objects exist, kubectl diff previews exactly what a future apply would change:

kubectl apply -f web.yaml --dry-run=client
Prints "created (dry run)" per object; no cluster state is changed.
$ kubectl diff -f web.yaml
(empty output after a successful apply means the file and the cluster agree)

What this teaches: The manifest is the application as data; Deployment plus Service is the smallest complete shippable unit.

02
Apply it

kubectl apply is declarative: it diffs your file against the live state and makes only the changes needed. Re-running it with an unchanged file is a no-op, which is why it, not kubectl create, is the command you script and commit to CI. Follow it immediately with rollout status, which blocks until all replicas are updated and ready, so your terminal prompt returning means the deployment actually succeeded.

kubectl apply -f web.yaml && kubectl rollout status deploy/web
$ kubectl apply -f web.yaml
deployment.apps/web created service/web created
$ kubectl rollout status deploy/web
Waiting for deployment "web" rollout to finish: 0 of 3 updated replicas are available... Waiting for deployment "web" rollout to finish: 2 of 3 updated replicas are available... deployment "web" successfully rolled out

Now verify what was actually created. You should see three running pods (each with a random suffix, the first chunk is the ReplicaSet hash, the second the pod hash) and a Service with a cluster-internal IP:

$ kubectl get pods -l app=web -o wide
NAME READY STATUS RESTARTS AGE IP NODE web-584f6c6d9b-4k2mz 1/1 Running 0 45s 10.244.0.8 lab-control-plane web-584f6c6d9b-7xvqp 1/1 Running 0 45s 10.244.0.9 lab-control-plane web-584f6c6d9b-t9rnw 1/1 Running 0 45s 10.244.0.10 lab-control-plane
$ kubectl get svc web && kubectl get endpoints web
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE web ClusterIP 10.96.143.21 <none> 80/TCP 1m NAME ENDPOINTS AGE web 10.244.0.10:80,10.244.0.8:80,10.244.0.9:80 1m

The endpoints output is the proof that the label wiring works: the Service's virtual IP 10.96.143.21 is backed by exactly the three pod IPs from the first command. If the endpoints list were <none>, the selector does not match any pod labels.

The Deployment's own status row is the compact summary operators watch:

$ kubectl get deploy web
NAME READY UP-TO-DATE AVAILABLE AGE web 3/3 3 3 2m

UP-TO-DATE counts pods running the latest template revision; AVAILABLE counts pods passing readiness (with no probes defined, that simply means "container started"). During a rollout these two columns briefly disagree, that gap is the update in progress.

How to verify: READY 1/1 on all three pods, the rollout status line "successfully rolled out", and three IPs in the endpoints list. All three must be true before moving on.

Expected output:

$ kubectl apply -f web.yaml
deployment.apps/web created
service/web created

What this teaches: Apply is the only deployment verb you need; it creates or updates toward the declared state.

03
Roll out a new image version

Now simulate shipping a new release: bump nginx from 1.25 to 1.27. kubectl set image patches the Deployment's pod template (the container name before the = must match the name: web in your manifest). Changing the template triggers the Deployment's default update strategy, RollingUpdate: it creates a new ReplicaSet for nginx:1.27, scales it up one pod at a time, and only scales an old pod down once a new one is Ready. With the defaults maxSurge: 25% and maxUnavailable: 25%, the Service always has healthy pods behind it, that is what makes the update zero-downtime for containers that report ready promptly. A readiness probe is what lets Kubernetes hold traffic until the new container actually serves, add one before relying on this in production.

$ kubectl set image deploy/web web=nginx:1.27 && kubectl rollout status deploy/web
deployment "web" successfully rolled out

To watch the swap happen in real time, open a second terminal before running the update and stream the pod list:

kubectl get pods -l app=web -w
You will see new pods enter ContainerCreating then Running while old pods Terminate one by one. Ctrl+C to exit the watch.

Catch the rollout mid-flight and the ReplicaSet view tells the whole story, the old ReplicaSet scaling down while the new one scales up:

$ kubectl get rs -l app=web
NAME DESIRED CURRENT READY AGE web-584f6c6d9b 2 2 2 8m web-7d9c4b58f7 2 2 1 20s

Each ReplicaSet name embeds a hash of its pod template, which is how the Deployment tells revisions apart. When the rollout completes, the old ReplicaSet sits at 0 replicas but is kept around, it becomes your rollback target in step 04.

Every template change is recorded as a numbered revision. This history is what makes the next step possible:

$ kubectl rollout history deploy/web
REVISION CHANGE-CAUSE 1 <none> 2 <none>

Common mistake: the CHANGE-CAUSE column shows <none> because nothing recorded why the change happened. On real projects annotate each release so the history is self-documenting: kubectl annotate deploy/web kubernetes.io/change-cause="bump nginx 1.25 to 1.27". Also note the image tag is explicit (1.27, not latest), with latest the pod template hash does not change, so no rollout is triggered at all.

How to verify: kubectl get deploy web -o jsonpath='{.spec.template.spec.containers[0].image}' should print nginx:1.27, and kubectl get rs should show two ReplicaSets, the new one at 3 replicas, the old one scaled to 0 but retained for rollback.

Expected output:

$ kubectl set image deploy/web web=nginx:1.27
deployment.apps/web image updated
$ kubectl rollout status deploy/web
deployment "web" successfully rolled out

What this teaches: Rolling out a new image is changing one field and letting the controllers converge; watch the ReplicaSets trade places.

04
Roll back if needed

Pretend the 1.27 release introduced a regression and you need to revert now. Because the old ReplicaSet was scaled to zero rather than deleted, a rollback is just a pointer flip: the Deployment scales the previous ReplicaSet back up and the new one down. It is the same rolling mechanism in reverse, so it is also zero-downtime under the same readiness conditions.

kubectl rollout undo deploy/web

That reverts to the immediately previous revision. To jump to a specific older revision, pick its number from the history:

kubectl rollout undo deploy/web --to-revision=1
Use the plain undo first; only pin a revision when you need to skip over several bad releases.
$ kubectl rollout status deploy/web && kubectl get deploy web -o jsonpath='{.spec.template.spec.containers[0].image}'; echo
deployment "web" successfully rolled out nginx:1.25

One subtlety: a rollback does not erase history, it creates a new revision whose template is a copy of the one you reverted to. After the undo, kubectl rollout history shows revision 3 with the nginx:1.25 template, revisions only ever move forward.

$ kubectl rollout history deploy/web
REVISION CHANGE-CAUSE 2 <none> 3 <none>

Revision 1 disappeared from the list because its template now lives at revision 3, Kubernetes renumbers the newest copy rather than resurrecting the old slot. The change-cause annotation habit from step 03 is what keeps this list meaningful on a real project.

How to verify: the image is back to nginx:1.25, all pods show Running, and kubectl get endpoints web still lists three healthy IPs. In a real incident you would then curl the Service (next section) to confirm the app itself, not just the pods, is healthy.

Expected output:

$ kubectl rollout undo deploy/web
deployment.apps/web rolled back

What this teaches: Rollback scales the previous ReplicaSet back up; it is fast because the old pods' definition never left etcd.

Step Flow Map

Each box is one step in the lab; work them in order, since later steps build on earlier state.

Progress So Far

How It Works

A Deployment is a declaration of desired state for stateless pods: the image, the replica count, and the update strategy. The Deployment controller does not manage pods directly; it manages a ReplicaSet, which maintains the count. This indirection is what makes rolling updates possible: the new ReplicaSet scales up while the old scales down, and both exist simultaneously mid-rollout.

The rolling update strategy is governed by two knobs: maxUnavailable (how many pods may be down during the update) and maxSurge (how many extra pods may exist). The defaults (25 percent each) mean a rolling update never takes the service fully down and never more than doubles capacity. Changing these trades speed against safety, which is a deliberate operational decision, not a default to accept blindly.

Every rollout writes a new revision into the ReplicaSet history stored in etcd. Rollback is not undoing the YAML; it is scaling the previous ReplicaSet back up, which is why it is fast and reliable. The revision history is bounded (default 10), so old rollback targets eventually age out; annotate significant rollouts so future you knows what each revision was.

Testing & Validation

Passing rollout status proves Kubernetes reached the desired state, but it does not prove the application answers traffic. Validate end to end in three parts.

1. Confirm the object state. The history should show the revisions you created, and the Service should have a stable ClusterIP:

kubectl rollout history deploy/web && kubectl get svc web

You should see multiple revisions and a Service with a ClusterIP. If so, you can deploy, update, and undo declaratively.

2. Prove the Service actually routes traffic. A ClusterIP exists only inside the cluster network, so test it from a throwaway pod, not from your laptop:

$ kubectl run curl-test --rm -i --image=curlimages/curl:8.22.0 --command -- curl -s -o /dev/null -w "%{http_code}\n" http://web
200

Two things are being validated at once here: cluster DNS resolves the bare name web to the Service's ClusterIP, and kube-proxy routes that IP to one of the healthy pods. The --rm flag deletes the test pod automatically when the command finishes, so nothing is left behind. --command overrides the image entrypoint so the trailing curl invocation runs as the container command instead of being appended to it, and -- separates kubectl flags from that command.

3. Prove the Service follows scaling. Scale to five replicas and watch the endpoints list grow to match, this demonstrates that the Service tracks labels, not fixed pod IPs:

$ kubectl scale deploy/web --replicas=5 && kubectl rollout status deploy/web
deployment.apps/web scaled deployment "web" successfully rolled out
$ kubectl get endpoints web
NAME ENDPOINTS AGE web 10.244.0.10:80,10.244.0.11:80,10.244.0.12:80,10.244.0.8:80 + 1 more... 12m

Scale back to 3 when done (kubectl scale deploy/web --replicas=3) so the live state matches the manifest in Git, drift between the file and the cluster is exactly what declarative management is supposed to prevent.

Validation is complete. Tear the lab workloads down last, nothing validates after this point by design:

kubectl delete -f web.yaml

Expected output:

$ kubectl delete -f web.yaml
deployment.apps "web" deleted
service "web" deleted

Troubleshooting

Quick-reference for the failures you are most likely to hit in this lab.

SymptomLikely causeFix
kubectl apply rejects the manifestYAML indentation or a wrong API versionValidate with --dry-run=client; the error names the exact line.
Pods stuck in CrashLoopBackOffThe new image exits immediatelyRead the logs of the previous container; the rollout is fine, the image is broken.
Rollout hangs mid-updateReadiness probe never passes on the new podsDescribe a new pod; the events show which probe fails and why.
Service sends no trafficSelector does not match the pod labelsCompare the service selector against pod labels; one typo breaks it all.
Rollback went to the wrong revisionRevisions counted without checkingRead rollout history first; the numbers only mean something with the annotations.
Troubleshooting
  • Rollout stuck: a new pod is crashing; inspect with kubectl describe and logs. Run kubectl describe pod -l app=web and read the Events section at the bottom, it names the failing container and reason. For application-level errors, kubectl logs deploy/web --tail=50. A rollout stuck in CrashLoopBackOff never goes unhealthy on the old pods, which is safe but means the new release is not live; fix the image/config and re-apply, or kubectl rollout undo.
  • Service has no endpoints: the selector labels must match the pod labels exactly. Confirm with kubectl get endpoints web, if it shows <none>, compare kubectl get svc web -o jsonpath='{.spec.selector}' against kubectl get pods --show-labels. A single-character difference means zero matches. Fix the manifest and re-apply; do not patch pod labels by hand.
  • Image not updating: use a specific tag, not latest, so changes are detected. Re-tagging an image under latest changes nothing in the pod template, so the Deployment sees no diff and rolls nothing. Pin a real version tag, or force a restart with kubectl rollout restart deploy/web when you genuinely want to re-pull the same tag.
  • curl to the ClusterIP times out from your laptop: that is expected, not a bug. ClusterIP addresses exist only on the cluster's internal virtual network. Test from a pod (as in the validation step) or bridge your laptop in temporarily with kubectl port-forward svc/web 8080:80 and curl http://localhost:8080. To expose the app properly, use a NodePort/LoadBalancer Service or an Ingress (covered in the networking lab).
  • Pods stuck in Pending: the scheduler cannot place them. kubectl describe pod shows an event like Insufficient cpu or a taint/toleration mismatch. On a small local cluster, reduce replicas or add resource requests small enough to fit; the Deployment is fine, the cluster is just full.

Extension Ideas

  • Expose externally and add ingress in Kubernetes Networking.
  • Add readiness/liveness probes and resource limits. A readinessProbe on port 80 would make the rolling update genuinely safe, today Kubernetes considers a pod ready the moment the container starts, so a slow-booting app could still drop requests mid-rollout.
  • Force a template change without touching the image: set an environment variable or annotation with kubectl patch, watch the new rollout, then find it in the history.
  • Manage manifests with Kustomize or Helm under GitOps, put web.yaml in a repo and let Argo CD reconcile it, then try editing the cluster by hand and watch GitOps revert your change.

Key Takeaways

  • Declarative beats imperative: the YAML manifest is the source of truth, kubectl apply converges the cluster to it, and the file belongs in Git.
  • A Deployment never runs pods directly, it owns ReplicaSets, which own pods. Understanding that chain explains where rollout history and rollback capacity come from.
  • Rolling updates are safe by default because of maxSurge/maxUnavailable: new pods must become Ready before old ones are removed.
  • Services select pods by label, not by IP, so they survive pod churn, rollouts, and scaling without any reconfiguration.
  • rollout status, rollout history, and rollout undo are the everyday lifecycle trio, know them before touching a production release.
  • ClusterIP is internal-only; reaching it from outside requires port-forward for debugging or a NodePort/LoadBalancer/Ingress for real exposure.

Key Results

  • Deployed a 3-replica workload from declarative YAML.
  • Performed a zero-downtime rolling update.
  • Rolled back a release in a single command.
  • Exposed pods through a stable Service ClusterIP.
  • Verified routing end to end with an in-cluster curl and confirmed endpoints track scaling.

Practice Challenges

Challenge 1: Feel the Recreate strategy

Goal: Switch the strategy to Recreate, roll out, and observe the downtime window.

Hint: The outage is the lesson; this is why RollingUpdate is the default.

Challenge 2: Break the probes

Goal: Add readiness and liveness probes, then deploy an image that fails them, and watch the rollout stall.

Hint: The stalled rollout protecting the old version is the safety mechanism working.

Challenge 3: Manifest vs imperative scale

Goal: Scale with kubectl scale, then diff against the manifest to see the drift.

Hint: The manifest still says 2 while reality says 5; decide which one is the source of truth.