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.