Skip to content
ExponaBLOG
NESTJS · DAY 079 / 100

PM2, Docker or Kubernetes for Node for NestJS developers

Pick the deployment model that matches your team.

Expona Admin6 min read

PM2, Docker or Kubernetes for Node?

After 79 days of building NestJS applications, you've finally got something production-ready. Now comes the real question: how do you actually run it? The answer depends entirely on your team size, infrastructure budget, and operational maturity. I've deployed Node services across all three models, and I can tell you there's no one-size-fits-all solution—only the right fit for where you are today.

PM2 on a VM: Simple, Cheap, Great for Small Teams

PM2 is the first approach I recommend for teams of 1-5 developers or early-stage startups. It's a process manager that keeps your Node application running on a single Linux box, restarts it on crashes, and handles log rotation. You get simplicity and low cost without orchestration complexity.

Here's what a typical PM2 setup looks like. You SSH into an Ubuntu VM (probably a t3.medium on AWS or equivalent), install Node, and then create an ecosystem.config.js file:

typescript
module.exports = {
  apps: [
    {
      name: 'api',
      script: './dist/main.js',
      instances: 4,
      exec_mode: 'cluster',
      env: {
        NODE_ENV: 'production',
        DATABASE_URL: process.env.DATABASE_URL,
        REDIS_URL: process.env.REDIS_URL,
      },
      error_file: './logs/err.log',
      out_file: './logs/out.log',
      log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
      max_memory_restart: '500M',
    },
  ],
};

Then you run pm2 start ecosystem.config.js and it's live. PM2 handles clustering across CPU cores, zero-downtime reloads, and automatic restarts if your process crashes. You can monitor it via the CLI or their paid dashboard if you want deeper observability.

The real win here is operational simplicity. Your CI/CD pipeline is just: build, upload tarball, extract, run PM2 restart. No Docker builds, no container registry, no orchestration learning curve. Costs are minimal—a single $20/month VM can run multiple small APIs. I've kept small production services alive on PM2 for years with minimal maintenance.

The trade-off is clear: you're betting on a single machine. If it goes down, you have downtime. If you outgrow one box, scaling becomes messy. And reproducing the production environment locally requires matching OS, libraries, and environment variables perfectly—which almost never happens.

Docker Compose: Reproducible Environments

Docker Compose bridges the gap between local development and production. It lets you define your entire stack—API, database, cache, message queue—in a single docker-compose.yml file that works identically on your laptop and in production.

Here's a realistic example for a NestJS application:

typescript
version: '3.9'

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - '3000:3000'
    environment:
      DATABASE_URL: postgresql://user:pass@postgres:5432/mydb
      REDIS_URL: redis://redis:6379
      NODE_ENV: production
    depends_on:
      - postgres
      - redis
    restart: unless-stopped
    healthcheck:
      test: ['CMD', 'curl', '-f', 'http://localhost:3000/health']
      interval: 30s
      timeout: 10s
      retries: 3

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: mydb
    volumes:
      - postgres_data:/var/lib/postgresql/data
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redis_data:/data

volumes:
  postgres_data:
  redis_data:

Your Dockerfile is equally straightforward:

typescript
FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY dist/ ./dist/

EXPOSE 3000

CMD ["node", "dist/main.js"]

Now every developer runs docker-compose up and gets the exact same environment. No "works on my machine" arguments. Your CI/CD builds and pushes images to a registry (ECR, Docker Hub, etc.), and your production server pulls and runs them with docker-compose pull && docker-compose up -d.

Docker Compose works beautifully for small-to-medium deployments: teams of 5-15 people, 1-3 services, single or dual server deployment. You get reproducibility, versioned images, and still reasonable operational overhead. I've run this pattern successfully with ride-hailing platforms serving millions of requests monthly.

The limitation arrives when you need true high availability, multi-region failover, or scaling services independently. Docker Compose is great at orchestrating services on a single host, but managing multiple hosts manually becomes error-prone.

