The Gap Between Dev and Production
Most container tutorials end at "run docker-compose up and it works." Production is a different game entirely. Memory leaks kill your pods. Missing health probes cause traffic to route to crashed instances. No resource limits let one bad pod starve the entire node. Missing graceful shutdown handling causes dropped requests during deployments.
This guide is about the 20% of Docker and Kubernetes knowledge that covers 80% of production problems.
Writing Production-Grade Dockerfiles
The Anti-Pattern Dockerfile
# ❌ DON'T DO THIS
FROM node:latest
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "src/index.js"]Problems: uses latest tag (non-deterministic), copies everything including node_modules, runs as root, massive image size (1.2GB+).
The Production Dockerfile
# Stage 1: Dependencies
FROM node:20.11.0-alpine3.19 AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Stage 2: Build
FROM node:20.11.0-alpine3.19 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 3: Production image
FROM node:20.11.0-alpine3.19 AS runner
WORKDIR /app
# Security: run as non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
# Copy only what's needed
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./
EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
# Graceful shutdown support
STOPSIGNAL SIGTERM
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
CMD ["node", "server.js"]What changed:
- Pinned Node version with specific Alpine tag
- Multi-stage build (final image is ~200MB vs 1.2GB)
- Runs as non-root user (UID 1001)
- Built-in HEALTHCHECK
- STOPSIGNAL for graceful shutdown
- Only production artifacts in final stage
Kubernetes Deployment Configuration
Deployment Manifest with Best Practices
apiVersion: apps/v1
kind: Deployment
metadata:
name: nextjs-app
namespace: production
labels:
app: nextjs-app
version: "1.0.0"
spec:
replicas: 3
selector:
matchLabels:
app: nextjs-app
# Rolling update strategy — zero downtime
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # Create 1 extra pod before removing old
maxUnavailable: 0 # Never remove a pod until new one is healthy
template:
metadata:
labels:
app: nextjs-app
spec:
# Spread across nodes for HA
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: nextjs-app
# Graceful shutdown — wait for existing requests to complete
terminationGracePeriodSeconds: 30
containers:
- name: nextjs-app
image: ghcr.io/bipinbaral/portfolio:1.0.0
imagePullPolicy: Always
ports:
- containerPort: 3000
# Environment from Kubernetes Secrets
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: app-secrets
key: database-url
- name: NODE_ENV
value: "production"
# Resource limits — CRITICAL for stability
resources:
requests:
cpu: "100m" # 0.1 CPU core
memory: "256Mi"
limits:
cpu: "500m" # Max 0.5 CPU core
memory: "512Mi" # Hard limit — pod killed if exceeded
# Liveness probe — restart pod if app is hung
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
periodSeconds: 10
failureThreshold: 3
# Readiness probe — remove from service if not ready
readinessProbe:
httpGet:
path: /api/ready
port: 3000
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 2
# Startup probe — give app time to boot before liveness kicks in
startupProbe:
httpGet:
path: /api/health
port: 3000
failureThreshold: 30
periodSeconds: 2Health Check Endpoints
Your app MUST implement proper health endpoints:
// app/api/health/route.ts — Liveness check
export async function GET() {
return Response.json({ status: "ok", timestamp: Date.now() });
}
// app/api/ready/route.ts — Readiness check (includes DB connection)
import { db } from "@/lib/db";
export async function GET() {
try {
// Verify database connection
await db.execute("SELECT 1");
return Response.json({
status: "ready",
database: "connected",
timestamp: Date.now()
});
} catch (error) {
return Response.json(
{ status: "not ready", error: "Database unavailable" },
{ status: 503 }
);
}
}Liveness vs Readiness distinction:
- Liveness: "Is the process alive?" If NO → Kubernetes restarts the pod
- Readiness: "Can this pod accept traffic?" If NO → Kubernetes removes it from the service load balancer (but doesn't restart it)
Horizontal Pod Autoscaling (HPA)
Scale automatically based on CPU and memory:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: nextjs-app-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: nextjs-app
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70 # Scale up when CPU > 70%
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleUp:
stabilizationWindowSeconds: 60 # Wait 60s before scaling up
policies:
- type: Pods
value: 2 # Add max 2 pods at a time
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300 # Wait 5 minutes before scaling downSecrets Management
Never put secrets in environment variables or ConfigMaps in plain text:
# Create secret from literal values
kubectl create secret generic app-secrets \
--from-literal=database-url="postgresql://user:pass@host/db" \
--from-literal=jwt-secret="your-256-bit-secret" \
--namespace=production
# OR from a file (for certificates, SSH keys)
kubectl create secret generic tls-certs \
--from-file=tls.crt=./cert.pem \
--from-file=tls.key=./key.pem \
--namespace=productionFor production, use External Secrets Operator to sync from AWS Secrets Manager, HashiCorp Vault, or GCP Secret Manager. Kubernetes-native secrets are base64-encoded, not encrypted.
Zero-Downtime Deployment Checklist
Before deploying to production:
- ✅ Health check endpoints implemented (
/api/healthand/api/ready) - ✅
terminationGracePeriodSeconds> your longest request timeout - ✅
maxUnavailable: 0in rolling update strategy - ✅ Resource limits set on every container
- ✅ Liveness, readiness, AND startup probes configured
- ✅ Non-root user in Dockerfile
- ✅ Pinned Docker image tag (never use
latestin production) - ✅ Pod Disruption Budget configured
- ✅ Topology spread constraints for HA across nodes
Conclusion
The difference between "it works in Docker" and "it runs reliably in Kubernetes production" is configuration discipline. Health probes, resource limits, rolling update strategy, and graceful shutdown are not optional extras — they're the foundation of production-grade container deployments.


