Deploy a Containerized App to Kubernetes

Run a multi-node Kubernetes cluster locally and deploy with self-healing.

Introduction

30 Second Summary

When your app runs inside a single container, one crash means it's gone until someone manually restarts it. In production, teams need containers that automatically recover, scale, and update without anyone waking up at 3 AM.

In this project, you will deploy a containerized app to a local Kubernetes cluster, experience firsthand why raw containers die permanently, and fix it with a production-style Deployment that self-heals and rolls updates with zero downtime.

What You'll Build

A running app on a 3-node Kubernetes cluster where you can kill any pod, watch it respawn in seconds, and push a new version while traffic keeps flowing without interruption.

By the end of this project, you'll have:

  • A multi-node cluster running locally on your machine with Kind, where traffic is balanced across replicas so you can see different pods serving each request.
  • Self-healing deployments that automatically replace crashed pods within seconds, proving your app survives failures without manual intervention.
  • Zero-downtime rolling updates where you push a new version and watch old pods drain gracefully while new ones take over, with health checks keeping unhealthy pods out of traffic rotation.
  • Secret Mission: Add environment configuration with ConfigMaps and perform a rollback to a previous deployment version.

Are there any prerequisites?

You need Docker Desktop installed and running, familiarity with building Docker images, and a GitHub account.

Before We Start

Before we dive into clusters and containers, let's lock in what you're about to build and why it matters. This step is your moment to commit to the goal before any hands-on work begins.

Install Kind and kubectl, Create Your Cluster

To practice Kubernetes, you need a real multi-node cluster. Cloud clusters cost money and take time to provision.

Kind (Kubernetes in Docker) gives you a production-grade cluster in about 30 seconds using Docker containers as nodes. In this step, you will install Kind and kubectl, then spin up a 3-node cluster on your local machine.

In this step, get ready to:
  • Verify that kubectl is available and install Kind.
  • Create a Kind cluster configuration file defining 3 nodes.
  • Create a multi-node cluster and confirm all nodes are ready.
Check if kubectl is already available

Docker Desktop for Windows bundles its own copy of kubectl. You may already have it without realising.

  • Open your terminal by pressing the Windows key, typing PowerShell, and pressing Enter.
  • Check if kubectl is already on your system by running:
kubectl version --client

✔️ I see a version number

You should see output that includes a client version string. kubectl is already available from Docker Desktop. Skip ahead to installing Kind below.

ⓧ Command not found

Your Docker Desktop installation did not add kubectl to your PATH. Install it with winget by running:

winget install -e --id Kubernetes.kubectl
  • Close and reopen your PowerShell window so the new PATH takes effect.
  • Confirm it installed by running:
kubectl version --client

You should now see a client version string printed.

Install Kind

Kind is the tool that creates your local Kubernetes cluster using Docker containers as cluster nodes.

  • Install Kind by running:
winget install Kubernetes.kind
  • Close and reopen your PowerShell window so the new PATH takes effect.
  • Confirm Kind installed by running:
kind version

You should see a version string that includes v0.32.0 or higher.

Seeing 'kind: command not found'?

Make sure you closed and reopened PowerShell after the install. winget adds Kind to your PATH, but existing terminal sessions do not pick it up until restarted.

help me fix 'kind: command not found' after installing with winget on Windows

Create the cluster configuration file

Kind uses a YAML config file to define how many nodes your cluster should have and what role each node plays.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir hello-k8s
cd hello-k8s
  • Confirm the folder exists by running:
ls

You should see an empty directory listing inside your new hello-k8s folder.

  • Create the Kind configuration file by running:
New-Item kind-config.yaml
  • Open the hello-k8s folder in your text editor.
  • Open kind-config.yaml and paste this configuration:
# Define a 3-node cluster: 1 control-plane + 2 workers
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
- role: worker

What does this config do?

  • kind: Cluster tells Kind this file describes a cluster.
  • apiVersion: kind.x-k8s.io/v1alpha4 sets the config schema version Kind expects.
  • The nodes list defines three Docker containers. One runs the control-plane (the brain that schedules work). The other two are workers that actually run your app's containers.
  • Save kind-config.yaml.

✔️ Awesome, I've got everything!

Great. Make sure you saved the file before continuing.

ⓧ I'd like to double check the full code

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
- role: worker
Create the cluster and verify all nodes are ready

Now you will use your config file to spin up a 3-node cluster. Kind creates each node as a Docker container and bootstraps Kubernetes inside them.

  • Create the cluster by running this command:
kind create cluster --config kind-config.yaml --name dev-cluster

What does this command do?

  • kind create cluster bootstraps a Kubernetes cluster using pre-built node images.
  • --config kind-config.yaml points Kind to your multi-node config file.
  • --name dev-cluster gives the cluster a name. Your kubectl context will be set to kind-dev-cluster automatically.

This takes about 30-60 seconds. You will see progress indicators as Kind pulls node images and configures networking.

Cluster creation stuck or erroring?

Make sure Docker Desktop is running. Kind needs a working Docker daemon to create container nodes.

If you see a timeout, check that Docker is not paused by looking for the Docker whale icon in your system tray. It should be green.

help me fix Kind cluster creation failing on Windows

Before you run the next command, what do you think you will see? How many nodes, and what status do you expect?

  • Verify all three nodes are up and healthy by running:
kubectl get nodes

You should see three nodes listed: one with the role control-plane and two workers. All three should show STATUS Ready.

What is a Node in Kubernetes?

A Node is a machine (physical or virtual) that runs your containerized workloads. The control-plane node manages scheduling and cluster state. Worker nodes are where your application Pods actually run.

In Kind, each node is just a Docker container pretending to be a separate machine. This gives you a realistic multi-node experience without needing multiple physical servers.

Nodes not showing Ready?

If one or more nodes show NotReady, wait 30 seconds and run kubectl get nodes again. Nodes can take a moment to finish their internal networking setup.

help me troubleshoot Kind nodes stuck in NotReady status

Your cluster is live. You have a real multi-node Kubernetes environment running entirely on your machine. Next up, you will deploy your first container into this cluster and see what happens when it dies.

Deploy Your App the Simple Way (Raw Pod)

Your cluster is running with three nodes ready and waiting. Now you need an application to actually run on it.

The fastest unit you can deploy to Kubernetes is a Pod. It wraps one or more containers and gives them shared networking. In this step, you will deploy your app as a raw Pod to see how quickly you can get code running on a cluster.

In this step, get ready to:
  • Build a containerized Node.js app and load the image into your Kind cluster.
  • Deploy the app as a raw Pod and access it through port-forwarding.
  • Delete the pod and observe what happens to it.
Create the app and build a Docker image

You need a simple web application that responds to HTTP requests. This app will run inside a container on your cluster.

  • Create a file called server.js in your project folder (the same folder where kind-config.yaml lives).
  • Paste this code into server.js:
const http = require("http");

const port = 3000;

const server = http.createServer((req, res) => {
  // Health check endpoint for Kubernetes probes
  if (req.url === "/healthz") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ status: "healthy" }));
    return;
  }

  // Main response showing version and which pod is serving
  res.writeHead(200, { "Content-Type": "text/html" });
  res.end(
    "<h1>Hello from Kubernetes! (v1)</h1>" +
    "<p>Hostname: " + (process.env.HOSTNAME || "unknown") + "</p>"
  );
});

server.listen(port, () => {
  console.log("Server v1 running on port " + port);
});

What does this code do?

This is a minimal Node.js HTTP server with two routes.

  • The /healthz endpoint returns a JSON status. Kubernetes will use this later to check if the app is alive.
  • Every other request returns an HTML page showing the app version and the hostname of the pod serving it.
  • The HOSTNAME environment variable is automatically set by Kubernetes to the pod name. This lets you see which pod handled a request.
  • Save server.js.
  • Confirm it exists by running:
ls

You should see server.js alongside your kind-config.yaml.

Don't see server.js?

  • Make sure you saved the file in the correct folder. Run cd ~/Desktop and then ls to check you are in the right place.
  • Confirm the filename is exactly server.js with no extra extension like .txt.

Why isn't my file showing up in ls?

✔️ Awesome, I've got everything!

Great. Make sure server.js is saved and move on to the Dockerfile.

ⓧ I'd like to double check the full code

const http = require("http");

const port = 3000;

const server = http.createServer((req, res) => {
  // Health check endpoint for Kubernetes probes
  if (req.url === "/healthz") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ status: "healthy" }));
    return;
  }

  // Main response showing version and which pod is serving
  res.writeHead(200, { "Content-Type": "text/html" });
  res.end(
    "<h1>Hello from Kubernetes! (v1)</h1>" +
    "<p>Hostname: " + (process.env.HOSTNAME || "unknown") + "</p>"
  );
});

server.listen(port, () => {
  console.log("Server v1 running on port " + port);
});

Now you need a Dockerfile to package this app into a Docker image.

  • Create a file called Dockerfile (no file extension) in the same folder.
  • Paste this code into Dockerfile:
FROM node:22-alpine
WORKDIR /app
COPY server.js .
EXPOSE 3000
CMD ["node", "server.js"]

What does this Dockerfile do?

  • FROM node:22-alpine starts from a lightweight Linux image with Node.js 22 pre-installed.
  • WORKDIR /app sets the working directory inside the container.
  • COPY server.js . copies your app code into the image.
  • CMD ["node", "server.js"] tells Docker to start the server when the container launches.
  • Save Dockerfile.
  • Build your Docker image by running this command:
docker build -t hello-k8s:v1 .

The build pulls the node:22-alpine base image (this may take a minute the first time) and packages your app. When it finishes, your terminal prompt returns with no errors.

Seeing a build error?

  • If you see COPY failed, make sure server.js is in the same folder where you are running the build command.
  • If you see a Docker daemon connection error, confirm Docker Desktop is running.
  • Make sure the filename is exactly Dockerfile with a capital D and no extension.

Why is my Docker build failing?

✔️ Awesome, I've got everything!

Great. Confirm you saved Dockerfile and that the build completed without errors.

ⓧ I'd like to double check the full code

FROM node:22-alpine
WORKDIR /app
COPY server.js .
EXPOSE 3000
CMD ["node", "server.js"]
  • Load the image into your Kind cluster by running this command:
kind load docker-image hello-k8s:v1 --name dev-cluster

This copies the image from your local Docker into each node of the Kind cluster. You will see output indicating the image is being loaded onto the cluster nodes.

Seeing an error loading the image?

  • If you see a message about the cluster not being found, confirm your cluster is named dev-cluster by running kind get clusters.
  • If the image is not found, confirm the build succeeded by running docker images and checking for hello-k8s with tag v1.

Why can't Kind find my Docker image?

Deploy a raw Pod and access your app

Your image is loaded on every node in the cluster. Now you can tell Kubernetes to run it by creating a Pod manifest.

  • Create a k8s folder to hold your Kubernetes manifests by running:
mkdir k8s
  • Create a file called pod.yaml inside the k8s folder.
  • Paste this manifest into k8s/pod.yaml:
apiVersion: v1
kind: Pod
metadata:
  name: hello-app
  labels:
    app: hello
spec:
  containers:
  - name: hello
    image: hello-k8s:v1
    imagePullPolicy: Never
    ports:
    - containerPort: 3000

What does this manifest do?

  • apiVersion: v1 and kind: Pod tell Kubernetes you want to create the simplest deployable unit.
  • metadata.name: hello-app gives this pod a unique name you can reference in commands.
  • imagePullPolicy: Never tells Kubernetes to use the local image you loaded with Kind instead of trying to pull from the internet.
  • containerPort: 3000 declares that the app listens on port 3000 inside the container.
  • Save k8s/pod.yaml.
  • Deploy the pod to your cluster by running:
kubectl apply -f k8s/pod.yaml

You should see pod/hello-app created in the output.

  • Check that the pod is running by running:
kubectl get pods

You should see hello-app with STATUS Running and READY 1/1.

Pod not showing Running?

  • If STATUS shows ErrImageNeverPull, the image was not loaded into Kind. Re-run kind load docker-image hello-k8s:v1 --name dev-cluster.
  • If STATUS shows Pending, wait a few seconds and run kubectl get pods again. The node may still be pulling the image layers locally.

Why is my pod not running?

✔️ Awesome, I've got everything!

Great. Confirm k8s/pod.yaml is saved and your pod shows Running.

ⓧ I'd like to double check the full code

apiVersion: v1
kind: Pod
metadata:
  name: hello-app
  labels:
    app: hello
spec:
  containers:
  - name: hello
    image: hello-k8s:v1
    imagePullPolicy: Never
    ports:
    - containerPort: 3000

Your app is alive inside the cluster, but it is not exposed to your machine yet. You need to forward a local port to the pod.

  • Forward traffic from your machine to the pod by running:
kubectl port-forward pod/hello-app 8080:3000

What does port-forward do?

This command maps port 8080 on your machine to port 3000 inside the pod. It keeps running in your terminal until you stop it.

  • Open your browser and go to http://localhost:8080.

You should see a page with the heading Hello from Kubernetes! (v1) and a hostname like hello-app.

  • Go back to your terminal and press Ctrl+C to stop port-forwarding.
Delete the pod and observe what happens

Your app works. You deployed a container to Kubernetes and accessed it from your browser. But what happens if this pod crashes or gets deleted?

Before you run the next command, think about this: if you delete the pod, do you think Kubernetes will create a new one to replace it?

  • Delete the pod by running:
kubectl delete pod hello-app

You should see pod "hello-app" deleted in the output.

  • Check what pods are still running:
kubectl get pods

You should see No resources found in default namespace. The pod is gone permanently. No replacement is spawning.

Why did nothing replace it?

A raw Pod has no controller watching over it. When it dies, nothing notices and nothing reacts.

In production, pods crash, nodes fail, and updates need to roll out. A bare Pod cannot handle any of these situations. This is why nobody runs raw Pods in production.

Your app vanished with no way to recover. In the next step, you will fix this with a Deployment that watches your pods and automatically replaces any that disappear.

Fix It with a Deployment and Service

Your pod vanished permanently in the last step. No restart, no recovery, no replacement. That is exactly what happens when a raw Pod has no controller watching it.

A Deployment fixes this by adding a reconciliation loop. It constantly checks how many pods should be running and automatically replaces any that die. In this step, you will create a Deployment with 3 replicas, expose it with a Service, and prove that Kubernetes self-heals by deleting a pod and watching it come back.

In this step, get ready to:
  • Create a Deployment manifest with 3 replicas and apply it to your cluster.
  • Expose the Deployment with a Service and test load distribution across pods.
  • Scale up and prove self-healing by deleting a pod and watching Kubernetes replace it.
Write and apply a Deployment manifest

A Deployment wraps your Pod spec in a controller that monitors and maintains a desired replica count. If a pod crashes or gets deleted, the Deployment immediately spins up a replacement.

  • Create a new file called deployment.yaml inside your k8s folder with the following content:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: hello
  template:
    metadata:
      labels:
        app: hello
    spec:
      containers:
      - name: hello
        image: hello-k8s:v1
        imagePullPolicy: Never
        ports:
        - containerPort: 3000