Kubernetes: Many Services and Autoscaling

Kubernetes is the orchestration platform for teams running dozens of microservices, needing automatic failover, and wanting to optimize infrastructure costs through intelligent resource allocation. It abstracts away the underlying servers and lets you declare desired state—then it makes it happen.

A basic NestJS deployment on Kubernetes looks like this. First, your Deployment manifest:

typescript
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  labels:
    app: api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
      - name: api
        image: myregistry.azurecr.io/api:v1.2.3
        ports:
        - containerPort: 3000
        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: app-secrets
              key: database-url
        - name: NODE_ENV
          value: production
        livenessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 10
          periodSeconds: 30
        readinessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 5
          periodSeconds: 10
        resources:
          requests:
            memory: '256Mi'
            cpu: '250m'
          limits:
            memory: '512Mi'
            cpu: '500m'

Add a Service to expose it and an Ingress for external traffic, then Kubernetes handles pod scheduling, rolling updates, restarts, and networking automatically. Add a Horizontal Pod Autoscaler and your API scales from 3 to 10 replicas based on CPU usage during traffic spikes—all without manual intervention.

Kubernetes shines when you have 10+ microservices, teams of 20+ engineers, complex networking requirements, or multi-region deployments. It pays for itself through efficient resource utilization, automated failover, and the ability to update services without downtime.

I've deployed to Kubernetes for large e-commerce platforms, SaaS products, and fintech trading systems. Once your team understands the mental model—declarative state, controllers, operators—it becomes incredibly powerful. But that learning curve is real, and the operational overhead is significant.

Do Not Adopt Kubernetes for a Single API

This deserves its own section because I see it constantly: startups with one API service adopting Kubernetes because "we might scale later." You won't scale later by accident. Scaling is a deliberate choice driven by revenue and users. Premature Kubernetes adoption taxes your velocity with minimal benefit.

A single API on Kubernetes still requires you to understand pod lifecycle, resource requests, networking policies, persistent volumes, and secret management. Your deployment becomes opaque—a pod fails and you're SSH-ing into nodes debugging kubelet logs instead of shipping features. Your CI/CD pipeline needs a container registry, helm charts or kustomize, and cluster access credentials. Your on-call engineer needs a $300 Kubernetes course.

The cost-benefit is inverted. You spend 2-3 months learning Kubernetes to solve problems you don't have yet. Instead, start with PM2 or Docker Compose. When you genuinely need Kubernetes—when you have 5+ services and your team is fighting coordination problems—then migrate. The patterns you learn building for Docker Compose transfer directly to Kubernetes.

Common Mistakes

  • Choosing deployment model before defining the problem: Don't pick Kubernetes because it's trendy. Define: How many services? How many people managing infrastructure? What's your failover requirement? Answer those first.
  • Skipping health checks: All three approaches work better with proper health endpoints. A crashed process looks alive until it doesn't respond to traffic. Build /health from day one.
  • Coupling your code to deployment strategy: Write your NestJS app to run anywhere. Don't hardcode file paths, assume local sockets, or depend on container orchestration features.
  • Setting resource limits too tight: A process that hits its memory limit gets killed instantly. Be generous initially, then tune after measuring real usage.
  • Migrating without a rollback plan: Moving from PM2 to Docker Compose is easy only if you keep PM2 running on a parallel server during transition. Plan your switchover.

Key Takeaways

  • PM2 on a VM: best for small teams, single services, low budget. Zero container overhead, simple mental model.
  • Docker Compose: bridges development and production, reproducible environments, works for 1-3 services on 1-2 servers.
  • Kubernetes: choose this only when you have multiple services, high availability needs, and enough team maturity to maintain it.
  • Match deployment model to team size and problem complexity, not aspirational scale.
  • Every approach requires health checks, proper logging, and configuration management. Don't skip those regardless of choice.
  • Build portable code that doesn't depend on deployment specifics—you'll migrate eventually and regret it otherwise.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles