Web Development

Docker vs PM2: Which One Does Your Node App Actually Need?

They both keep your Node app alive, but they solve different problems. Here's how to pick one without overthinking it.

·
8 min read
·
AI-assisted

AI tooling disclosure

Generative AI materially contributed to this article.

Human review confirmed by the author.

Docker vs PM2: Which One Does Your Node App Actually Need?

Every few months someone asks the same question: should I use Docker or PM2 to run my Node app? It sounds like a fair comparison, but it's a bit like asking whether you need a toolbox or a screwdriver. They overlap in a few places, and that overlap is exactly why people get confused.

The way I make sense of it is to split the job into three layers:

  1. Supervision: keep the process alive, restart it when it dies, use every core.

  2. Packaging: make the environment reproducible and keep the app isolated from everything else on the machine.

  3. Orchestration: run copies across several machines, roll out new versions safely, replace machines that die.

PM2 lives in layer one. Docker lives in layer two and borrows a little of layer one through its restart policy. Layer three is where Kubernetes, Swarm and managed container platforms live. Most of the confusion comes from tools reaching into each other's layer.

What PM2 does

PM2 is a process manager for Node.js. You install it with npm, hand it your app, and it keeps that app running.

npm install -g pm2
pm2 start server.js --name api -i max
pm2 startup   # prints a command; run it once (with sudo) to enable start on boot
pm2 save

Those commands get you a lot. Your app restarts if it crashes, it runs one worker per CPU core, and it comes back after a server reboot. You also get logs and a live dashboard with pm2 logs and pm2 monit. When you deploy new code, pm2 reload api restarts workers one at a time, so users don't see downtime as long as your app shuts down cleanly. That only works in cluster mode, which is what -i max turns on.

In practice you'll want this in a config file that lives in your repo, so the setup is versioned along with your code:

// ecosystem.config.js
module.exports = {
  apps: [{
    name: 'api',
    script: './server.js',
    instances: 'max',
    exec_mode: 'cluster',
    max_memory_restart: '400M',
    env_production: {
      NODE_ENV: 'production',
      PORT: 3000,
    },
  }],
};

Start it with pm2 start ecosystem.config.js --env production. If your package.json sets "type": "module", name the file ecosystem.config.cjs instead.

For a single server and a single app, that's a very pleasant experience. No new concepts, no extra layer. It's just Node, supervised.

The catch is that PM2 only manages processes. It has no opinion about the machine they run on. Your server needs the right Node version, the right system libraries, the right environment variables. If staging has Node 22 and production has Node 24, PM2 will happily run your app on both and let you find out the hard way.

What Docker does

Docker solves a different problem: it packages your app together with everything it needs to run. The Node version, the OS libraries, your dependencies, your build output, all baked into an image. That image runs the same on your laptop, in CI, and on a production server.

FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
docker run -d --restart unless-stopped -p 3000:3000 myapp

Two notes on that Dockerfile. Add a .dockerignore that lists node_modules and .git, otherwise COPY . . copies your local node_modules into the image on top of the clean install. And it assumes a plain JavaScript app; with TypeScript you'd add a build stage.

You also get isolation (your app can't trample other things on the box), resource limits, health checks, and a clean way to run a database or Redis next to your app with Docker Compose.

The price is a learning curve. Images, layers, volumes, networks, registries: there's a whole vocabulary to pick up, and debugging inside a container feels different from debugging on a plain server. Builds also add a step to your workflow.

Where they overlap

Both can restart a crashed app, start it on boot, collect logs, and react to runaway memory use. That's the core of the overlap, and it's why people treat them as substitutes. Here's the rest side by side:

PM2

Docker

Main job

Supervise Node processes

Package and isolate an app and its environment

Environment consistency

No, depends on the host

Yes, the image is the environment

Multi-core usage

Built in (cluster mode)

Run several containers, or use cluster in the app

Zero-downtime deploys

pm2 reload

Needs a reverse proxy or orchestrator

Non-Node apps

Possible, but not its focus

Yes, anything

Learning curve

Gentle

Steeper

Scaling past one server

Not its job

Natural fit with Swarm, Kubernetes, managed container platforms

The cluster mode question

This is where PM2 feels a bit magical. Node runs your code on a single thread, so on an 8-core machine you're using one core unless you do something about it. pm2 start -i max forks a worker per core and load-balances between them. Done.

Docker has no equivalent built in. The usual approach is to run several containers behind a reverse proxy like Nginx, or let an orchestrator manage the replicas. In Kubernetes the convention is one process per container, and you scale by adding more of them. It's more moving parts, but it keeps working when you outgrow a single machine, which PM2 can't do.

Here's the Docker version of the same idea with Compose: two replicas of the app behind Nginx.

# docker-compose.yml
services:
  api:
    build: .
    restart: unless-stopped
    environment:
      NODE_ENV: production
    deploy:
      replicas: 2
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/health"]
      interval: 30s
      timeout: 3s
      retries: 3

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - api

The api service publishes no ports, so the replicas don't fight over one. Nginx reaches them by service name, using a small nginx.conf:

server {
  listen 80;
  location / {
    proxy_pass http://api:3000;
  }
}

The health check assumes your app has a /health route. One gotcha: Nginx looks up the name api when it starts, so reload it after you recreate the containers.

Deployments and zero downtime

With PM2, a deploy is: pull the code, install, pm2 reload. With Docker, it's: build an image, push it, pull it on the server, start the new container, switch traffic, stop the old one. By hand on one server, that means a few seconds of downtime. Doing it properly takes a rolling update from an orchestrator, or a proxy that can shift traffic between old and new, which is more work upfront.

When things go wrong

The feature lists look similar until something breaks. Here's what each tool does in the situations that actually page people at night:

Situation

PM2

Docker

App crashes

Restarts it. After too many quick crashes in a row, it gives up and marks the app errored.

Restarts it per the restart policy, waiting longer after each quick failure.

App hangs but doesn't exit

Doesn't notice.

A health check marks the container unhealthy, but plain Docker and Compose won't restart it. An orchestrator will.

Memory leak

max_memory_restart restarts the process once it passes the limit.

A memory limit, if you set one, kills the container when it's exceeded, and the restart policy brings it back.

Server reboots

Comes back if you ran pm2 startup and pm2 save.

Comes back through the restart policy (unless-stopped or always), as long as the Docker daemon starts on boot.

Bad deploy

Redeploy the old code and reload.

Start the previous image tag.

The machine dies

Your app is gone.

Your app is gone, unless an orchestrator moves it to another machine.

The hung-app row surprises people: a health check only reports status, so something else has to act on it.

Shutdown is your job

Both tools stop your app with a signal, and your code has to listen for it. docker stop sends SIGTERM and waits 10 seconds by default before killing the container. PM2 sends SIGINT and waits only 1.6 seconds by default, which you can change with kill_timeout. An app that ignores the signal drops whatever requests are in flight.

const server = app.listen(3000);

function shutdown() {
  // stop accepting new connections and let in-flight requests finish
  server.close(() => process.exit(0));
  // give up if that takes too long
  setTimeout(() => process.exit(1), 8000).unref();
}

process.on('SIGTERM', shutdown); // Docker
process.on('SIGINT', shutdown);  // PM2, and Ctrl+C

If you use PM2, set kill_timeout above that 8 seconds, or PM2 will kill the app first. In Docker, you may also want an init process (docker run --init, or init: true in Compose) so signals are forwarded properly and stray child processes get cleaned up.

Logs and monitoring

PM2 writes each app's output to files under ~/.pm2/logs, split into stdout and error logs. pm2 logs tails them, pm2 monit shows CPU and memory per process, and pm2 install pm2-logrotate keeps the files from eating your disk.

Docker flips the convention: your app writes to stdout and stderr, and Docker captures it. docker logs -f follows it, and a logging driver or plugin can ship it somewhere central like CloudWatch or Loki, so your logs stop living on one server's disk. One catch: the default log driver keeps growing its files unless you set max-size and max-file.

I use Dozzle for this. It's a small web UI that tails every container's logs live, and it only works because everything writes to stdout.

Using them together

You can run PM2 inside a container with pm2-runtime, which exists for exactly that:

CMD ["pm2-runtime", "start", "ecosystem.config.js", "--env", "production"]

It's a reasonable move if you want cluster mode on a single host and you like PM2's config file (you'll need to install pm2 in the image, since the base Node image doesn't ship with it). But it's often redundant. Docker's restart policy already handles crashes, and an orchestrator already handles scaling. Put PM2 on top and you have two things deciding when to restart your app, which gets confusing fast.

If you do combine them, set instances to a fixed number rather than 'max'. Inside a container, 'max' can count the host's cores instead of the CPU limit you gave the container.

So which one should you pick?

Here's how I'd decide.

Pick PM2 if you have one or two apps on a single VPS, you control the server, your team is small, and you want to ship today without learning a new toolchain. It's simple and it works, and there's no shame in that.

Pick Docker if you run more than one service, need a database or cache alongside your app, deploy through CI/CD, or work on a team where “it works on my machine” has come up. It's also the safer bet if you expect to move to a cloud platform or Kubernetes at some point, since plenty of hosting platforms expect a container anyway.

Use both if you want PM2's cluster mode and config inside a container on a single host. Just be sure you actually need it.

I run my own Node apps in Docker, and it's what I'd reach for by default on a new project. My setup is more than the app: a Postgres database, Dozzle for reading logs, Cloudflare Tunnels, and my background workers all run in Docker too. A worker is just another container, often the same image started with a different command. PM2 only manages the Node processes, so all of that would have lived outside it. The container has become the standard unit of deployment across the ecosystem, and the habit transfers everywhere. That said, if PM2 is already running your app happily and nothing is hurting, don't rip it out for the sake of it.

So it comes down to what you're missing. If you just need a Node process kept alive, PM2 has you covered. If you need your app to behave the same on every machine it touches, that's what Docker is for.

Comments(0)

Join the discussion by logging into your account.

No comments yet. Be the first to comment!

Sanju Singh

Passionate developer sharing knowledge about modern web technologies and best practices.

Subscribe to Sanju Singh's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.