What does this manifest do?

  • replicas: 3 tells Kubernetes to always keep 3 copies of your pod running.
  • selector.matchLabels connects the Deployment to pods that have the label app: hello.
  • template is the pod blueprint. Every replica gets created from this template.
  • imagePullPolicy: Never tells Kubernetes to use the image you loaded locally into Kind instead of trying to pull from a registry.
  • Save deployment.yaml.
  • Apply the Deployment to your cluster by running:
kubectl apply -f k8s/deployment.yaml

You should see deployment.apps/hello-app created in the output.

  • Check that your pods are running and distributed across nodes by running:
kubectl get pods -o wide

You should see 3 pods with STATUS Running. The NODE column shows them spread across your two worker nodes.

Pods stuck in Pending or ImagePullBackOff?

  • If pods show ImagePullBackOff, the image was not loaded into the Kind cluster. Run kind load docker-image hello-k8s:v1 --name dev-cluster again and delete the failing pods so the Deployment recreates them.
  • If pods show Pending, run kubectl describe pod <pod-name> and check the Events section for scheduling errors.

Still stuck? Help me debug why my Kubernetes pods are not running after applying a Deployment manifest.

✔️ Awesome, I've got everything!

Great. Make sure you have saved k8s/deployment.yaml and applied it to your cluster.

ⓧ I'd like to double check the full code

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: hello
  template:
    metadata:
      labels:
        app: hello
    spec:
      containers:
      - name: hello
        image: hello-k8s:v1
        imagePullPolicy: Never
        ports:
        - containerPort: 3000
Create a Service and test load distribution

Your 3 pods are running, but they each have a different internal IP that changes every time a pod restarts. A Service gives your Deployment a single stable endpoint that automatically distributes traffic across all healthy pods.

  • Create a new file called service.yaml inside your k8s folder with this content:
apiVersion: v1
kind: Service
metadata:
  name: hello-service
spec:
  selector:
    app: hello
  ports:
  - port: 80
    targetPort: 3000
  type: ClusterIP

What does this Service do?

  • selector: app: hello connects this Service to any pod with that label (your 3 Deployment pods).
  • port: 80 is the Service's listening port. targetPort: 3000 is the container port it forwards to.
  • type: ClusterIP makes the Service reachable only inside the cluster. You will use port-forwarding to access it from your machine.
  • Save service.yaml.
  • Apply the Service to your cluster by running:
kubectl apply -f k8s/service.yaml

You should see service/hello-service created in the output.

  • Port-forward the Service to your machine by running:
kubectl port-forward service/hello-service 8080:80

What is port-forward doing here?

This command maps localhost:8080 on your machine to port 80 on the Service inside the cluster. The Service then routes traffic to port 3000 on one of the 3 pods. The command runs in the foreground and blocks your terminal until you stop it with Ctrl+C.

  • Open your browser and go to http://localhost:8080.
  • Refresh the page 5-6 times. Watch the Hostname value change between refreshes.

Each hostname is a different pod name. The Service is distributing your requests across all 3 replicas.

Seeing the same hostname every time?

  • Your browser may be caching the response. Try a hard refresh with Ctrl+Shift+R (Windows) or Cmd+Shift+R (macOS).
  • Alternatively, open a private/incognito window and refresh there multiple times.

Still stuck? Help me understand why I see the same pod hostname on every request through my Kubernetes Service.

  • Return to your terminal and press Ctrl+C to stop the port-forward.

✔️ Awesome, I've got everything!

Great. Make sure you have saved k8s/service.yaml and applied it to your cluster.

ⓧ I'd like to double check the full code

apiVersion: v1
kind: Service
metadata:
  name: hello-service
spec:
  selector:
    app: hello
  ports:
  - port: 80
    targetPort: 3000
  type: ClusterIP
Scale up and prove self-healing

You have seen load distribution. Now it is time to prove the real power of a Deployment: self-healing. You will scale up your replicas and then intentionally kill a pod to watch Kubernetes bring it back.

  • Scale your Deployment to 5 replicas by running:
kubectl scale deployment hello-app --replicas=5
  • Confirm all 5 pods are running by running:
