Build a Job Queue Worker on Kubernetes

Deploy a BullMQ producer and auto-scaling worker pods using Kubernetes HPA.

Introduction

30 Second Summary

When your API accepts work that takes 30 seconds to finish (resizing an image, generating a report, crunching data), you cannot block the HTTP response that long. The standard solution is a job queue: accept the request, return immediately, and let background workers process the heavy lifting.

In this project, you will deploy a BullMQ-based job queue system to a local Kubernetes cluster, with a producer that enqueues jobs and separate worker pods that process them. Then you will configure a Horizontal Pod Autoscaler (HPA) that scales workers up when the queue is busy and back down when it drains.

What You'll Build

A job queue system with a Node.js producer service and a separate worker service, both backed by Redis via BullMQ, deployed as independent Kubernetes Deployments with HPA-driven autoscaling on the worker pods.

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

  • A producer service (Express API) that accepts HTTP requests and enqueues jobs into a BullMQ queue backed by Redis.
  • A worker Deployment that processes jobs independently from the producer, scaling separately based on load.
  • A Horizontal Pod Autoscaler (HPA) that automatically scales worker pods up and down based on CPU utilization.
  • Secret Mission: Add job retries with exponential backoff and a dead letter queue for failed jobs.

Are there any prerequisites?

You should have basic Docker experience (building images and running containers) and familiarity with terminal commands and YAML. No prior Kubernetes or BullMQ knowledge is needed.

Build the Containerized Node.js App

Your Kubernetes cluster is running and ready to orchestrate containers. Now it needs something to orchestrate.

In this step, you'll create a Node.js web application that counts page visits using Redis as a shared data store. Then you'll package it into a Docker container image that Kubernetes can run as pods.

In this step, get ready to:
  • Create the Node.js application files.
  • Write a production-ready Dockerfile to containerize the app.
  • Build and verify the Docker image locally.
Create the application code

Your app needs two files: a package.json that declares dependencies, and a server.js that runs the web server.

  • In VS Code's Explorer sidebar, right-click inside the k8s-counter-app folder and select New File.
  • Name the file package.json and paste the following code:
{
  "name": "k8s-counter-app",
  "version": "1.0.0",
  "description": "A simple counter app for Kubernetes learning",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "redis": "4.7.0"
  }
}

What does this file do?

  • The dependencies section declares that the app needs the redis npm package at version 4.7.0.
  • The start script tells Node.js how to launch the app.
  • Kubernetes doesn't read this file directly. Docker uses it during the image build to install the right packages.
  • Save package.json with Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and move on to creating server.js.

ⓧ I'd like to double check the full code

Here's the complete package.json for comparison:

{
  "name": "k8s-counter-app",
  "version": "1.0.0",
  "description": "A simple counter app for Kubernetes learning",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "redis": "4.7.0"
  }
}

Now you'll create the main application file that runs the web server.

  • Right-click inside the k8s-counter-app folder again and select New File.
  • Name this file server.js and paste the following code:
// Import required modules
const http = require('http');
const { createClient } = require('redis');
const os = require('os');

// Configuration: port and Redis connection URL from environment variable
const PORT = 3000;
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';

// Create the Redis client using the URL
const client = createClient({ url: REDIS_URL });

// Log any Redis connection errors
client.on('error', (err) => {
  console.error('Redis connection error:', err);
});

// Connect to Redis when the app starts
client.connect().then(() => {
  console.log('Connected to Redis');
});

What does this code do?

  • The REDIS_URL reads its value from an environment variable. This lets Kubernetes inject the connection string at deploy time without changing the code.
  • The createClient({ url: REDIS_URL }) uses the modern async/await Redis client (v4.x). It requires an explicit .connect() call before use.
  • The os module gives access to the system hostname. Inside Kubernetes, each pod gets a unique hostname, so this will show which pod handled each request.

You won't see a change yet. The next chunk completes the server logic.

  • Add the following code directly below what you just pasted in server.js:
// Create the HTTP server
const server = http.createServer(async (req, res) => {
  // Health check endpoint for Kubernetes probes
  if (req.url === '/health') {
    try {
      await client.ping();
      res.writeHead(200, { 'Content-Type': 'text/plain' });
      res.end('OK');
    } catch (err) {
      res.writeHead(503, { 'Content-Type': 'text/plain' });
      res.end('Unhealthy');
    }
    return;
  }

  // Main route: increment visit counter and respond with hostname
  try {
    const visits = await client.incr('visits');
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end(`Hello from ${os.hostname()}! Visit count: ${visits}\n`);
  } catch (err) {
    res.writeHead(500, { 'Content-Type': 'text/plain' });
    res.end('Error connecting to Redis\n');
  }
});

// Start listening on the configured port
server.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

What does this code do?

  • The /health endpoint pings Redis and returns OK or Unhealthy. Kubernetes can use this later to check if the pod is alive and ready for traffic.
  • The main route increments a visits key in Redis with client.incr(). Because all pods share the same Redis instance, the counter persists across pods and requests.
  • The response includes os.hostname(). In Kubernetes, this returns the pod name, so you can see which pod handled each request when you test load balancing.
  • Save server.js with Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm both files exist by running this in your terminal:
ls

You should see package.json and server.js in the output.

Don't see both files?

  • Make sure your terminal is inside the k8s-counter-app directory. Run pwd to check your current location.
  • Check that you created the files inside k8s-counter-app, not in a parent or nested folder.

Still stuck? Help me find my project files in the terminal.

✔️ Awesome, I've got everything!

Great. Double check you've saved server.js and move on to the Dockerfile.

ⓧ I'd like to double check the full code

Here's the complete server.js for comparison:

const http = require('http');
const { createClient } = require('redis');
const os = require('os');

const PORT = 3000;
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';

const client = createClient({ url: REDIS_URL });

client.on('error', (err) => {
  console.error('Redis connection error:', err);
});

client.connect().then(() => {
  console.log('Connected to Redis');
});

const server = http.createServer(async (req, res) => {
  if (req.url === '/health') {
    try {
      await client.ping();
      res.writeHead(200, { 'Content-Type': 'text/plain' });
      res.end('OK');
    } catch (err) {
      res.writeHead(503, { 'Content-Type': 'text/plain' });
      res.end('Unhealthy');
    }
    return;
  }

  try {
    const visits = await client.incr('visits');
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end(`Hello from ${os.hostname()}! Visit count: ${visits}\n`);
  } catch (err) {
    res.writeHead(500, { 'Content-Type': 'text/plain' });
    res.end('Error connecting to Redis\n');
  }
});

server.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
Write the Dockerfile

A Dockerfile is a recipe that tells Docker how to package your app into a container image. You'll use best practices like a minimal base image, layer caching, and a non-root user.

  • Right-click inside the k8s-counter-app folder in VS Code's Explorer sidebar and select New File.
  • Name the file Dockerfile (no extension) and paste the following code:
# Use a small, secure Alpine-based Node.js image
FROM node:22-alpine

# Set the working directory inside the container
WORKDIR /app

# Copy package files first for Docker layer caching
COPY package*.json ./

# Install only production dependencies
RUN npm ci --omit=dev

# Copy the application code
COPY server.js .

# Run as non-root user for security
USER node

# Document which port the app uses
EXPOSE 3000

# Start the application
CMD ["node", "server.js"]

What does this Dockerfile do?

  • The node:22-alpine base image is much smaller than the default Node image (around 50 MB vs 350 MB). Smaller images mean faster deploys.
  • Copying package*.json before the app code means Docker caches the npm ci layer. Rebuilds are fast unless dependencies change.
  • The USER node line switches to a non-root user. If an attacker compromises the container, they can't get root access.
  • The npm ci --omit=dev command installs only production dependencies, keeping the final image lean.
  • Save Dockerfile.
  • Confirm the file exists by running:
ls

You should see Dockerfile, package.json, and server.js in the output.

Don't see the Dockerfile?

  • Make sure the file is named exactly Dockerfile with a capital D and no file extension.
  • Check that it's in the k8s-counter-app folder, not nested inside a subfolder.

