I remember the moment I realized Docker Compose wasn't enough anymore.
I was running a side project â a small SaaS with maybe 200 active users â on a single DigitalOcean droplet. Docker Compose handled everything: the Node.js API, PostgreSQL, Redis, an Nginx reverse proxy. One YAML file, one docker-compose up, done.
Then the database went down at 2 AM. Not a crash â the container just stopped. By the time I woke up and ran docker-compose restart, I'd lost three hours of uptime. When it happened again two weeks later during peak usage, I knew I needed something smarter. Something that could restart failed containers automatically, distribute load across multiple servers, and let me update the API without taking the whole site offline.
That's when I started learning Kubernetes. Not because it's trendy or because "everyone uses it now." I needed orchestration â a system that could manage my containers when I couldn't be there.
This guide walks you through the path I took: from a working Dockerfile to a production-ready Kubernetes cluster. You'll learn how Docker and Kubernetes work together, when the complexity is worth it, and how to migrate from Compose to K8s without breaking your application. Every command and manifest here is tested and working â the same setup I use today.
Docker and Kubernetes: How They Work Together
The first time someone told me "Kubernetes runs Docker containers," I thought it was redundant. If Docker already runs containers, why do I need Kubernetes?
Here's the distinction: Docker builds and packages containers. Kubernetes orchestrates and manages them at scale.
Think of Docker as the engine that creates a standardized shipping container for your application. It bundles your code, dependencies, and runtime into an image that runs the same way everywhere. When you run docker run, you're starting one container on one machine.
Kubernetes is the logistics system that manages hundreds of those containers across multiple machines. It decides where containers run, monitors their health, restarts them when they fail, and handles traffic routing. You tell Kubernetes "I want three copies of this container running at all times," and it makes that happen â even if servers crash or traffic spikes.
You need both. Docker creates the container images. Kubernetes deploys and manages them in production. They're not competing tools â Kubernetes uses Docker (or other container runtimes like containerd) under the hood.
The relationship:
- Container runtime (Docker, containerd): Runs individual containers on a single machine
- Orchestration platform (Kubernetes): Manages containers across multiple machines
When you're running one or two containers on one server, Docker Compose is enough. When you need automatic failover, zero-downtime deployments, or horizontal scaling, that's when Kubernetes pays off.
Prerequisites: Setting Up Your Development Environment
Before deploying to Kubernetes, you need a local cluster to test against. Here's the setup I use â the path of least resistance for getting started.
Docker Desktop with Kubernetes enabled is the easiest option for Mac and Windows. It bundles everything: Docker, kubectl (the Kubernetes command-line tool), and a single-node Kubernetes cluster.
- Install Docker Desktop
- Open Docker Desktop â Settings â Kubernetes â Enable Kubernetes
- Wait a few minutes for the cluster to start
Verify it's working:
kubectl version --client
kubectl cluster-info
For Linux users, I use k3d â a lightweight Kubernetes distribution that runs in Docker containers:
curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
k3d cluster create dev-cluster
kubectl get nodes
Alternative options: Minikube (well-documented, heavier) or kind (popular in CI pipelines).
Creating a Production-Ready Dockerfile
Here's the Dockerfile I use for Node.js applications in 2026:
# Stage 1: Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: Production stage
FROM node:20-alpine
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Copy dependencies from builder
COPY --from=builder /app/node_modules ./node_modules
COPY server.js ./
RUN chown -R nodejs:nodejs /app
USER nodejs
EXPOSE 3000
CMD ["node", "server.js"]
Why multi-stage builds? The second stage copies only the final artifacts â no build tools, no npm cache, just the runtime. Smaller image, faster pulls.
Why node:20-alpine? Alpine Linux is a minimal base image (~5MB vs ~200MB for Debian). Node 20 is the 2026 LTS. Always pin versions â latest breaks deployments.
Why a non-root user? If an attacker compromises your application, they shouldn't have root privileges inside the container.
Layer caching: COPY package*.json comes before COPY server.js. When you change application code, only the final layer invalidates. Dependency installation stays cached. Rebuilds are fast.
The .dockerignore file:
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.DS_Store
*.md
Build and test:
docker build -t demo-app:v1 .
docker run -p 3000:3000 demo-app:v1
From Docker Run to Kubernetes: Understanding the Concepts
Kubernetes has a reputation for complexity, but the core concepts map directly to Docker:
| Docker Concept | Kubernetes Equivalent | What Changed |
|---|---|---|
docker run |
Pod | Pods can run multiple containers together |
docker-compose.yml |
Deployment + Service | Deployment manages replicas, Service routes traffic |
| Container | Container (inside a Pod) | Same thing, different layer |
docker network |
Service, Ingress | Services are load balancers, Ingress routes HTTP |
-p 3000:3000 |
containerPort + Service |
Service exposes pods to the network |
--restart unless-stopped |
Deployment (automatic) | Kubernetes restarts Pods by default |
-e KEY=value |
ConfigMap, Secret | ConfigMaps for config, Secrets for sensitive data |
Pods are the smallest deployable unit. A Pod runs one or more containers sharing networking and storage.
Deployments maintain a desired replica count. If a Pod crashes, Kubernetes starts a new one automatically.
Services give Pods a stable IP address and DNS name, load-balancing traffic across replicas.
Ingress routes external HTTP/HTTPS traffic to Services â like Nginx, but managed by Kubernetes.
Deploying Your First Application to Kubernetes
Step 1: Push your image to a registry
docker build -t your-username/demo-app:v1 .
docker login
docker push your-username/demo-app:v1
Step 2: Create k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo-app
labels:
app: demo-app
spec:
replicas: 3
selector:
matchLabels:
app: demo-app
template:
metadata:
labels:
app: demo-app
spec:
containers:
- name: demo-app
image: your-username/demo-app:v1
ports:
- containerPort: 3000
env:
- name: PORT
value: "3000"
- name: NODE_ENV
value: "production"
Step 3: Create k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
name: demo-app-service
spec:
selector:
app: demo-app
ports:
- protocol: TCP
port: 80
targetPort: 3000
type: LoadBalancer
Step 4: Deploy and verify
kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml
kubectl get pods
kubectl get deployment demo-app
kubectl get service demo-app-service
You should see 3 Pods in Running status. Debugging:
kubectl describe pod <pod-name>
kubectl logs <pod-name>
kubectl logs -f <pod-name>
Access your app: kubectl get service demo-app-service â look for EXTERNAL-IP. On Docker Desktop it's localhost.
Kubernetes Production Best Practices
Resource Requests and Limits
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "200m"
100m = 0.1 CPU cores. 128Mi = 128 mebibytes. If a Pod exceeds 256Mi memory, Kubernetes kills it (OOMKilled). CPU limits throttle instead of kill.
How to pick values: Run under load and check docker stats. Start conservative.
Liveness and Readiness Probes
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
Add these endpoints to your Node.js app:
app.get('/health', (req, res) => res.json({ status: 'healthy' }));
app.get('/ready', (req, res) => {
if (databaseConnected) {
res.json({ status: 'ready' });
} else {
res.status(503).json({ status: 'not ready' });
}
});
Without probes, Kubernetes routes traffic to Pods that haven't started yet or have crashed. I've debugged too many "why is my app 500ing" incidents that turned out to be missing probes.
ConfigMaps and Secrets
apiVersion: v1
kind: ConfigMap
metadata:
name: demo-app-config
data:
PORT: "3000"
NODE_ENV: "production"
LOG_LEVEL: "info"
envFrom:
- configMapRef:
name: demo-app-config
For secrets:
kubectl create secret generic demo-app-secrets \
--from-literal=DB_PASSWORD=supersecret
envFrom:
- secretRef:
name: demo-app-secrets
Rolling Updates and Rollbacks
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
Update the image tag, apply, and Kubernetes replaces Pods one at a time with no downtime. Roll back when something breaks:
kubectl rollout undo deployment/demo-app
kubectl rollout history deployment/demo-app
Horizontal Pod Autoscaling
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: demo-app-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: demo-app
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
When CPU exceeds 70%, Kubernetes adds Pods. When it drops, Kubernetes removes them. HPA requires the Metrics Server â most managed services (GKE, EKS, AKS) include it by default.
Migrating from Docker Compose to Kubernetes
Use Kompose for automated conversion:
brew install kompose # macOS
# Linux: download from GitHub releases
kompose convert
Example docker-compose.yml:
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- PORT=3000
- NODE_ENV=production
restart: unless-stopped
redis:
image: redis:7-alpine
ports:
- "6379:6379"
restart: unless-stopped
Kompose generates deployment and service manifests. Add resource limits, probes, and secrets manually.
What Doesn't Translate 1:1
Volumes: Docker's host-directory mounts become PersistentVolumes and PersistentVolumeClaims.
depends_on: Kubernetes doesn't guarantee startup order. Use readiness probes â your app should retry connections until dependencies are ready.
Networks: In Kubernetes, Pods communicate via Service DNS names. Your app Deployment reaches Redis at redis-service:6379.
When to Migrate
Migrate to Kubernetes when:
- You need high availability across multiple servers
- You're scaling horizontally
- You want zero-downtime deployments
- Multiple developers deploy simultaneously
If you're on a single VPS with Docker Compose and it works, don't migrate. Only adopt Kubernetes when the problems it solves are problems you actually have.
Monitoring, Logging, and Debugging in Production
Essential kubectl Commands
kubectl get pods
kubectl describe pod <pod-name>
kubectl logs <pod-name>
kubectl logs -f <pod-name>
kubectl logs -l app=demo-app
kubectl exec -it <pod-name> -- /bin/sh
kubectl port-forward pod/<pod-name> 3000:3000
Common Deployment Issues
Pods stuck in Pending: Not enough resources on any Node. Check kubectl describe pod <pod-name>.
CrashLoopBackOff: Container keeps crashing. Check kubectl logs <pod-name>. Common causes: missing env vars, bad image, app crashes on startup.
Service not routing traffic: Check that Service selector matches Pod labels: kubectl get pods --show-labels.
Image pull errors: Check image name and tag. Private registries need an image pull secret.
Most issues surface in kubectl describe pod events or kubectl logs. When something breaks, start there.
Prometheus and Grafana
For production monitoring:
helm install prometheus prometheus-community/prometheushelm install grafana grafana/grafana- Configure Prometheus as a Grafana data source
- Import the "Kubernetes Cluster Monitoring" dashboard
On GKE, EKS, or AKS, use the built-in monitoring instead â it integrates automatically.
Tested environment: Node.js 20.19.2 LTS, Docker 27.1, Kubernetes 1.30 (local k3d cluster)
When Kubernetes Is Worth It (And When It Isn't)
Kubernetes is overkill for most side projects. If you're running a blog, a small SaaS, or an internal tool on one server, Docker Compose is enough.
Kubernetes makes sense when:
- You're running on multiple servers and need workload distribution
- Downtime costs you money â you need automatic failover and rolling updates
- You're scaling a team â multiple developers deploying independently
- You need fine-grained resource control and autoscaling
It doesn't make sense when:
- Your app fits on one server
- You don't have time to learn Kubernetes properly
- You're optimizing for simplicity over resilience
I run Kubernetes for client projects where uptime matters. I run Docker Compose for my personal blog. The right tool depends on the problem.
If you've made it this far, you have everything you need to deploy a real application to Kubernetes. The YAML manifests here are production-ready â I use variations of them in production today. Start small, test locally, and only move to a cloud cluster when you're confident the pieces fit together.
The learning curve is steep. But once you've deployed a few apps, the patterns repeat. And when that 2 AM database crash happens again, Kubernetes will restart the Pod before you even wake up.