Deploy a Node.js Microservice to Kubernetes

Containerize a Node.js REST API and deploy it to a local Kubernetes cluster.

Introduction

30 Second Summary

Every app you use daily runs on servers that need to stay online, scale up during busy periods, and recover from crashes automatically. Kubernetes is the tool that makes this possible.

In this project, you will build a Node.js REST API, package it into a Docker container, and deploy it to a local Kubernetes cluster on your Mac.

What You'll Build

A "Task Tracker" REST API microservice that runs inside a Kubernetes cluster, managed with health checks, multiple replicas, and a Service for traffic routing.

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

  • A containerized Node.js REST API with health check, list, create, and delete endpoints.
  • A Kubernetes Deployment running 2 replicas with health probes for automatic recovery.
  • A Kubernetes Service routing traffic to your pods, accessible via port-forwarding.
  • Secret Mission: Perform a zero-downtime rolling update and watch Kubernetes replace pods one by one.

Are there any prerequisites?

None. This project includes full setup instructions for Docker Desktop, Kind, kubectl, Node.js, and VS Code. You just need a Mac with at least 8 GB of RAM.

Install Your Tools

Before you write any code, you need the right tools on your machine. This step gets your local environment ready with everything you need to build, containerize, and deploy a microservice.

You will install five tools. Docker Desktop packages your app into a container. Kind creates a local Kubernetes cluster. kubectl talks to that cluster. Node.js runs your JavaScript microservice. VS Code is your code editor.

In this step, get ready to:
  • Install Docker Desktop, Kind, and kubectl for container and cluster management.
  • Install Node.js v24 LTS to run your microservice locally.
  • Install VS Code as your code editor.
Install Docker Desktop for Mac

Docker Desktop provides the Docker engine that builds and runs containers on macOS. It also provides the Linux VM that Kind needs to run Kubernetes nodes.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Docker and press Enter to open Docker Desktop.
  • Accept the license agreement and use the default (recommended) settings.
  • Wait for Docker to finish starting. The whale icon in your menu bar stops animating when it is ready.

How do I know Docker is running?

Look at the top-right of your screen (the menu bar). When the whale icon is still and not animating, Docker Desktop is fully started and ready.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Verify Docker is installed by running this command:
docker --version

✔️ I see a version number

Docker is installed and running. Move on to the next substep.

ⓧ Command not found or no output

Docker Desktop may not have finished starting, or the install did not complete.

  • Check that the whale icon in your menu bar is present and not animating.
  • If you do not see the whale icon, open Docker Desktop again from Applications.
  • Close and reopen Terminal, then run docker --version again.

If it still does not work, help me troubleshoot Docker Desktop installation.

Install Kind and kubectl via Homebrew

Homebrew is a package manager for macOS that makes installing command-line tools simple. You will use it to install Kind (for creating a local cluster) and kubectl (for talking to that cluster).

  • In your Terminal, check if Homebrew is already installed by running:
brew --version

✔️ I see a version number

Homebrew is ready. Skip ahead to installing Kind and kubectl below.

ⓧ Command not found

  • Install Homebrew by running this command:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

What does this command do?

This downloads and runs the official Homebrew installer script. It will ask for your Mac password and may take a minute or two to complete.

  • Follow any on-screen prompts (enter your password when asked).
  • When the installer finishes, close and reopen Terminal.
  • Run brew --version again to confirm it works.
  • Install Kind and kubectl by running these commands:
brew install kind
brew install kubernetes-cli

What are Kind and kubectl?

Kind runs a full Kubernetes cluster inside Docker containers on your Mac. It starts in about 30 seconds and uses roughly 1 GB of RAM.

kubectl is the command-line tool that sends instructions to any Kubernetes cluster. You will use it to deploy your app and inspect running pods.

  • Verify Kind is installed by running:
kind version
  • Verify kubectl is installed by running:
kubectl version --client

✔️ Both commands print version info

Kind and kubectl are ready. Move on to the next substep.

ⓧ One or both commands are not found

  • Close and reopen Terminal, then try the version commands again.
  • If kubectl is not found, run brew link --overwrite kubernetes-cli and try again. Docker Desktop sometimes installs its own version that conflicts.
  • If kind is not found, run brew install kind again and check for errors in the output.

If you are still stuck, help me troubleshoot Kind and kubectl installation via Homebrew.

Install Node.js and VS Code

Node.js runs your microservice's JavaScript code. This project requires version 24 (the current LTS release).

  • Open your browser and go to the Node.js download page:
  • https://nodejs.org/en/download
  • Click the macOS .pkg installer to download it.
  • Double-click the downloaded file and follow the installer prompts, accepting the defaults.
  • When the installer finishes, go back to your Terminal and verify by running:
node --version

✔️ I see version 24 or higher

Node.js is ready. You should see something like v24.18.0. Move on to installing VS Code below.

ⓧ I see an older version

You have an older version of Node.js installed. The .pkg installer you just downloaded should replace it.

  • Close and reopen Terminal to pick up the new PATH.
  • Run node --version again.
  • If it still shows an older version, the old install may be shadowing the new one. Run which node to see which binary is being used.
  • Re-download the v24 installer from https://nodejs.org/en/download and run it again.

If you are still stuck, help me upgrade Node.js to version 24 on macOS.

ⓧ Command not found

The installer may not have added Node.js to your PATH yet.

  • Close and reopen Terminal completely.
  • Run node --version again.
  • If it still fails, re-download and run the installer from https://nodejs.org/en/download.

If you are still stuck, help me install Node.js v24 on macOS.

  • Also verify npm (Node's package manager) is available by running:
npm --version

You should see a version number printed. npm is included with the Node.js installer.

Now install VS Code, the editor you will use throughout this project.

  • Open your browser and go to:
  • https://code.visualstudio.com/
  • Click the download button for macOS.
  • Unzip the downloaded file (it may unzip automatically).
  • Drag Visual Studio Code.app into your Applications folder.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.

✔️ VS Code opens successfully

VS Code is installed and ready. You will use it in the next step to write your microservice code.

ⓧ It does not open or I cannot find it

  • Make sure you dragged the app into Applications, not just into Downloads.
  • Try searching for Code in Spotlight (Cmd+Space).
  • If macOS blocks it with a security warning, go to System Settings > Privacy & Security and click Open Anyway.

If you are still stuck, help me install VS Code on macOS.

All five tools are now installed. Let's confirm everything works together with one final check.

  • Run this combined command in Terminal to verify all four CLI tools at once:
docker --version && kind version && kubectl version --client && node --version

You should see version info printed for each tool with no errors.

Seeing an error for one of the tools?

  • If docker --version fails, make sure Docker Desktop is running (whale icon in menu bar).
  • If kind version or kubectl version --client fails, close and reopen Terminal so Homebrew's PATH is loaded.
  • If node --version fails, close and reopen Terminal. The Node.js installer updates your PATH only for new Terminal sessions.

If you are still stuck, help me get all my tools working.

Your environment is ready. Next up, you will write the Task Tracker microservice that these tools will containerize and deploy.

Write the Task Tracker Microservice

Your development environment is locked and loaded. Now it's time to write the actual application that will run inside Kubernetes.

You'll build a small REST API called the Task Tracker. It lets you create, list, and delete tasks over HTTP. The app uses only the Node.js built-in http module, so there are zero external dependencies to manage.

In this step, get ready to:
  • Create the project folder and package.json file.
  • Write a REST API with health check, list, create, and delete endpoints.
  • Test the microservice locally to confirm it works before containerizing.
Create the project structure

Every microservice starts as a folder with a package.json that describes the project. This file tells Node.js how to start your app.

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

You should see task-tracker in the output.

Don't see the folder?

Make sure you ran cd ~/Desktop first. If you skipped that step, the folder was created in whatever directory your terminal opened to.

help me find where my task-tracker folder was created

  • Open the task-tracker folder in VS Code by running:
code .

Getting 'command not found' for code?

On macOS, you need to install the shell command manually. In VS Code, press Cmd+Shift+P to open the Command Palette. Type shell command and select Shell Command: Install 'code' command in PATH. Restart your terminal and try again.

help me set up the code command for VS Code on macOS

VS Code opens with an empty task-tracker workspace.

  • Create a package.json file inside task-tracker by clicking File in the top menu bar, then New File.
  • Name the file package.json and press Enter.
  • Paste the following code into package.json:
{
  "name": "task-tracker",
  "version": "1.0.0",
  "description": "A simple task tracker microservice",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  }
}

