Next.js on a VPS: PM2 or Docker for Production?

Published on

PM2 or Docker for Next.js on a VPS? How builds, memory, deploys, rollbacks, logs and several apps differ, with the commands for both and when each one fits.

Screenshot of a VPS control panel listing one Debian virtual server with its memory, cores, disk and status

Your Next.js app is ready for a VPS, and the guides disagree: some run next start under PM2, others ship a Dockerfile. Both work, but the choice decides where the build runs, what a deploy and a rollback look like, where the logs end up and how a second app fits on the same server. This comparison sticks to what the official docs and source code say, for Ubuntu 24.04 and Debian 12. We have not benchmarked the two, so you won't find speed numbers here.

Quick answer: Both run every Next.js feature: the Next.js deployment docs rate a Node.js server and a Docker container the same, "All". Choose PM2 for one or a few apps that you build on the VPS itself: it has the fewest moving parts, and pm2 reload in cluster mode swaps versions without a gap. Choose Docker when a CI pipeline should build the app and the VPS only run the image, when apps need different Node.js versions or system libraries, or when the app runs next to a database in Compose. Docker has its own costs: docker compose up -d stops the old container before the new one starts, published ports bypass UFW, so you bind them to 127.0.0.1, and the default log driver never rotates. Either way nginx stays in front, and Docker doesn't make next build any lighter.

What Stays the Same Either Way

  • Features. The deployment docs list "All" for both. The official Docker example runs the standalone server.js, which you can also run under PM2.
  • nginx in front. The self-hosting guide recommends a reverse proxy such as nginx rather than exposing the Node.js server directly. The headers and streaming setting from our Next.js deployment guide with PM2 and nginx apply unchanged; with Docker, proxy_pass points at the port you publish.
  • Environment variables. NEXT_PUBLIC_ values are inlined into the JavaScript during next build, so they belong to the build. Server-only values are read at run time during dynamic rendering, which the guide says lets "a singular Docker image" move through several environments.
  • Shutdown. On SIGINT or SIGTERM, Next.js stops taking new connections and finishes open requests, and its docs recommend allowing 10 to 30 seconds for that. PM2 sends SIGINT and kills after 1,600 ms by default; Docker sends SIGTERM and kills after 10 seconds. Raise both, with kill_timeout in PM2 and stop_grace_period in Compose.

The Docker Setup in Brief

Our deployment guide covers the PM2 side: a release folder per deploy, a current symlink and next start under PM2. For Docker, the Next.js docs point to the with-docker example. Its Dockerfile has three stages: dependencies installs packages from your lockfile (npm ci for npm), builder runs the build, and runner copies only public, .next/standalone and .next/static into an image that starts node server.js as the unprivileged node user. Copy its Dockerfile and .dockerignore into your project and set output: "standalone" in next.config.ts. The Dockerfile pins node:24.13.0-slim and tells you to raise the NODE_VERSION argument to the latest LTS; on 30 October 2026 Docker Hub's lts-slim tag is Node.js 26.13.0.

Install Docker Engine from Docker's apt repository as its Ubuntu or Debian page shows; Docker doesn't recommend its convenience script for production. Then put a compose.yaml in the project folder on the server:

services:
  web:
    image: myapp:live
    restart: unless-stopped
    ports:
      - "127.0.0.1:3000:3000"
    env_file: .env.production.local
    stop_grace_period: 30s
  • 127.0.0.1 means only the server itself, where nginx runs, can reach the port. Without a host IP, Docker binds to all interfaces, which the Compose reference warns bypasses host firewall rules; our Docker and UFW guide explains why. Inside the container the example sets HOSTNAME="0.0.0.0", which is correct there.
  • unless-stopped restarts the container when it exits and when Docker starts at boot, but not after you stopped it yourself.
  • stop_grace_period gives Next.js 30 seconds between SIGTERM and SIGKILL.
  • env_file passes server secrets at run time. The example's .dockerignore keeps .env and .env*.local files out of the image on purpose.
  • myapp:live rather than latest: Compose always tries to pull a latest tag, even for a local image.

Builds and Memory

Docker doesn't make the build lighter: the builder stage runs the same npm run build. Built on the VPS, the build shares RAM with the version that is serving, whether in a release folder or in Docker's builder. If it runs out of memory, the fixes in our deployment guide (swap, no source maps, building elsewhere) apply to both.

The bigger difference is where the build can run. A CI job builds the image and pushes it to a registry, and the VPS only pulls it and runs docker compose up -d, so the server never needs build memory or your source code. If you build on an ARM machine, such as an Apple silicon Mac, for an x86-64 VPS, add --platform linux/amd64 to docker buildx build; Docker warns that emulated builds "can be much slower than native builds". PM2 can do the same with the standalone folder, built on Linux x64 and copied over.

At run time, three things differ:

  • Memory caps. A container has no memory limit by default. With --memory or mem_limit, Node.js sizes its default heap from that limit: it takes the lower of the machine's RAM and the cgroup limit. Leave headroom above normal use. PM2's counterpart is max_memory_restart, a threshold that PM2 checks every 30 seconds before it reloads or restarts the app. Our RAM sizing guide shows how to read docker stats and the OOM log.
  • Extra services. Docker runs two services of its own, docker and containerd, next to your app.
  • Disk. Every image you keep for a rollback takes space, and so does the build cache. The example's README puts the slim base image at about 226 MB. sudo docker system df shows what Docker uses, sudo docker image rm myapp:<tag> removes an old version and sudo docker builder prune clears the build cache. With PM2, old release folders, each with its own node_modules, pile up the same way.

