I avoided Kubernetes for two years. I thought it was overkill for small projects. Then I deployed an app that needed to handle traffic spikes, rolling updates, and auto-recovery. Docker Compose wasn’t enough anymore. Here’s everything I wish someone told me before I started.
Why Kubernetes Exists
Let me be blunt: if you’re deploying to a single server and don’t expect traffic spikes, you don’t need Kubernetes. Use Docker Compose. Seriously.
Kubernetes solves three specific problems at scale:
- Self-healing: If your app crashes at 3 AM, Kubernetes restarts it automatically. No PagerDuty wake-up call needed.
- Scaling: When Black Friday traffic hits, Kubernetes spins up more copies of your app. When traffic dies down, it scales back to save money.
- Zero-downtime deploys: Push new code without any user seeing an error page. Kubernetes rolls out updates gradually.
The catch: Kubernetes has a steep learning curve. The YAML files are intimidating, the concepts are abstract, and debugging feels like navigating a maze. But once it clicks, it’s incredibly powerful.
The Core Concepts (Simplified)
Forget the official documentation for a moment. Here’s how I explain Kubernetes to developers:
Pod: A Wrapper Around Your Container
A Pod is the smallest thing Kubernetes can manage. It’s a wrapper around one or more containers. In 99% of cases, a Pod contains exactly one container — your app.
# my-app-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: my-app
labels:
app: my-app
spec:
containers:
- name: my-app
image: my-registry/my-app:1.0.0
ports:
- containerPort: 3000
resources:
requests:
memory: "128Mi"
cpu: "250m"
limits:
memory: "256Mi"
cpu: "500m"
But here’s the thing: you almost never create Pods directly. You create Deployments, and the Deployment creates Pods for you. This is like how you don’t create EC2 instances manually — you use Auto Scaling Groups.
Deployment: Your Pod’s Manager
A Deployment tells Kubernetes: “I want 3 copies of my app running at all times. If one dies, replace it.”
# my-app-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
replicas: 3
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: my-app
image: my-registry/my-app:1.0.0
ports:
- containerPort: 3000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
Notice the readinessProbe. This is crucial — Kubernetes won’t send traffic to your Pod until it’s ready. Without it, users might hit a Pod that’s still starting up.
Service: The Network Layer
Pods are ephemeral — they get created and destroyed constantly. You can’t rely on a Pod’s IP address. A Service gives your Pods a stable DNS name and load balances traffic across them.
# my-app-service.yaml
apiVersion: v1
kind: Service
metadata:
name: my-app
spec:
selector:
app: my-app
ports:
- protocol: TCP
port: 80
targetPort: 3000
type: ClusterIP
Now other apps in the cluster can reach your app at http://my-app (yes, Kubernetes has built-in DNS).
My First Real Deployment
Let me walk you through deploying an actual Node.js app to Kubernetes. I’ll use Minikube for local testing, but the same manifests work in production.
Step 1: Containerize Your App
Start with a good Dockerfile:
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production=false
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]
Step 2: Push to a Registry
# Build and tag
docker build -t my-registry/my-app:1.0.0 .
# Push to registry (Docker Hub, GCR, ECR, etc.)
docker push my-registry/my-app:1.0.0
Step 3: Deploy to Kubernetes
# Apply all manifests at once
kubectl apply -f k8s/
# Check if Pods are running
kubectl get pods
# NAME READY STATUS RESTARTS AGE
# my-app-7d4b8c9f-x2k1m 1/1 Running 0 30s
# my-app-7d4b8c9f-y3n2p 1/1 Running 0 30s
# my-app-7d4b8c9f-z4q3r 1/1 Running 0 30s
# Check the Service
kubectl get svc my-app
# NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
# my-app ClusterIP 10.96.45.123 NONE 80/TCP 1m
Step 4: Test It
# Port-forward to access locally
kubectl port-forward svc/my-app 8080:80
# Now open http://localhost:8080 in your browser
# Your app is running on Kubernetes!
Debugging: The Survival Guide
When things go wrong (and they will), these commands save lives:
# Why isn't my Pod starting?
kubectl describe pod my-app-7d4b8c9f-x2k1m
# Look for "Events" section at the bottom
# What are the logs?
kubectl logs my-app-7d4b8c9f-x2k1m
# For previous container (if it crashed):
kubectl logs my-app-7d4b8c9f-x2k1m --previous
# Is my Service routing correctly?
kubectl get endpoints my-app
# Should show Pod IPs. If empty, labels don't match.
# Run a temporary debug pod
kubectl run debug --rm -it --image=alpine sh
# Inside the pod:
wget -qO- http://my-app
# If this works but your browser doesn't, it's an Ingress/Service issue
The 3 Things I Got Wrong
These mistakes cost me hours of debugging:
1. Resource Limits Are Not Optional
Without resource limits, a single Pod can eat all your node’s CPU and memory. I learned this the hard way — one Pod’s memory leak crashed every other Pod on the node.
# Always set resource requests AND limits
resources:
requests:
memory: "128Mi" # Guaranteed minimum
cpu: "250m" # 0.25 CPU cores guaranteed
limits:
memory: "256Mi" # Maximum before OOMKill
cpu: "500m" # Maximum before throttling
2. Liveness vs. Readiness Probes
Readiness probe: “Am I ready to receive traffic?” (Temporary failure = stop sending traffic)
Liveness probe: “Am I alive?” (Failure = restart the Pod)
Mixing these up is catastrophic. If your liveness probe is too aggressive, Kubernetes will kill and restart healthy Pods during high load (when the probe times out). I recommend starting with generous timeouts:
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 5
3. Secrets Management
Never put secrets in your Deployment YAML. Use Kubernetes Secrets (or better, external secret managers like HashiCorp Vault or AWS Secrets Manager):
# Create a secret
kubectl create secret generic db-secret \
--from-literal=url='postgres://user:pass@db:5432/mydb'
# Reference in your Deployment (see env section above)
When NOT to Use Kubernetes
I’ll say it again: Kubernetes is not always the answer.
- Personal projects: Use Vercel, Railway, or Fly.io. Free tiers are generous.
- Small teams (<5 devs): The operational overhead isn’t worth it. Use managed platforms.
- Simple APIs: If your app is one container with no scaling needs, Docker Compose on a single VPS is simpler and cheaper.
Use Kubernetes when you have multiple services that need to communicate, need auto-scaling, or require zero-downtime deployments across multiple nodes.
Conclusion
Kubernetes is a powerful tool, but it’s not magic. Start simple — one Deployment, one Service. Get comfortable with kubectl commands. Then gradually add complexity as you need it. The best Kubernetes setup is the simplest one that meets your requirements.
The investment is worth it when you reach the point where you need it. Your 3 AM self will thank you when Kubernetes automatically restarts your crashed Pod and nobody notices.