What does this file do?

The package.json file is the identity card for any Node.js project. It names the project, declares the entry point (server.js), and defines a start script so you can launch the app with npm start.

  • Save package.json by pressing Cmd+S.

✔️ Awesome, I've got everything!

Great. Make sure you've saved package.json before moving on.

ⓧ I'd like to double check the full code

Here's the complete package.json for comparison:

{
  "name": "task-tracker",
  "version": "1.0.0",
  "description": "A simple task tracker microservice",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  }
}
Write the REST API

Your API needs four endpoints: a health check (so Kubernetes can monitor the app), a list of tasks, a way to create tasks, and a way to delete them. You'll write everything in a single server.js file.

  • Create a new file called server.js in the task-tracker folder (File > New File).
  • Paste the following code at the top of server.js. This sets up the module, starter data, and helper functions:
const http = require('node:http');

const PORT = 3000;

// Starter tasks so the API isn't empty on first request
let tasks = [
  { id: 1, title: 'Learn Docker', done: false },
  { id: 2, title: 'Learn Kubernetes', done: false }
];
let nextId = 3;

// Collects the request body and parses it as JSON
function parseBody(req) {
  return new Promise((resolve, reject) => {
    let body = '';
    req.on('data', chunk => { body += chunk; });
    req.on('end', () => {
      try {
        resolve(body ? JSON.parse(body) : {});
      } catch (err) {
        reject(err);
      }
    });
    req.on('error', reject);
  });
}

// Sends a JSON response with the given status code
function sendJSON(res, statusCode, data) {
  res.writeHead(statusCode, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(data));
}

What does this code do?

The tasks array holds your data in memory. Two sample tasks are already there so you can immediately test the GET endpoint.

The parseBody function reads incoming request data and converts it from a JSON string into a JavaScript object. The sendJSON function is a shortcut for writing a response with the correct headers.

  • Now add the server and all route handlers below the helper functions in server.js:
// Create the HTTP server and define route handlers
const server = http.createServer(async (req, res) => {
  const { method, url } = req;

  // Health check endpoint for Kubernetes probes
  if (method === 'GET' && url === '/health') {
    return sendJSON(res, 200, { status: 'healthy', timestamp: new Date().toISOString() });
  }

  // List all tasks
  if (method === 'GET' && url === '/tasks') {
    return sendJSON(res, 200, { tasks });
  }

  // Create a new task
  if (method === 'POST' && url === '/tasks') {
    const body = await parseBody(req);
    if (!body.title) {
      return sendJSON(res, 400, { error: 'title is required' });
    }
    const task = { id: nextId++, title: body.title, done: false };
    tasks.push(task);
    return sendJSON(res, 201, { task });
  }

  // Delete a task by ID
  if (method === 'DELETE' && url.startsWith('/tasks/')) {
    const id = parseInt(url.split('/')[2]);
    const index = tasks.findIndex(t => t.id === id);
    if (index === -1) {
      return sendJSON(res, 404, { error: 'task not found' });
    }
    tasks.splice(index, 1);
    return sendJSON(res, 200, { message: 'task deleted' });
  }

  // Catch-all for unknown routes
  sendJSON(res, 404, { error: 'not found' });
});

// Start listening on all network interfaces
server.listen(PORT, '0.0.0.0', () => {
  console.log(`Task tracker microservice running on port ${PORT}`);
});

What does this code do?

Each if block matches a specific HTTP method and URL path. This is a simple router pattern.

  • GET /health returns a status object. Kubernetes will call this endpoint every few seconds to check whether the app is alive.
  • GET /tasks returns the full task list.
  • POST /tasks reads the request body, validates it has a title, and adds a new task to the array.
  • DELETE /tasks/:id removes a task by its numeric ID.

The server binds to 0.0.0.0 so it accepts connections from any network interface. This is important later when the app runs inside a Docker container.

  • Save server.js by pressing Cmd+S.

Seeing red squiggles in VS Code?

If VS Code shows a warning about require, that is a linting hint, not an error. Your code will still run correctly. Node.js supports require natively.

help me understand why VS Code shows a warning on require

✔️ Awesome, I've got everything!

