Deployment: PM2, Proxies & Docker
Your app runs on your laptop. Deployment means running it on a server that is always on, reachable from the internet, restarts itself after a crash, uses HTTPS, and can be updated without long downtime. The tools change from company to company, but the ideas are the same everywhere.
In this lesson you will prepare an app for production, keep it running with the PM2 process manager, put the Nginx web server in front of it as a reverse proxy with HTTPS, and package it as a Docker container. The examples assume a Linux server such as Ubuntu 26.04 LTS, which is what most servers run.
Where can a Node.js app run?
| Option | You manage | Good for |
|---|---|---|
| VPS (virtual private server) | Operating system, Node.js, PM2, Nginx, updates | Learning, full control, low monthly cost |
| Platform as a service (PaaS) | Only your code and settings; the platform builds and runs it | Fast deployment, small teams |
| Containers (Docker) on a server or a cloud service | A Dockerfile; the platform runs containers | Teams, many services, identical environments |
Whichever you choose, the app itself needs the same preparation.
Prepare the app for production
- Read settings from the environment: port, database URL, secrets. Platforms tell your app which port to use through
PORT, so alwayslisten(process.env.PORT ?? 3000). - Set
NODE_ENV=production. Express then caches view templates and hides error stack traces, and many libraries switch to faster modes. - Install only what production needs with
npm ci --omit=dev. - Log to stdout in JSON (see Logging) and let the platform collect the logs.
- Add a health check route such as
GET /api/healththat returns200, so the platform or load balancer can check the app is alive. - Shut down gracefully: on
SIGTERM, stop accepting new requests, finish the current ones and close database connections.
// server.js
import { app } from "./src/app.js";
const PORT = Number(process.env.PORT ?? 3000);
const server = app.listen(PORT, () => console.log(`Listening on ${PORT}`));
function shutdown(signal) {
console.log(`${signal} received, closing server`);
server.close(() => process.exit(0)); // finish open requests first
setTimeout(() => process.exit(1), 10_000).unref(); // give up after 10 s
}
process.on("SIGTERM", shutdown); // sent by PM2, Docker and most platforms
process.on("SIGINT", shutdown); // Ctrl+CPM2: keep the app running
If you start the app with node server.js over SSH, it stops when you log out, and it stays down after a crash or a reboot. A process manager fixes this. PM2 is the most widely used one for Node.js: it restarts crashed apps, starts them when the server boots, collects logs, and can run one copy per CPU core.
npm install -g pm2
pm2 start server.js --name shop-api # start and give it a name
pm2 list # status of all apps
pm2 logs shop-api # follow the logs
pm2 restart shop-api # restart after updating code
pm2 stop shop-api # stop itFor real projects, describe the app in an ecosystem file. PM2 reads it with require(), so in a project with "type": "module" give it the .cjs extension:
// ecosystem.config.cjs
module.exports = {
apps: [
{
name: "shop-api",
script: "./server.js",
instances: 2, // or "max" for one per CPU core
exec_mode: "cluster",
env: {
NODE_ENV: "production",
PORT: 3100,
},
max_memory_restart: "300M",
},
],
};pm2 start ecosystem.config.cjs[PM2] Spawning PM2 daemon with pm2_home=/home/deploy/.pm2 [PM2] PM2 Successfully daemonized [PM2][WARN] Applications shop-api not running, starting... [PM2] App [shop-api] launched (2 instances)
PM2 then prints a table showing both instances online in cluster mode with their process IDs, memory and restart count (the ↺ column).
- Cluster mode starts several copies of your app that share one port, so a 4-core server can use all its cores. Your app must not keep important state in memory (sessions, counters), because each copy has its own memory; use Redis or a database.
max_memory_restartrestarts a copy that grows too large, a simple safety net against memory leaks.pm2 reload shop-apirestarts cluster copies one at a time, so users see no downtime during an update.pm2 startupprints a command that makes PM2 start at boot, andpm2 saveremembers the current app list. Run both once on the server.- Logs are written to files in
~/.pm2/logs. Install thepm2-logrotatemodule (pm2 install pm2-logrotate) so they do not fill the disk.
Secrets should not be written in the ecosystem file if it is committed to Git. Load them from a .env file on the server instead, for example with node_args: "--env-file=.env" in the app entry.
Nginx as a reverse proxy
Your Node.js app listens on a high port such as 3100. In front of it, a reverse proxy such as Nginx receives all traffic on ports 80 and 443 and forwards it to the app. This gives you:
- HTTPS handled in one place, with free certificates from Let's Encrypt.
- Fast delivery of static files, compression and request size limits.
- The ability to run several apps on one server under different domain names.
- No need to run Node.js as root to use port 80 or 443.
A typical site file, /etc/nginx/sites-available/shop:
server {
listen 80;
server_name shop.example.in;
location / {
proxy_pass http://127.0.0.1:3100;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# needed for WebSockets
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}Enable it and add HTTPS with Certbot, which requests a Let's Encrypt certificate, edits the Nginx file and renews the certificate automatically:
sudo ln -s /etc/nginx/sites-available/shop /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d shop.example.inBehind a proxy, every request seems to come from 127.0.0.1. Tell Express to trust the proxy's X-Forwarded-* headers, so req.ip, req.protocol and secure cookies work correctly:
app.set("trust proxy", 1); // trust one proxy in front of the appSet it to the number of proxies you really have. Trusting more than that lets clients fake their IP address, which would break rate limiting.
Docker: package the app with its environment
"It works on my machine" is a common problem: the server has a different Node.js version or missing system libraries. Docker packages your app, the exact Node.js version and everything else it needs into an image. A running image is a container, and it behaves the same on your laptop, a test server and production.
# Dockerfile
FROM node:26-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]# .dockerignore
node_modules
.env
.git
*.logdocker build -t shop-api .
docker run -d --name shop-api -p 3000:3000 --env-file .env --restart unless-stopped shop-api
docker logs -f shop-apiEach line of the Dockerfile matters:
FROM node:26-alpinestarts from the official Node.js 26 image based on Alpine Linux, which is small.node:26-slim(Debian based) is a common alternative when a package needs extra system libraries.- Copying
package*.jsonand runningnpm cibefore copying the rest of the code lets Docker reuse that layer when only your code changes, which makes rebuilds fast. .dockerignorekeeps your localnode_modulesand secrets out of the image.USER noderuns the app as the unprivileged user that the official image provides, not as root.CMD ["node", "server.js"]runs Node.js directly, so it receives theSIGTERMsignal fromdocker stopand your graceful shutdown code runs.--restart unless-stoppedrestarts the container after a crash or a reboot, so you do not need PM2 inside the container.
For an app plus its database, Docker Compose describes several containers in one file:
# compose.yaml
services:
api:
build: .
ports:
- "3000:3000"
env_file: .env
depends_on:
- db
restart: unless-stopped
db:
image: mysql:8.4
environment:
MYSQL_DATABASE: shop
MYSQL_ROOT_PASSWORD: change-me
volumes:
- dbdata:/var/lib/mysql
volumes:
dbdata:docker compose up -d starts both. Inside Compose, the app reaches the database at the host name db, so DB_HOST=db goes in .env. The named volume dbdata keeps the data when the container is replaced.
Common mistakes
- Hard-coding the port. Many platforms assign one through
PORT; always read it. - Running
npm install(with dev dependencies) on the server. Usenpm ci --omit=dev. - Copying
.envandnode_modulesinto a Docker image. Add them to.dockerignore. - Forgetting
trust proxybehind Nginx. Secure cookies and rate limiting then misbehave. - Keeping sessions in memory with cluster mode or several containers. Use a shared store.
- No log rotation. Logs grow until the disk is full.
Key points
- Prepare the app: settings from the environment,
NODE_ENV=production,npm ci --omit=dev, a health check and graceful shutdown onSIGTERM. - PM2 keeps apps running, restarts crashes, starts on boot, collects logs and runs cluster copies; use an
ecosystem.config.cjsfile. - Nginx as a reverse proxy handles HTTPS, static files and WebSocket upgrades; set
app.set("trust proxy", 1)in Express. - Docker packages the app with its Node.js version; base it on
node:26-alpineornode:26-slim, run asUSER nodeand keep secrets out of the image. - Docker Compose runs the app and its database together with volumes for data.
Exercise
Write an ecosystem.config.cjs for an app called school-portal whose entry file is src/server.js. It should run one instance per CPU core in cluster mode, set NODE_ENV to production and PORT to 4000, and restart any instance that uses more than 400 MB. Then write the PM2 commands to start it, make it start at boot and follow its logs.
Show solution
// ecosystem.config.cjs
module.exports = {
apps: [
{
name: "school-portal",
script: "./src/server.js",
instances: "max",
exec_mode: "cluster",
env: {
NODE_ENV: "production",
PORT: 4000,
},
max_memory_restart: "400M",
},
],
};pm2 start ecosystem.config.cjs
pm2 startup # run the command it prints, once
pm2 save
pm2 logs school-portalinstances: "max" starts one copy per CPU core. The file uses module.exports and the .cjs extension because PM2 loads it with require(), which works even when the project's package.json says "type": "module".
Quick Quiz
1. Why should a Node.js app read its port from process.env.PORT?
Reading PORT lets the same code run on any platform.
2. What does PM2's cluster mode do?
Each copy is a separate process, so shared state must live outside the app.
3. Why is app.set("trust proxy", 1) needed behind Nginx?
Without it, every request looks like it came from the proxy over plain HTTP.
4. Why does the Dockerfile copy package*.json and run npm ci before copying the rest of the code?
Docker caches each step; dependencies change less often than code.
Learn Node.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
