Skip to main content

Hands-On: Deploy an Express App to Kubernetes

What We Are Building

An Express API running as 3 replicas on a local minikube cluster, exposed through a Service, with a rolling update at the end. The same manifests work unchanged on EKS or any managed cluster — only the Service type and image registry differ.

Step 1 — Start minikube

brew install minikube # macOS
minikube start
kubectl get nodes
# NAME STATUS ROLES AGE VERSION
# minikube Ready control-plane 30s v1.28.0

Step 2 — The Express App and Dockerfile

A minimal server.js:

const express = require('express');
const app = express();

app.get('/health', (req, res) => {
res.json({ status: 'ok', pod: process.env.HOSTNAME, version: 'v1' });
});

app.listen(3000, () => console.log('listening on 3000'));

Returning process.env.HOSTNAME is a small trick that pays off later — Kubernetes sets it to the pod name, so we can see load balancing across replicas with plain curl.

Dockerfile:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
USER node
CMD ["node", "server.js"]

Step 3 — Build the Image Inside minikube

minikube runs its own Docker daemon. If you build with your host Docker, the cluster cannot see the image. Point your shell at minikube's daemon first:

eval $(minikube docker-env)
docker build -t express-api:v1 .
docker images | grep express-api

This is the single most common beginner stumble with minikube, so it is worth internalizing: build where the cluster can pull.

Step 4 — Write deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
name: express-api
labels:
app: express-api
spec:
replicas: 3
selector:
matchLabels:
app: express-api
template:
metadata:
labels:
app: express-api
spec:
containers:
- name: express-api
image: express-api:v1
imagePullPolicy: Never
ports:
- containerPort: 3000
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "128Mi"
cpu: "250m"
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 3
periodSeconds: 5

Two lines deserve attention:

  • imagePullPolicy: Never — the image exists only in minikube's local daemon; without this, K8s tries to pull express-api:v1 from Docker Hub and fails with ErrImagePull. On a real cluster you would push to ECR or Docker Hub and drop this line.
  • The readinessProbe — K8s only routes traffic to a pod after /health responds. During rolling updates this is what makes deploys zero-downtime.

Step 5 — Write service.yaml

apiVersion: v1
kind: Service
metadata:
name: express-api
spec:
selector:
app: express-api
ports:
- port: 80
targetPort: 3000
type: NodePort

NodePort is the practical choice on minikube, where there is no cloud load balancer to provision. In production on EKS this one field changes to LoadBalancer.

Step 6 — Apply and Inspect

kubectl apply -f deployment.yaml
kubectl apply -f service.yaml

kubectl get pods
# NAME READY STATUS RESTARTS AGE
# express-api-7d9f8b6c5-4x2kp 1/1 Running 0 15s
# express-api-7d9f8b6c5-8mzqt 1/1 Running 0 15s
# express-api-7d9f8b6c5-tj6wn 1/1 Running 0 15s

kubectl get service express-api
kubectl logs deployment/express-api --tail=5

kubectl logs deployment/... picks one pod for you; add -f to stream, or use the pod name for a specific replica.

Step 7 — Hit the Service

minikube service express-api --url
# http://127.0.0.1:54321

curl http://127.0.0.1:54321/health
# {"status":"ok","pod":"express-api-7d9f8b6c5-4x2kp","version":"v1"}

curl http://127.0.0.1:54321/health
# {"status":"ok","pod":"express-api-7d9f8b6c5-8mzqt","version":"v1"}

Repeated curls return different pod names — that is the Service load balancing across the 3 replicas.

Step 8 — Kill a Pod and Watch Reconciliation

kubectl delete pod express-api-7d9f8b6c5-4x2kp
kubectl get pods

Within seconds a replacement pod appears with a new name. Nobody restarted anything — the Deployment noticed actual state (2 replicas) drifted from desired state (3) and reconciled.

Step 9 — Scale

kubectl scale deployment express-api --replicas=5
kubectl get pods
# 5 pods running

kubectl scale deployment express-api --replicas=3

Scaling by command is fine for experiments, but in a real repo you would edit replicas: in the YAML and re-apply, so Git stays the source of truth.

Step 10 — Roll Out a New Version

Change version: 'v1' to 'v2' in server.js, rebuild, and update the Deployment:

docker build -t express-api:v2 .
kubectl set image deployment/express-api express-api=express-api:v2
kubectl rollout status deployment/express-api
# deployment "express-api" successfully rolled out

curl http://127.0.0.1:54321/health
# {"status":"ok","pod":"express-api-6c8d7f9b4-qw2rt","version":"v2"}

Old pods were terminated only as new ones passed their readiness probe — requests kept succeeding throughout. If v2 were broken:

kubectl rollout undo deployment/express-api

Step 11 — Clean Up

kubectl delete -f service.yaml -f deployment.yaml
minikube stop

Common Problems

ErrImagePull or ImagePullBackOff — you built the image on the host daemon, not minikube's. Re-run eval $(minikube docker-env) in the current shell and rebuild; also confirm imagePullPolicy: Never is set.

Pods stuck in Pending — usually insufficient cluster resources. kubectl describe pod <name> shows the scheduler's reason at the bottom under Events.

Service returns connection refused — check that the Service selector exactly matches the pod labels, and that targetPort matches containerPort. kubectl get endpoints express-api should list pod IPs; an empty list means the selector matches nothing.