Great. Double check you've saved server.js before continuing.

ⓧ I'd like to double check the full code

Here's the complete server.js for comparison:

const http = require('node:http');

const PORT = 3000;

let tasks = [
  { id: 1, title: 'Learn Docker', done: false },
  { id: 2, title: 'Learn Kubernetes', done: false }
];
let nextId = 3;

function parseBody(req) {
  return new Promise((resolve, reject) => {
    let body = '';
    req.on('data', chunk => { body += chunk; });
    req.on('end', () => {
      try {
        resolve(body ? JSON.parse(body) : {});
      } catch (err) {
        reject(err);
      }
    });
    req.on('error', reject);
  });
}

function sendJSON(res, statusCode, data) {
  res.writeHead(statusCode, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(data));
}

const server = http.createServer(async (req, res) => {
  const { method, url } = req;

  if (method === 'GET' && url === '/health') {
    return sendJSON(res, 200, { status: 'healthy', timestamp: new Date().toISOString() });
  }

  if (method === 'GET' && url === '/tasks') {
    return sendJSON(res, 200, { tasks });
  }

  if (method === 'POST' && url === '/tasks') {
    const body = await parseBody(req);
    if (!body.title) {
      return sendJSON(res, 400, { error: 'title is required' });
    }
    const task = { id: nextId++, title: body.title, done: false };
    tasks.push(task);
    return sendJSON(res, 201, { task });
  }

  if (method === 'DELETE' && url.startsWith('/tasks/')) {
    const id = parseInt(url.split('/')[2]);
    const index = tasks.findIndex(t => t.id === id);
    if (index === -1) {
      return sendJSON(res, 404, { error: 'task not found' });
    }
    tasks.splice(index, 1);
    return sendJSON(res, 200, { message: 'task deleted' });
  }

  sendJSON(res, 404, { error: 'not found' });
});

server.listen(PORT, '0.0.0.0', () => {
  console.log(`Task tracker microservice running on port ${PORT}`);
});
Test the microservice locally