kubectl get pods

You should see 5 pods listed, all with STATUS Running. Kubernetes scheduled the 2 new replicas across your worker nodes automatically.

Now for the real test. Before you run the next command, ask yourself: what do you think will happen when you delete one of these pods? Will it vanish permanently like the raw Pod in the last step, or will something different happen?

  • Pick any pod name from the list above and delete it by running:
kubectl delete pod [[POD_NAME="one of your hello-app pod names from the list above"]]
  • Immediately check your pods again by running:
kubectl get pods

You still have 5 pods. One has a brand new name and a very recent AGE value. The Deployment detected the missing pod and created a replacement within seconds. That is self-healing in action.

Why did this pod come back but the raw Pod did not?

A raw Pod is a standalone object with no controller. When it dies, nothing notices.

A Deployment runs a reconciliation loop that constantly compares "desired replicas" (5) against "actual running pods". When the count drops, it immediately creates a new pod from the template to restore the desired state.

Still seeing only 4 pods after deletion?

  • Wait a few seconds and run kubectl get pods again. The replacement pod may still be in ContainerCreating state.
  • Make sure you deleted a pod whose name starts with hello-app-. If you accidentally targeted a system pod, the Deployment count would not have changed.

Need more help? Help me understand why my Deployment is not replacing deleted pods.

Your Deployment self-heals, scales on command, and distributes traffic across pods. Next up, you will add health checks so Kubernetes knows when a pod is truly ready to serve traffic, and perform a zero-downtime rolling update to a new version of your app.

Add Health Checks and Perform a Rolling Update

Your Deployment can self-heal by replacing dead pods. But there's still a dangerous gap. Right now, Kubernetes routes traffic to pods the instant their container starts, even if the app inside is still booting up.

In this step, you'll add readiness probes and liveness probes so Kubernetes knows when a pod is truly ready to serve. Then you'll perform a zero-downtime rolling update from v1 to v2.

In this step, get ready to:
  • Add readiness and liveness probes plus resource limits to your Deployment.
  • Build a v2 image and trigger a rolling update.
  • Watch the rolling update happen live with zero downtime.
Add health checks and resource limits to your Deployment

A readiness probe tells Kubernetes when a pod can accept traffic. A liveness probe tells Kubernetes when to restart a stuck pod. Together, they prevent users from hitting a pod that isn't ready.

  • Open k8s/deployment.yaml in your editor.
  • Find the line that says - containerPort: 3000. Copy the following code directly below it, keeping the indentation aligned:
        resources:
          requests:
            memory: "64Mi"
            cpu: "50m"
          limits:
            memory: "128Mi"
            cpu: "100m"
        readinessProbe:
          httpGet:
            path: /healthz
            port: 3000
          initialDelaySeconds: 5
          periodSeconds: 5
        livenessProbe:
          httpGet:
            path: /healthz
            port: 3000
          initialDelaySeconds: 10
          periodSeconds: 10

What does this code do?

The resources block sets how much CPU and memory each pod is guaranteed (requests) and the maximum it can consume (limits). This prevents one pod from starving others on the same node.

The readinessProbe hits your /healthz endpoint every 5 seconds. If it fails, Kubernetes removes the pod from the Service so it stops receiving traffic.

The livenessProbe hits the same endpoint every 10 seconds. If it fails repeatedly, Kubernetes restarts the container entirely.

  • Save k8s/deployment.yaml.
  • Apply the updated manifest by running this command:
kubectl apply -f k8s/deployment.yaml

Kubernetes detects the spec change and restarts the pods with health checks now active. Since the file still says replicas: 3, Kubernetes also scales back down to 3 pods from the 5 you set earlier.

  • Wait about 10 seconds for the new pods to start, then list them by running:
kubectl get pods

You should see 3 pods with STATUS Running and READY 1/1.

  • Pick any pod name from the list and inspect it by running:
kubectl describe pod -l app=hello

Scroll through the output. You should see Liveness and Readiness entries in the container section, confirming the probes are active.

Pods stuck in CrashLoopBackOff?

Check that the indentation in your YAML is correct. The resources, readinessProbe, and livenessProbe blocks must be at the same indent level as ports (8 spaces from the left margin).

Also confirm your server.js still has the /healthz endpoint returning a 200 status code.

Help me debug my pods that are stuck in CrashLoopBackOff after adding health probes.

Build v2 of your app and trigger a rolling update

With health checks in place, Kubernetes now knows exactly when a pod is ready for traffic. This means you can safely update your app to a new version and Kubernetes will only send traffic to new pods once they pass their readiness probe.

  • In server.js, find this line:
    "<h1>Hello from Kubernetes! (v1)</h1>" +
  • Replace (v1) with (v2 - Updated), so the line now looks like this:
    "<h1>Hello from Kubernetes! (v2 - Updated)</h1>" +
  • Also find this line near the bottom:
  console.log("Server v1 running on port " + port);
  • Replace v1 with v2, so it reads:
  console.log("Server v2 running on port " + port);
  • Save server.js.
  • Build the new Docker image tagged as hello-k8s:v2 by running:
docker build -t hello-k8s:v2 .

You should see the build complete with a success message.

  • Load the new image into your Kind cluster by running:
kind load docker-image hello-k8s:v2 --name dev-cluster
  • Now trigger the rolling update by changing the Deployment's image:
kubectl set image deployment/hello-app hello=hello-k8s:v2

Why kubectl set image instead of editing the file?

The kubectl set image command updates the live Deployment directly, which is the standard way to trigger a rolling update in production. It lets you deploy a new version without touching your manifest files.

The Deployment controller then gradually replaces old pods with new ones, waiting for each new pod to pass its readiness probe before terminating an old one.

  • To keep your local file in sync with the live state, open k8s/deployment.yaml and change image: hello-k8s:v1 to image: hello-k8s:v2. Save the file.

Seeing ImagePullBackOff?

This means the image wasn't loaded into the Kind cluster. Run kind load docker-image hello-k8s:v2 --name dev-cluster again and make sure you see no errors in the output.

Help me fix ImagePullBackOff after updating my deployment image.

Watch the rolling update live

The rolling update is now in progress. Kubernetes is replacing old v1 pods with new v2 pods one at a time.

  • Before you run the next command, predict: do you think it will say the rollout is still in progress, or already complete?
  • Check the rollout status by running:
kubectl rollout status deployment/hello-app

You should see a message ending with:

deployment "hello-app" successfully rolled out

What happened during the rolling update?

Kubernetes created new pods with the v2 image and waited for each one to pass its readiness probe (a successful HTTP 200 from /healthz). Only after a new pod was ready did Kubernetes terminate one of the old v1 pods.

This ensures at least some pods are always serving traffic throughout the update. Zero downtime.

  • Verify the new pods are all running v2 by running:
kubectl get pods

You should see 3 pods, all with STATUS Running, READY 1/1, and a recent AGE (seconds or minutes old).

  • Now port-forward the Service to access the app in your browser:
kubectl port-forward service/hello-service 8080:80
  • Open your browser and go to http://localhost:8080.

You should see Hello from Kubernetes! (v2 - Updated) along with the pod hostname. The rolling update is complete with zero downtime.

  • Press Ctrl+C in your terminal to stop the port-forward when you're done.

Browser still showing v1?

Make sure you stopped any previous port-forward session before starting a new one. Only one process can bind to port 8080 at a time.

Also try a hard refresh in your browser (Ctrl+Shift+R on Windows or Cmd+Shift+R on macOS) to clear the cache.

Help me figure out why my browser still shows the old v1 version after a rolling update.

✔️ Awesome, I've got everything!

Great. Double check you've saved both k8s/deployment.yaml and server.js.

ⓧ I'd like to double check the full code

Your server.js should now look like this:

const http = require("http");

const port = 3000;

const server = http.createServer((req, res) => {
  if (req.url === "/healthz") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ status: "healthy" }));
    return;
  }

  res.writeHead(200, { "Content-Type": "text/html" });
  res.end(
    "<h1>Hello from Kubernetes! (v2 - Updated)</h1>" +
    "<p>Hostname: " + (process.env.HOSTNAME || "unknown") + "</p>"
  );
});

server.listen(port, () => {
  console.log("Server v2 running on port " + port);
});

Your k8s/deployment.yaml has three sections. Confirm each is present in order:

  • The top-level Deployment metadata with name: hello-app, replicas: 3, and selector/template labels set to app: hello.
  • The container spec with image: hello-k8s:v2, imagePullPolicy: Never, and containerPort: 3000.
  • The resources, readinessProbe, and livenessProbe blocks you added in this step, directly below containerPort: 3000.

You've added production-grade health checks and performed a zero-downtime rolling update. Your cluster now only routes traffic to pods that are truly ready, and it can restart stuck pods automatically. Next up, you'll take this further with environment configuration and rollbacks in the Secret Mission.

Secret mission

ConfigMaps and Rollbacks

Your app is deployed and self-healing, but every configuration value is hardcoded. What happens when you need different settings for staging vs production? And what if a bad deploy slips through? In this mission, you'll inject environment configuration without rebuilding your image, then prove you can undo a deployment in a single command.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • A Kind cluster named dev-cluster running as Docker containers (no cost, local only).
  • Docker images hello-k8s:v1, hello-k8s:v2, and hello-k8s:v3 stored locally.
  • Project files on your Desktop (server.js, Dockerfile, kind-config.yaml, and the k8s/ directory).
  • Kind CLI tool (installed via winget during this project).

Keep everything running

No action needed. Choose this if you are still actively building or want to continue experimenting with your cluster.

  • Your Kind cluster persists between Docker Desktop restarts. You can run kubectl commands anytime.
  • The Docker images stay cached locally and take up minimal disk space.
  • Your project files remain on your Desktop for future reference.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your cluster and files stay intact for when you return.

  • Stop any active kubectl port-forward processes by pressing Ctrl+C in the terminal where they are running.
  • Quit Docker Desktop. The Kind cluster pauses when Docker stops.
  • When you start Docker Desktop again, the cluster resumes automatically and you can pick up where you left off.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow these steps in order.

  • Delete the Kind cluster by running:
kind delete cluster --name dev-cluster
  • Remove the Docker images by running:
docker rmi hello-k8s:v1 hello-k8s:v2 hello-k8s:v3
  • Delete your project folder from the Desktop. This is the folder containing server.js, Dockerfile, and the k8s/ directory.

macOS

cd ~/Desktop
rm -rf hello-k8s

Windows

cd ~\Desktop
Remove-Item -Recurse -Force hello-k8s
  • Uninstall Kind by running:
winget uninstall Kubernetes.kind

What about kubectl?

If kubectl was already bundled with Docker Desktop before this project, leave it alone. If you installed it separately via winget during Step 1, you can remove it by running:

winget uninstall --id Kubernetes.kubectl

Nice Work!

Nice Work!

You just deployed a containerized application to a multi-node Kubernetes cluster running entirely on your local machine. You went from a fragile raw Pod to a production-style Deployment with self-healing, health checks, and zero-downtime updates.

You've learned how to:

  • Set up a multi-node Kind cluster and deploy a containerized app with a Deployment that self-heals and scales across worker nodes automatically.
  • Add readiness and liveness probes so Kubernetes only routes traffic to healthy pods, plus resource requests and limits for proper cluster governance.
  • Perform a zero-downtime rolling update, watching old pods terminate gracefully while new pods take over traffic with no interruption.
  • Secret Mission: Injected environment configuration via ConfigMaps and performed a rollback to a previous deployment version.

Ready to quiz yourself?