Need help? Help me fix my Dockerfile location.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and move on to building the image.

ⓧ I'd like to double check the full code

Here's the complete Dockerfile for comparison:

FROM node:22-alpine

WORKDIR /app

COPY package*.json ./

RUN npm ci --omit=dev

COPY server.js .

USER node

EXPOSE 3000

CMD ["node", "server.js"]
Build and tag the Docker image

With all three files in place, you can now build a container image. Docker Desktop's Kubernetes can access local images directly, so you don't need to push to a registry.

  • Build and tag the image by running this command:
docker build -t counter-app:v1 .

What does this command do?

  • The -t counter-app:v1 flag names and tags the image. The v1 tag lets you track versions when you do rolling updates later.
  • The . at the end tells Docker to look for the Dockerfile in the current directory.

Docker will download the node:22-alpine base image (this may take a minute the first time), install dependencies, and package your app.

  • Verify the image was created by running:
docker images counter-app

You should see a table with your counter-app image listed, showing the v1 tag and a size of roughly 60-80 MB.

Build failed with an error?

  • If you see a "no such file or directory" error, make sure your terminal is inside the k8s-counter-app folder where the Dockerfile lives.
  • If you see a JSON syntax error during the build, open package.json and check for missing commas or extra trailing commas.
  • Make sure Docker Desktop is running. The build command needs the Docker daemon to be active.

Still stuck? Help me debug my Docker build error.

Your application is containerized and ready to deploy. Next up, you'll deploy Redis to your Kubernetes cluster and set up the internal networking that lets your app talk to it.

Deploy Redis to Kubernetes

Your Node.js app is containerized and ready to go. But it needs a Redis backend to store visit counts.

In Kubernetes, each service runs as its own Deployment with its own manifest file. In this step, you will write a YAML manifest that tells Kubernetes exactly how to run Redis and how other pods can discover it through internal DNS.

In this step, get ready to:
  • Create a Kubernetes manifest for the Redis Deployment and Service.
  • Apply the manifest to deploy Redis to your cluster.
  • Learn how Kubernetes DNS enables service discovery between pods.
Write the Redis Deployment manifest

Kubernetes uses YAML manifest files to describe what resources you want running in your cluster. You will create a single file that defines both a Deployment (what to run) and a Service (how to reach it).

  • In VS Code, create a new file called redis-deployment.yaml in your k8s-counter-app folder.
  • Paste the following YAML into the file to define the Redis Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
  labels:
    app: redis
spec:
  replicas: 1
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:
        - name: redis
          image: redis:7-alpine
          ports:
            - containerPort: 6379

What does this manifest do?

  • apiVersion: apps/v1 and kind: Deployment tell Kubernetes you want a Deployment resource that manages pod replicas.
  • replicas: 1 means Kubernetes keeps exactly one Redis pod running at all times.
  • selector and template labels connect the Deployment to the pods it manages. They must match.
  • The container uses redis:7-alpine, a lightweight Redis image, and exposes port 6379 (the default Redis port).

You won't see a change yet. The next section adds the Service definition to this same file.

  • Add the following YAML below the Deployment section in the same file. The --- separator tells Kubernetes this is a second resource in the same file:
---
apiVersion: v1
kind: Service
metadata:
  name: redis-service
spec:
  type: ClusterIP
  selector:
    app: redis
  ports:
    - port: 6379
      targetPort: 6379

What does the Service do?

  • kind: Service creates a stable network endpoint for the Redis pod.
  • type: ClusterIP means this Service is internal-only. Nothing outside the cluster can reach it directly.
  • The selector: app: redis routes traffic to any pod with the label app: redis (the pods created by the Deployment above).
  • The Service name redis-service becomes a DNS hostname inside the cluster that other pods use to connect.
  • Save redis-deployment.yaml.
  • Confirm the file exists by running:
ls

You should see redis-deployment.yaml listed alongside your other project files.

Don't see redis-deployment.yaml?

  • Make sure you saved the file with the exact name redis-deployment.yaml (not .yml or a different spelling).
  • Confirm your terminal is inside the k8s-counter-app directory. Run pwd to check.

Help me troubleshoot my redis-deployment.yaml file not appearing in my project directory.

✔️ Awesome, I've got everything!

Great. Double check you have saved redis-deployment.yaml before moving on.

ⓧ I'd like to double check the full code

Here is the complete redis-deployment.yaml file for comparison:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
  labels:
    app: redis
spec:
  replicas: 1
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:
        - name: redis
          image: redis:7-alpine
          ports:
            - containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:
  name: redis-service
spec:
  type: ClusterIP
  selector:
    app: redis
  ports:
    - port: 6379
      targetPort: 6379
Apply the Redis manifest

With the manifest written, you can now tell Kubernetes to create the resources it describes.

  • Deploy both the Redis Deployment and Service to your cluster by running:
kubectl apply -f redis-deployment.yaml

You should see two confirmation lines indicating both resources were created:

deployment.apps/redis created
service/redis-service created
  • Verify the Redis pod is running by running:
kubectl get pods

You should see a pod with a name starting with redis- and a STATUS of Running.

  • Check the Service was created by running:
kubectl get services

You should see redis-service listed with TYPE ClusterIP and PORT(S) 6379/TCP.

Pod stuck in ContainerCreating or ImagePullBackOff?

  • If the pod shows ContainerCreating, wait 10-20 seconds and run kubectl get pods again. Kubernetes needs a moment to pull the Redis image the first time.
  • If you see ImagePullBackOff, check that Docker Desktop is running and has internet access. The redis:7-alpine image must be pulled from Docker Hub.
  • Check for YAML syntax errors by running kubectl describe pod <pod-name> and reading the Events section at the bottom.

Help me fix my Redis pod that is not reaching Running status in Kubernetes.

Understand Kubernetes internal networking

How do pods find each other?

When you created the ClusterIP Service named redis-service, Kubernetes registered a DNS entry for it. The full DNS name is redis-service.default.svc.cluster.local.

Pods in the same namespace can use the short form redis-service to connect. This is why the Node.js app's REDIS_URL environment variable will be set to redis://redis-service:6379 in the next step.

This DNS-based service discovery means microservices never hardcode pod IP addresses. Pods can restart, move, or scale, and the Service DNS name always routes to the right place.

💡 Why ClusterIP instead of NodePort?

Redis is an internal data store. It should only be reachable by other pods inside the cluster, never from the outside world. ClusterIP enforces this by giving Redis an internal-only IP address. The Node.js app (which users DO access) will use a NodePort Service in the next step to expose itself externally.

Redis is deployed and discoverable inside your cluster. Next up, you will deploy the Node.js app and connect it to Redis using the DNS name you just created.

Deploy the Node.js App to Kubernetes

Your Redis backend is running inside Kubernetes and discoverable via its internal DNS name redis-service. Now it is time to deploy the Node.js app that will connect to Redis, count visits, and serve responses to the outside world.

You will create a Deployment that runs three replicas of your app and a NodePort Service that exposes the app on your machine at a fixed port. This gives you load balancing across pods and a stable URL to test from your browser.

In this step, get ready to:
  • Write the app Deployment and Service manifest.
  • Apply the manifest and verify three pods are running.
  • Test the live application in your browser.
Write the app manifest

Just like the Redis manifest, this file contains both a Deployment and a Service separated by ---. The Deployment tells Kubernetes how many copies of your app to run. The Service makes those copies reachable from outside the cluster.

  • Create a new file called app-deployment.yaml in your k8s-counter-app folder and paste the following:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: counter-app
  labels:
    app: counter-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: counter-app
  template:
    metadata:
      labels:
        app: counter-app
    spec:
      containers:
        - name: counter-app
          image: counter-app:v1
          imagePullPolicy: Never
          ports:
            - containerPort: 3000
          env:
            - name: REDIS_URL
              value: "redis://redis-service:6379"
---
apiVersion: v1
kind: Service
metadata:
  name: counter-app-service
spec:
  type: NodePort
  selector:
    app: counter-app
  ports:
    - port: 80
      targetPort: 3000
      nodePort: 30080

What does this manifest do?

The Deployment section creates 3 replicas of your counter-app:v1 image. Each replica is an independent pod that can serve requests.

  • imagePullPolicy: Never tells Kubernetes to use the local Docker image you built earlier instead of trying to pull from Docker Hub.
  • The REDIS_URL environment variable points to redis://redis-service:6379. Kubernetes DNS resolves redis-service to the ClusterIP Service you created in the previous step.
  • The NodePort Service maps external port 30080 on your machine to container port 3000 inside each pod. This is how you will access the app from your browser.

💡 Why imagePullPolicy: Never?

Docker Desktop's Kubernetes can access locally-built Docker images directly. Without imagePullPolicy: Never, Kubernetes tries to pull counter-app:v1 from Docker Hub, fails to find it, and your pods get stuck in an ImagePullBackOff state.

  • Save app-deployment.yaml.

YAML indentation tripping you up?

YAML is whitespace-sensitive. Make sure you are using spaces (not tabs) and that nested keys line up consistently. VS Code shows indentation guides that help you spot misalignment.

Help me fix YAML indentation errors in my Kubernetes manifest.

✔️ Awesome, I've got everything!

Great. Make sure you have saved app-deployment.yaml before moving on.

ⓧ I'd like to double check the full code

Here is the complete app-deployment.yaml for comparison:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: counter-app
  labels:
    app: counter-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: counter-app
  template:
    metadata:
      labels:
        app: counter-app
    spec:
      containers:
        - name: counter-app
          image: counter-app:v1
          imagePullPolicy: Never
          ports:
            - containerPort: 3000
          env:
            - name: REDIS_URL
              value: "redis://redis-service:6379"
---
apiVersion: v1
kind: Service
metadata:
  name: counter-app-service
spec:
  type: NodePort
  selector:
    app: counter-app
  ports:
    - port: 80
      targetPort: 3000
      nodePort: 30080
Apply the app manifest and verify
  • Deploy the Node.js app to your cluster by running this command:
kubectl apply -f app-deployment.yaml
  • You should see output confirming both the Deployment and Service were created.
  • Check that your app pods are running by running:
kubectl get pods

You should see 3 pods with names starting with counter-app- and STATUS Running. You will also see the Redis pod from the previous step.

  • Confirm the NodePort Service exists by running:
kubectl get services

You should see counter-app-service listed with TYPE NodePort and PORT(S) showing 80:30080/TCP.

Pods stuck in ImagePullBackOff or ErrImageNeverPull?

This means Kubernetes cannot find the counter-app:v1 image locally. Check these two things:

  • Run docker images counter-app to confirm the image exists. If it does not, go back and rebuild it with docker build -t counter-app:v1 ..
  • Confirm your YAML says imagePullPolicy: Never (not Always or IfNotPresent).

Help me debug pods stuck in ImagePullBackOff on Docker Desktop Kubernetes.

Test the application

The NodePort Service maps port 30080 on your local machine to the app pods. This means you can access the app from your browser just like any website.

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

You should see a message like: Hello from counter-app-7b9f4d6c8-x2k5m! Visit count: 1. The hostname portion is the name of the specific pod that handled your request.

  • Refresh the page several times.

Notice two things happening. The hostname changes between refreshes because Kubernetes is load balancing your requests across all 3 pods. The visit counter keeps incrementing because all pods share the same Redis backend.

What is this proving?

This single browser test demonstrates three Kubernetes capabilities working together:

  • Multi-container networking: the Node.js pods connect to the Redis pod using Kubernetes DNS.
  • Service discovery: the app finds Redis by name (redis-service) without hardcoding any IP address.
  • Load balancing: Kubernetes distributes incoming requests across all healthy pods automatically.

🙋‍♀️ Seeing 'Error connecting to Redis' in the browser?

This means your app pods cannot reach the Redis Service. Check that Redis is still running:

  • Run kubectl get pods and confirm the redis pod shows STATUS Running.
  • Run kubectl get services and confirm redis-service is listed with port 6379.
  • Confirm that the REDIS_URL value in your manifest is exactly redis://redis-service:6379.

Help me debug why my Node.js pods cannot connect to Redis in Kubernetes.

Your multi-container app is live on Kubernetes with load balancing, service discovery, and shared state all working together. Next up, you will push this cluster to its limits by scaling pods, breaking things on purpose, and performing a zero-downtime rolling update.

Scale, Self-Heal, and Rolling Update

Your multi-container app is live on Kubernetes with three pods handling traffic and Redis storing shared state. That's real microservice architecture in action.

Now you'll stress-test what makes Kubernetes special: its ability to scale your app instantly, recover from failures automatically, and deploy updates with zero downtime.

In this step, get ready to:
  • Scale your deployment from 3 to 5 replicas and observe load balancing.
  • Delete a pod and watch Kubernetes self-heal by replacing it automatically.
  • Perform a zero-downtime rolling update from v1 to v2 of your application.
Scale the deployment to five replicas

Right now your app runs 3 pods. Kubernetes makes it trivial to add more capacity without restarting anything or changing your code.

  • Scale the counter-app deployment to 5 replicas by running this command:
kubectl scale deployment counter-app --replicas=5
  • Watch the new pods spin up in real time by running:
kubectl get pods -w

What does the -w flag do?

The -w flag stands for "watch." It streams live updates to your terminal as pods change state. Press Ctrl+C to stop watching once you see all 5 pods in Running status.

  • Press Ctrl+C to exit the watch once all 5 pods show Running.
  • Open http://localhost:30080 in your browser and refresh multiple times.
  • You should see the hostname change across 5 different pod names, proving traffic is load-balanced across all replicas.

Still seeing only 3 hostnames?

Make sure you pressed Ctrl+C to stop the watch before refreshing your browser. If you still see 3 names, run kubectl get pods to confirm all 5 are in Running status.

help me troubleshoot why my scaled pods aren't showing in the browser response

Test Kubernetes self-healing

Kubernetes continuously monitors your pods. If one crashes or gets deleted, the Deployment controller notices the gap and spins up a replacement immediately.

  • List your running pods to pick one for deletion by running:
kubectl get pods
  • Copy the name of any one of the counter-app pods from the output. It looks something like counter-app-6b7f8d4c9-x2k4m.
  • Delete that pod by running this command (replace the pod name with yours):
kubectl delete pod [[POD_NAME="one of your counter-app pod names, e.g. counter-app-6b7f8d4c9-x2k4m"]]
  • Immediately watch the pods recover by running:
kubectl get pods -w
  • Press Ctrl+C once you see a new pod reach Running status.
  • Refresh http://localhost:30080 in your browser. The visit counter keeps incrementing because the other pods handled traffic while the replacement spun up.

What just happened?

You manually killed a pod, simulating a crash. Kubernetes detected that only 4 of the desired 5 replicas were running and immediately scheduled a new pod to restore the target state.

This is self-healing in action. Your users never experienced downtime because the remaining pods continued serving requests.

🙋‍♀️ Pod stuck in Terminating?

If the deleted pod stays in Terminating for more than 30 seconds, it may be waiting for a graceful shutdown. Give it a minute. The replacement pod should already be Running regardless.

help me understand why my pod is stuck in Terminating state

Perform a zero-downtime rolling update

In production, you need to ship new versions of your app without taking it offline. Kubernetes rolling updates replace pods one at a time, keeping the service available throughout the entire process.

  • Switch back to VS Code and open server.js.
  • Find this line inside the try block (around line 28):
    res.end(`Hello from ${os.hostname()}! Visit count: ${visits}\n`);
  • Change the message to include (v2) so you can visually confirm the update in the browser:
    res.end(`Hello from ${os.hostname()} (v2)! Visit count: ${visits}\n`);
  • Save server.js.
  • Return to your terminal and build a new Docker image tagged as v2 by running:
docker build -t counter-app:v2 .
  • Confirm the new image exists by running:
docker images counter-app
  • You should see both v1 and v2 tags listed.