Before containerizing anything, you should confirm the app works on your machine. If something is broken, it's far easier to debug locally than inside a container.

  • Start the server by running this command in your terminal (make sure you're in the task-tracker folder):
node server.js

You should see this output:

Task tracker microservice running on port 3000

Why does the terminal hang?

The terminal isn't frozen. Your server is running and waiting for requests. It will keep running until you stop it. You'll send requests from a separate terminal window.

  • Open a second terminal window by pressing Cmd+T (new tab) or Cmd+N (new window) in Terminal.
  • Test the health endpoint by running:
curl http://localhost:3000/health

You should see a JSON response like this (the timestamp will be different):

{"status":"healthy","timestamp":"2026-06-30T12:00:00.000Z"}
  • Test the tasks endpoint by running:
curl http://localhost:3000/tasks

You should see the two starter tasks:

{"tasks":[{"id":1,"title":"Learn Docker","done":false},{"id":2,"title":"Learn Kubernetes","done":false}]}
  • Switch back to the first terminal window (where the server is running).
  • Stop the server by pressing Ctrl+C.

Getting 'Connection refused'?

Make sure you're running curl in the second terminal, not the one running the server. Also check that you see the "running on port 3000" message in the first terminal, confirming the server started.

If you see an error about the port being in use, another process is already on port 3000. Stop it with lsof -i :3000 to find the PID, then kill <PID> to free the port.

help me debug why my Node.js server isn't responding to curl

Your microservice runs and responds to requests locally. Next up, you'll package it into a Docker container so it can run anywhere.

Containerize with Docker

Your microservice runs perfectly on your Mac, but that only proves it works in your specific environment.

To ship software reliably, you need to package your app with its exact runtime so it behaves the same everywhere. That package is called a container image, and you create one by writing a Dockerfile.

In this step, you will write a Dockerfile, build the image, and confirm the containerized app responds the same way it did locally.

In this step, get ready to:
  • Write a production-ready Dockerfile for the task tracker.
  • Build and run the container to verify it works.
  • Clean up the test container.
Write the Dockerfile

A Dockerfile is a recipe that tells Docker how to build your image. Each line adds a layer: pick a base image, copy files in, set who runs the process, and declare the startup command.

  • In VS Code, create a new file called Dockerfile (no extension) in the root of your task-tracker folder.
  • Add the following code to your Dockerfile:
# Use a lightweight Alpine-based Node.js image
FROM node:24-alpine

# Set the working directory inside the container
WORKDIR /app

# Copy application files into the container
COPY package.json ./
COPY server.js ./

# Document the port the app listens on
EXPOSE 3000

# Run as non-root user for security
USER node

# Start the server when the container launches
CMD ["node", "server.js"]

What does this Dockerfile do?

  • FROM node:24-alpine picks a tiny (~50 MB) Linux image that already has Node.js installed.
  • WORKDIR /app sets the folder where all subsequent commands run inside the container.
  • COPY pulls your two source files into the container's filesystem.
  • EXPOSE 3000 documents which port your app listens on.
  • USER node runs the process as a non-root user, which is a security best practice.
  • CMD ["node", "server.js"] tells Docker what command to execute when the container starts.
  • Save Dockerfile.

File not recognised as a Dockerfile?

Make sure the file is named exactly Dockerfile with a capital D and no file extension. If you accidentally added .txt or another extension, rename it in VS Code's Explorer sidebar.

Help me troubleshoot this

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

FROM node:24-alpine

WORKDIR /app

COPY package.json ./
COPY server.js ./

EXPOSE 3000

USER node

CMD ["node", "server.js"]
Build and run the container

With the Dockerfile written, you can now build a container image and spin up a running container from it.

  • Build the image by running this command in your terminal:
docker build -t task-tracker:1.0 .

What does this command do?

  • docker build reads your Dockerfile and produces a container image.
  • -t task-tracker:1.0 tags the image with the name task-tracker and version 1.0.
  • . tells Docker to use the current directory as the build context (where it looks for the Dockerfile and files to copy).
  • Wait for the build to finish. You should see progress steps scrolling by, ending with the image ID and no error messages.

Build failing?

Make sure Docker Desktop is running. Look for the whale icon in your menu bar. If it is not there, open Docker Desktop and wait for it to fully start before retrying the build.

Help me debug my Docker build

  • Run a container from the image by executing this command:
docker run -d -p 3000:3000 --name tracker task-tracker:1.0

What does this command do?

  • -d runs the container in the background (detached mode).
  • -p 3000:3000 maps port 3000 on your Mac to port 3000 inside the container.
  • --name tracker gives the container a friendly name so you can reference it easily.
  • task-tracker:1.0 is the image you just built.
  • You should see a long hexadecimal string printed. That is your container ID, confirming the container started.
  • Test the containerized app by running:
curl http://localhost:3000/health
  • You should see a JSON response like {"status":"healthy","timestamp":"..."}. This confirms the app is running inside the container and responding to requests.
  • Check the container's logs by running:
docker logs tracker
  • You should see Task tracker microservice running on port 3000 in the output.

Getting "connection refused" from curl?

Run docker ps to check if your container is still running. If you do not see a container named tracker in the list, it may have crashed on startup.

Run docker logs tracker to see if there is an error in the output. A common cause is a typo in server.js that prevents the server from starting.

Help me troubleshoot my container

Clean up the test container

You have confirmed the container works. Before moving on, remove this test container so port 3000 is free for the next step.

  • Stop and remove the container by running this command:
docker stop tracker && docker rm tracker
  • You should see tracker printed twice (once for stop, once for remove), confirming both operations succeeded.

Seeing "No such container"?

If the container already stopped or was removed, Docker will report it does not exist. Run docker ps -a to see all containers (including stopped ones) and verify tracker is gone.

Help me understand what happened

Your microservice is containerized and proven to work inside Docker. Next up, you will create a local Kubernetes cluster and deploy your container into it.

Deploy to Kubernetes

Your Task Tracker microservice is packaged inside a Docker image, ready to run anywhere. But running a single container manually is fine for testing. In production, you need multiple copies of your app, automatic restarts when something crashes, and health monitoring.

That is exactly what Kubernetes does. In this step, you will spin up a local Kubernetes cluster using Kind, load your image into it, and deploy your app with manifests that tell Kubernetes how to run and monitor it.

In this step, get ready to:
  • Create a local Kubernetes cluster using Kind and load your Docker image into it.
  • Write and apply a Deployment manifest that runs 2 replicas with health probes.
  • Write and apply a Service manifest that routes traffic to your pods.
Create a Kind cluster and load your image

Kind (Kubernetes in Docker) runs a full Kubernetes cluster as Docker containers on your Mac. It starts in seconds and uses minimal resources.

  • Create a new cluster named microservice by running this command:
kind create cluster --name microservice

What does this command do?

This spins up a Kubernetes cluster running inside Docker containers on your Mac. The --name microservice flag gives your cluster a recognizable name so you can manage it later.

Kind also sets your kubectl context automatically, so all future kubectl commands target this cluster.

  • Wait for the output to show the cluster is ready. You should see a message ending with something like Have a nice day!.
  • Verify your cluster is running by running this command:
kubectl cluster-info
  • You should see output showing the Kubernetes control plane is running at a local address.
  • Confirm the node is ready by running:
kubectl get nodes
  • You should see one node with a Ready status.

Cluster creation failing?

Make sure Docker Desktop is running. Check for the whale icon in your menu bar. If Docker is not running, open it and wait for the icon to stop animating before retrying.

help me troubleshoot why my Kind cluster won't create

Your cluster is running, but it cannot access your locally built Docker image by default. You need to load the image directly into the cluster.

  • Load your task-tracker:1.0 image into the Kind cluster by running:
kind load docker-image task-tracker:1.0 --name microservice

Why load the image manually?

Kind clusters run inside Docker, isolated from your local image cache. Without loading the image, Kubernetes would try to pull it from Docker Hub and fail because task-tracker:1.0 only exists on your machine.

  • You should see output confirming the image was loaded. No errors means success.

Image load failing?

Make sure you built the image in the previous step. Run docker images and confirm task-tracker with tag 1.0 appears in the list.

help me figure out why kind load docker-image is failing

Write and apply the Deployment manifest

A Deployment is a Kubernetes resource that manages your application's lifecycle. It defines how many copies (replicas) of your app to run and automatically restarts them if they crash.

  • In VS Code, create a new file called deployment.yaml inside your task-tracker folder.
  • Add the following Deployment manifest:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: task-tracker
  labels:
    app: task-tracker
spec:
  replicas: 2
  selector:
    matchLabels:
      app: task-tracker
  template:
    metadata:
      labels:
        app: task-tracker
    spec:
      containers:
        - name: task-tracker
          image: task-tracker:1.0
          imagePullPolicy: Never
          ports:
            - containerPort: 3000
          livenessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 3
            periodSeconds: 5

What does this manifest do?

replicas: 2 tells Kubernetes to run two copies of your app. If one crashes, Kubernetes automatically starts a replacement.

imagePullPolicy: Never tells Kubernetes to use the image you loaded directly into the cluster instead of trying to pull it from a remote registry.

The livenessProbe checks if your app is still alive by hitting /health every 10 seconds. The readinessProbe checks if your app is ready to receive traffic every 5 seconds.

  • Save deployment.yaml.
  • Apply the manifest to your cluster by running:
kubectl apply -f deployment.yaml
  • Watch your Pods start up by running:
kubectl get pods -w
  • You should see two pods transition from Pending to ContainerCreating to Running. Both pods should show 1/1 in the READY column.
  • Press Ctrl+C to stop watching once both pods are running.

Pods stuck in ImagePullBackOff?

This means Kubernetes is trying to pull the image from a remote registry instead of using your local copy. Double-check that your deployment.yaml has imagePullPolicy: Never set on the container.

Also confirm you loaded the image into the cluster with kind load docker-image task-tracker:1.0 --name microservice earlier in this step.

help me fix my pods stuck in ImagePullBackOff

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

apiVersion: apps/v1
kind: Deployment
metadata:
  name: task-tracker
  labels:
    app: task-tracker
spec:
  replicas: 2
  selector:
    matchLabels:
      app: task-tracker
  template:
    metadata:
      labels:
        app: task-tracker
    spec:
      containers:
        - name: task-tracker
          image: task-tracker:1.0
          imagePullPolicy: Never
          ports:
            - containerPort: 3000
          livenessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 3
            periodSeconds: 5
Write and apply the Service manifest

Your pods are running, but they have no stable network address. A Kubernetes Service gives your pods a single, consistent endpoint that routes traffic to whichever pods are healthy.

  • In VS Code, create a new file called service.yaml inside your task-tracker folder.
  • Add the following Service manifest:
apiVersion: v1
kind: Service
metadata:
  name: task-tracker
spec:
  selector:
    app: task-tracker
  ports:
    - port: 80
      targetPort: 3000

What does this manifest do?

The selector field matches pods with the label app: task-tracker. Any pod with that label receives traffic from this Service.

The port: 80 is what other services inside the cluster use to reach your app. The targetPort: 3000 maps to the port your Node.js server actually listens on. This is a ClusterIP Service by default, meaning it is only reachable from inside the cluster.

  • Save service.yaml.
  • Apply the Service to your cluster by running:
kubectl apply -f service.yaml
  • Verify the Service was created by running:
kubectl get services
  • You should see task-tracker listed with type ClusterIP and port 80.

Don't see the task-tracker Service?

Make sure you saved service.yaml before running the apply command. Also check that the file is in your task-tracker directory (same folder where your terminal is).

help me figure out why my Kubernetes Service isn't showing up

✔️ Awesome, I've got everything!

Great. Make sure you have saved service.yaml before moving on.

ⓧ I'd like to double check the full code

apiVersion: v1
kind: Service
metadata:
  name: task-tracker
spec:
  selector:
    app: task-tracker
  ports:
    - port: 80
      targetPort: 3000

Your microservice is deployed and managed by Kubernetes. Next up, you will access your app through the Service using port-forwarding and test every endpoint.

Access and Verify Your Microservice

Your microservice is deployed and managed by Kubernetes. Two pods are running, health checks are passing, and a Service routes traffic to them.

Now you need to reach your app from outside the cluster. On macOS, Docker containers run inside a Linux VM, so you cannot access cluster networking directly. The standard workaround is port-forwarding, which creates a tunnel from your Mac to the cluster. In this step, you will forward traffic, test every endpoint, and inspect the running deployment.

In this step, get ready to:
  • Port-forward local traffic into the Kubernetes cluster.
  • Test all four REST API endpoints through the Service.
  • Inspect the deployment, logs, and pod details with kubectl.
Port-forward to access the service

Your Service uses ClusterIP type, which means it is only reachable from inside the cluster. Port-forwarding bridges that gap by tunnelling traffic from a port on your Mac directly into the Service.

  • Start port-forwarding by running this command in your terminal:
kubectl port-forward service/task-tracker 8080:80

What does this command do?

This tells kubectl to listen on port 8080 on your Mac and forward all traffic to port 80 on the task-tracker Service. The Service then routes that traffic to port 3000 on one of your pods.

The command runs in the foreground and will not return. You will need a second terminal window to send requests.

You should see output similar to Forwarding from 127.0.0.1:8080 -> 80. Leave this terminal running and do not press Ctrl+C.

Seeing "unable to forward port" or "connection refused"?

  • Run kubectl get pods and confirm both pods show Running with 1/1 ready. If they are still starting, wait a few seconds and try again.
  • Make sure port 8080 is not already in use on your Mac. If it is, stop the other process or choose a different local port (e.g. 9090:80).
  • help me debug kubectl port-forward
Test all endpoints

With the tunnel open, you can now hit your API as if it were running on your Mac at http://localhost:8080.

  • Open a new terminal window by pressing Cmd+T (new tab) or Cmd+N (new window).
  • Check the health endpoint by running:
curl http://localhost:8080/health
  • You should see a JSON response containing "status":"healthy" and a timestamp field.
  • Fetch the list of tasks by running:
curl http://localhost:8080/tasks
  • You should see two default tasks: "Learn Docker" and "Learn Kubernetes".
  • Create a new task by running:
curl -X POST http://localhost:8080/tasks -H "Content-Type: application/json" -d '{"title":"Deploy to production"}'
  • You should see a 201 response with the new task object containing your title.
  • Delete a task by running:
curl -X DELETE http://localhost:8080/tasks/1
  • You should see {"message":"task deleted"} confirming the deletion.
  • Verify the deletion by fetching tasks again:
curl http://localhost:8080/tasks
  • The task with id 1 ("Learn Docker") should no longer appear in the list.

Why are requests going to different pods?

The Service load-balances between your 2 replicas. Each pod manages its own in-memory task list. If you create a task and then immediately fetch, you might see slightly different data depending on which pod handles the request.

This is expected behaviour for stateless services. In production, a database would give all replicas a shared source of truth.

Getting "connection refused" on localhost:8080?

  • Switch back to your first terminal and confirm the port-forward command is still running. If you accidentally pressed Ctrl+C, run it again.
  • Make sure you are using port 8080 (not 3000). The container port is 3000 internally, but your Mac reaches it through 8080.
  • help me fix curl connection refused
Inspect your Kubernetes resources

Beyond testing endpoints, kubectl gives you visibility into the state of your deployment. These inspection commands are what you will use daily to debug issues in real clusters.

  • View detailed information about the deployment by running:
kubectl describe deployment task-tracker

What should I see?

The output shows the replica count (2 desired, 2 available), the container image (task-tracker:1.0), port mappings, and the health check configuration. Look for Conditions at the bottom. Available True confirms everything is healthy.

  • View the application logs across all pods by running:
kubectl logs -l app=task-tracker
  • You should see the startup message from both pods: Task tracker microservice running on port 3000.
  • View pod details including which node each pod runs on by running:
kubectl get pods -o wide
  • You should see 2 pods listed with their IP addresses, node assignment, and Running status.

What does -l app=task-tracker do?

The -l flag filters resources by label. Since your deployment applies the label app: task-tracker to every pod, this gives you logs from all replicas at once instead of naming each pod individually.

Logs showing errors or pods not in Running state?

  • Run kubectl describe pod followed by the pod name from kubectl get pods and check the Events section at the bottom for error details.
  • If pods are in ImagePullBackOff, make sure you loaded the image into Kind with kind load docker-image task-tracker:1.0 --name microservice.
  • help me debug my Kubernetes pods

Your microservice is fully operational inside Kubernetes. You have verified every endpoint, confirmed health checks are passing, and used kubectl to inspect the cluster from multiple angles. Next up, you will push this further with a zero-downtime rolling update in the Secret Mission.

Secret mission

Perform a Zero-Downtime Rolling Update

Your microservice is running, but what happens when you need to ship a new version? In production, you can't take your app offline to deploy changes. Kubernetes solves this with rolling updates, replacing pods one at a time so users never experience downtime. Your mission: modify your app, rebuild the image, and watch Kubernetes swap pods live.

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • A Kind Kubernetes cluster named microservice (runs as Docker containers on your Mac).
  • Docker images task-tracker:1.0 and task-tracker:2.0 stored locally.
  • Local project files in the ~/Desktop/task-tracker folder.

Keep everything running

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

  • The Kind cluster runs as Docker containers. It persists across reboots as long as Docker Desktop is running.
  • Your project files stay in ~/Desktop/task-tracker.
  • Docker images remain in your local image cache.

Pause - I'll come back to this later

Shut down Docker Desktop to free up memory. The cluster will restart automatically when you reopen Docker.

  • Click the Docker whale icon in your menu bar.
  • Click Quit Docker Desktop.
  • When you are ready to resume, open Docker Desktop again. Your Kind cluster and images will still be there.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Delete the Kind cluster by running:
kind delete cluster --name microservice
  • Remove the Docker images by running:
docker rmi task-tracker:1.0 task-tracker:2.0
  • Delete your local project files by running:
rm -rf ~/Desktop/task-tracker
  • Confirm everything is gone by running:
kind get clusters
docker images | grep task-tracker
ls ~/Desktop/task-tracker

All three commands should return empty output or "No resources found" messages.

That's a Wrap!

That's a Wrap!

Nice work! You just built a containerized microservice and deployed it to a local Kubernetes cluster using the same patterns production teams rely on every day.

You've learned how to:

  • Containerized a Node.js microservice with a production-ready Dockerfile using an Alpine base image and non-root user.
  • Created a local Kubernetes cluster with Kind and wrote Deployment and Service manifests with health probes and multiple replicas.
  • Deployed your app and accessed it through kubectl port-forwarding, verifying all REST API endpoints respond correctly from inside the cluster.
  • Secret Mission: Performed a zero-downtime rolling update and watched Kubernetes replace pods one by one without dropping a single request.

Ready to quiz yourself?