Docker and Kubernetes for Full-Stack JS Developers
A practical guide to containerization and orchestration from a developer who learned the hard way. Real examples, gotchas, and deployment patterns.
I spent my first three years as a developer deploying Node.js apps the old-fashioned way: SSH into an EC2 instance, pull from git, run npm install, restart PM2, and pray nothing broke. It worked until it didn't. The "works on my machine" problem was real, and scaling meant manually spinning up more servers and configuring load balancers. Then I discovered Docker and Kubernetes, and honestly, I wish someone had explained them to me from a JavaScript developer's perspective earlier.
This isn't going to be another "what is a container" tutorial. Instead, I'll share how I actually use Docker and Kubernetes in production for full-stack TypeScript applications, including the mistakes I made and the patterns that work.
Why Containerization Actually Matters for JS Developers
Here's the thing: Node.js apps are relatively easy to deploy compared to, say, a Java application. You don't need Docker to run Express or Next.js. But containerization solves problems you don't realize you have until you're debugging why your app works locally but crashes in production.
Last year, we had a Next.js app that worked perfectly on my MacBook but failed on our Ubuntu CI/CD pipeline. The culprit? A native dependency in sharp (an image processing library) that compiled differently on different platforms. With Docker, you build once and run anywhere. The container that runs on my laptop is byte-for-byte identical to what runs in production.
Docker also forces you to think about dependencies explicitly. No more "oh, I forgot to document that you need Redis installed" moments. If it's not in your Dockerfile or docker-compose.yml, it doesn't exist.
Dockerizing a Node.js Application
Here's a production-ready Dockerfile for a typical Express API. I'll explain the gotchas after:
# Use specific version, not latest
FROM node:18-alpine AS builder
# Create app directory
WORKDIR /app
# Copy package files first (layer caching)
COPY package*.json ./
COPY tsconfig.json ./
# Install dependencies
RUN npm ci --only=production && npm cache clean --force
# Copy source
COPY src ./src
# Build TypeScript
RUN npm run build
# Production stage
FROM node:18-alpine
WORKDIR /app
# Copy only necessary files from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
USER nodejs
EXPOSE 3000
CMD ["node", "dist/index.js"]
The Multi-Stage Build Pattern
This multi-stage approach cut our image size from 1.2GB to 180MB. The first stage (builder) has all the build tools and dev dependencies. The second stage only gets the compiled code and production dependencies. In practice, this makes deployments faster and reduces attack surface.
The gotcha here is the COPY order. Docker caches layers, so if you copy package.json before your source code, npm install only runs when dependencies change, not on every code change. This alone saved us probably 30 minutes a day in build time across the team.
Next.js Specific Considerations
Next.js is trickier because it has both build-time and runtime dependencies. Here's what worked for us:
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
You need to enable standalone mode in next.config.js for this to work:
module.exports = {
output: 'standalone',
// other config
}
Local Development with Docker Compose
Docker Compose is where containerization really shines for local development. I used to spend hours helping new team members set up Postgres, Redis, and MongoDB on their machines. Now they run one command.
Here's our actual docker-compose.yml for a full-stack app:
version: '3.8'
services:
api:
build:
context: ./api
dockerfile: Dockerfile.dev
ports:
- "3001:3001"
volumes:
- ./api/src:/app/src
- /app/node_modules
environment:
- DATABASE_URL=postgresql://user:pass@postgres:5432/mydb
- REDIS_URL=redis://redis:6379
depends_on:
- postgres
- redis
web:
build:
context: ./web
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
volumes:
- ./web/src:/app/src
- /app/node_modules
environment:
- NEXT_PUBLIC_API_URL=http://localhost:3001
postgres:
image: postgres:15-alpine
ports:
- "5432:5432"
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=mydb
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
postgres_data:
The volume mount for node_modules is crucial. Without /app/node_modules as an anonymous volume, the host's (likely empty) node_modules would override the container's, and nothing would work. This bit me for days when I first started.
When and Why Kubernetes
Here's my honest take: if you're a small team with a few services, you probably don't need Kubernetes. We ran happily on AWS ECS for two years. Kubernetes becomes valuable when you have multiple microservices, need advanced deployment strategies (canary, blue-green), or want portability across cloud providers.
We moved to Kubernetes (GKE specifically) when we had six microservices and were spending too much time on deployment orchestration. Kubernetes gave us declarative configuration, automatic scaling, and self-healing.
Deploying a Node.js App to Kubernetes
A basic Kubernetes deployment for our Express API looks like this:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-deployment
labels:
app: api
spec:
replicas: 3
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: gcr.io/my-project/api:latest
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: "production"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: api-secrets
key: database-url
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
Health Checks Are Critical
Those liveness and readiness probes aren't optional. I learned this when we had a deployment that looked successful but was serving 500 errors. Kubernetes kept routing traffic to pods that were technically running but couldn't connect to the database.
Implement actual health check endpoints:
// Express health check
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
app.get('/ready', async (req, res) => {
try {
// Check database connection
await db.query('SELECT 1');
// Check Redis
await redis.ping();
res.status(200).json({ status: 'ready' });
} catch (error) {
res.status(503).json({ status: 'not ready', error: error.message });
}
});
Services and Ingress for Traffic Routing
A Deployment alone doesn't expose your app. You need a Service:
apiVersion: v1
kind: Service
metadata:
name: api-service
spec:
selector:
app: api
ports:
- protocol: TCP
port: 80
targetPort: 3000
type: ClusterIP
For external access, use an Ingress with SSL termination:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-ingress
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
tls:
- hosts:
- api.example.com
secretName: api-tls
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-service
port:
number: 80
CI/CD Pipeline for Kubernetes
We use GitHub Actions to build, push, and deploy. Here's a simplified workflow:
name: Deploy to GKE
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@v1
with:
service_account_key: ${{ secrets.GCP_SA_KEY }}
project_id: my-project
- name: Configure Docker
run: gcloud auth configure-docker
- name: Build and Push
run: |
docker build -t gcr.io/my-project/api:${{ github.sha }} .
docker push gcr.io/my-project/api:${{ github.sha }}
- name: Deploy to GKE
run: |
gcloud container clusters get-credentials my-cluster --zone us-central1-a
kubectl set image deployment/api-deployment api=gcr.io/my-project/api:${{ github.sha }}
kubectl rollout status deployment/api-deployment
Common Gotchas and Lessons Learned
Resource limits are not optional. We had a memory leak in a Next.js app that brought down an entire node in our cluster because we didn't set limits. Now every container has explicit requests and limits.
Don't run as root. Security scanners will yell at you, and for good reason. Always create a non-root user in your Dockerfile.
Use specific image tags. Never use :latest in production. We use git commit SHAs as tags so we always know exactly what's running.
Environment variables in Kubernetes are annoying. We use a combination of ConfigMaps for non-sensitive config and Secrets for credentials. Managing secrets across environments is still painful, honestly. We're looking at external secret managers like Google Secret Manager.
Key Takeaways
Docker and Kubernetes have a learning curve, but they solve real problems for JavaScript developers. Start with Docker for consistent local development and production deployments. Move to Kubernetes when you need orchestration across multiple services.
My advice: don't over-engineer. We wasted weeks trying to set up Helm charts and complex CI/CD pipelines before we even validated our product. Start simple with Docker Compose locally and a basic Dockerfile for production. Add Kubernetes when the pain of not having it becomes obvious.
The time you invest in understanding containerization will pay off. Being able to spin up an entire development environment with one command, deploy with confidence, and scale without manual intervention is worth the initial struggle. Just don't let the tooling distract you from building the actual product.
Related Articles
Kafka vs RabbitMQ: Event-Driven Microservices Reality Check
I've shipped event-driven architectures with both Kafka and RabbitMQ. Here's what actually matters when you're building microservices in production.
Monorepo Architecture: Turborepo & Nx for Large Apps
Explore monorepo architecture with Turborepo and Nx for building scalable JavaScript and TypeScript applications. Learn practical tips and trade-offs from real-world experience.
GoDaddy: A Developer's Perspective on Hosting & Domains
A seasoned developer's honest review of GoDaddy, covering domains, hosting, WordPress, and its place in the modern tech landscape. Learn the pros and cons and make informed decisions.