Build failed?

Make sure you saved server.js before running the build command. Also confirm your terminal is inside the k8s-counter-app directory where the Dockerfile lives.

help me fix my Docker build error for counter-app v2

  • Tell Kubernetes to update the deployment to use the new image by running:
kubectl set image deployment/counter-app counter-app=counter-app:v2
  • Watch the rollout progress by running:
kubectl rollout status deployment/counter-app

What is happening during the rollout?

Kubernetes replaces pods one at a time. It creates a new pod running v2, waits for it to become ready, then terminates an old v1 pod. This repeats until all replicas run the new version.

Because at least some pods are always serving traffic, your users never experience downtime. This is why rolling updates are the default deployment strategy in Kubernetes.

  • Once the rollout completes, refresh http://localhost:30080 in your browser.
  • You should see the updated message including (v2) along with the hostname and visit count.

How do I rollback if something goes wrong?

Kubernetes keeps a history of your deployment revisions. If a new version has a bug, you can instantly revert to the previous version by running:

kubectl rollout undo deployment/counter-app

You do not need to run this now since your v2 is working correctly. Just know it's available whenever you need it.

🙋‍♀️ Pods stuck in ImagePullBackOff?

This means Kubernetes is trying to pull the image from a registry instead of using your local build. Confirm that imagePullPolicy: Never is set in your app-deployment.yaml. If you used kubectl set image as instructed, the existing policy should carry over.

help me fix ImagePullBackOff after rolling update to counter-app v2

Secret mission

Add Production-Grade Health Probes and Resource Limits

Your deployment works, but it is missing three things every production Kubernetes manifest needs: readiness probes, liveness probes, and resource limits. Add all three to make your manifests interview-ready.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine using Docker Desktop's built-in Kubernetes cluster. There are no cloud costs or external services to worry about.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Kubernetes resources (Deployments, Services, and pods) running in Docker Desktop.
  • A local Kubernetes cluster running inside Docker Desktop.
  • Docker images stored locally (counter-app:v1 and counter-app:v2).
  • Project files in the k8s-counter-app directory on your Desktop.

Keep everything running

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

  • The Kubernetes cluster runs inside Docker Desktop and uses approximately 1-2 GB of RAM while active.
  • Your pods, services, and deployments will continue running in the background whenever Docker Desktop is open.
  • The counter-app:v1 and counter-app:v2 Docker images remain stored locally.

Pause - I'll come back to this later

Stop Docker Desktop to free up all memory and CPU used by the Kubernetes cluster. Your cluster state is preserved and resumes when you restart Docker Desktop.

  • Quit Docker Desktop from the menu bar (macOS) or system tray (Windows).
  • All Kubernetes pods stop running and RAM is reclaimed immediately.
  • When you reopen Docker Desktop later, your cluster and all resources will resume automatically.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to fully clean up.

  • Delete all Kubernetes resources by running this command:
kubectl delete -f app-deployment.yaml && kubectl delete -f redis-deployment.yaml
  • Remove the Docker images built for this project by running:
docker rmi counter-app:v1 counter-app:v2
  • Stop the Kubernetes cluster by opening the Docker Desktop Dashboard, selecting the Kubernetes view, and clicking Stop.
  • Delete the project folder from your Desktop by running:

macOS

cd ~/Desktop
rm -rf k8s-counter-app

Windows

cd ~\Desktop
rmdir /s /q k8s-counter-app

Nice Work!

Nice Work!

Nice work! You just deployed a multi-container application to Kubernetes and operated it like a production system.

You've learned how to:

  • Built and containerized a multi-container application (Node.js + Redis) with production Docker best practices including Alpine base images, non-root users, and layer caching.
  • Deployed multiple services to Kubernetes using Deployment and Service manifests with internal DNS networking and load balancing across pods.
  • Scaled a live application horizontally, witnessed self-healing pod recovery, and performed a zero-downtime rolling update with rollback capability.
  • Secret Mission: Added production-grade readiness probes, liveness probes, and resource limits to your deployment manifests.

Ready to quiz yourself?