If the build still runs short, more RAM helps in both setups: on Space-Node you can move to a bigger VPS plan from the client area.

Deploys and Rollbacks

With PM2, a deploy builds a new release folder, switches the symlink and runs pm2 reload. In cluster mode PM2 starts the new worker and stops the old one once the new one listens, or when listen_timeout (3,000 ms by default) runs out; in fork mode a reload falls back to a restart. A rollback points the symlink back and reloads.

With Docker, tag every build so older images stay on disk. In the project folder:

git pull
TAG=$(git rev-parse --short HEAD)
sudo docker build -t myapp:$TAG .
sudo docker tag myapp:$TAG myapp:live
sudo docker compose up -d

Compose labels each container with the digest of its image to detect outdated containers, so once myapp:live points at a new image, up -d replaces the container. To roll back, list the versions and move the tag back; nothing is rebuilt:

sudo docker image ls myapp
sudo docker tag myapp:<previous> myapp:live
sudo docker compose up -d

The catch is the moment in between. Compose applies a change by "stopping and recreating the containers", and while nothing listens on port 3000, nginx answers with a 502 (see our 502 guide). If those seconds matter, run two copies, for example on ports 3001 and 3002. Start the new version in the idle one, check it with curl -I http://127.0.0.1:3002/, point proxy_pass at it and run sudo nginx -t && sudo nginx -s reload. On a reload, nginx's old worker processes finish the requests they are serving before they exit. Then stop the old copy. PM2's cluster reload does all this for you.

Logs

PM2 writes each app's output to files in $HOME/.pm2/logs, and pm2 logs myapp --lines 50 shows the latest lines. The pm2-logrotate module rotates them; by default it rotates a file above 10 MB and keeps 30 old files:

pm2 install pm2-logrotate

Docker collects what the container writes to stdout and stderr: sudo docker compose logs -f --tail 50 web. Its default json-file driver performs no rotation, and Docker's docs warn that this can exhaust the disk. They recommend the local driver, which keeps 100 MB per container: five files of 20 MB, with rotated files compressed. Set it right after installing Docker, because only containers created afterwards use it. If /etc/docker/daemon.json already exists, add the key to it instead of overwriting it:

echo '{ "log-driver": "local" }' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
sudo docker info --format '{{.LoggingDriver}}'

Several Apps on One VPS

PM2 runs several apps from one ecosystem file: one entry per app with its own name, cwd and port, plus an nginx server block per domain. By default they share one Node.js. In cluster mode, PM2 starts every app through the Node.js cluster module of its own daemon, and Node.js forks those workers with the daemon's own binary. To give one app another version, run it in fork mode with an absolute interpreter path, which costs it the gapless reload.

Docker gives each app its own image, so each picks its own Node.js version and system packages. Give each a service with its own host port, such as 127.0.0.1:3001:3000. A database or Redis can sit in the same compose.yaml; our Docker Compose production guide and volume backup guide cover that part.

Several copies of the same app are a separate issue in both setups: each Next.js instance keeps its own cache, so revalidateTag() on one doesn't reach the others without a shared cache handler.

Which One Fits

Pick PM2 when:

  • you run one to a few Next.js apps on one VPS and build on the server;
  • you want deploys without a gap from a single command;
  • you want to see processes and files directly, with no container layer in between.

Pick Docker when:

  • a CI pipeline should build and test the image and the VPS only run it (see our GitHub Actions guide);
  • apps need different Node.js versions or system libraries;
  • the app ships with a database, Redis or workers in one Compose file;
  • you want the same image in staging and production, or plan to move to another host later.

On a Space-Node VPS you get KVM with full root access, and Ubuntu 24.04 LTS is the recommended choice in the order form, so both setups work as described here.

FAQ

Can I run PM2 inside the Docker container? Yes: PM2 ships pm2-runtime for containers, used as CMD ["pm2-runtime", "app.js"]. For one Next.js server it adds little, because the restart policy already brings a crashed container back, and Docker recommends restart policies over process managers for starting containers. Keep the example's exec form, CMD ["node", "server.js"], either way: Node.js is then the container's main process, the one docker stop sends SIGTERM to.

Slim or Alpine as the base image? The example uses slim, which is built on glibc, for compatibility with npm packages that ship native binaries. Its README puts Alpine at about 110 MB against about 226 MB for slim, but Alpine uses musl libc, so test every native dependency before you switch.

Why is a value from my .env file undefined in the container? The example's .dockerignore excludes .env, so local credentials never enter the image. Pass server values at run time with env_file or docker run -e. NEXT_PUBLIC_ values must exist during next build, so pass them as build arguments. Never pass secrets that way: the README warns that build arguments can be recovered from the image history.

Can I move from PM2 to Docker without downtime? Yes. Publish the container on a free port such as 127.0.0.1:3001:3000, check it with curl -I http://127.0.0.1:3001/, point nginx's proxy_pass at port 3001 and reload nginx. Then run pm2 delete myapp and pm2 save, so the old process doesn't return after a reboot.

Is Next.js slower in Docker? We have not measured it, so we give no numbers. Docker's docs note that containers share the host kernel, so there is no second operating system in between; what Docker adds is NAT for published ports and its own services. If it matters to you, run the same load test against both setups on your own VPS.

Sources

All opened on 30 October 2026: