Back to Blog
DevOps8 min read

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.

Jay Salot

Jay Salot

Senior Full Stack AI Engineer

July 13, 2026 · 8 min read

Share
AI and machine learning visualization

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.

#Docker#Kubernetes#DevOps#Node.js#Deployment
Share

Related Articles