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 pullexpress-api:v1from Docker Hub and fails withErrImagePull. 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/healthresponds. 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.