Writing
Infrastructure
Mingtindu Sherpa6 min read

Deploy a Node.js Application with PM2 and Nginx

Build and run a Node.js application with PM2, proxy it through Nginx, preserve client headers, and verify the deployment safely.

On this page

A production Node.js process must keep running after the SSH session ends, restart after a server reboot, and receive public HTTP traffic without binding directly to ports 80 and 443. PM2 can manage the process while Nginx handles the public connection and reverse proxy.

This setup assumes a Linux server where you have sudo, a supported Node.js release, Nginx, and permission to configure services. Shared hosting usually uses a provider-managed runner instead; see Deploy Node.js on DirectAdmin or cPanel.

Build the application first

Deploy a reviewed commit and install exactly the locked dependencies:

cd /srv/example-api
npm ci
npm run build

npm ci follows package-lock.json and removes incompatible existing dependency state. The build command is project-specific. Confirm the actual output and startup script in package.json; an Express TypeScript project might produce dist/server.js, while a framework may require npm run start.

Run the production command once on a non-public port before introducing PM2:

NODE_ENV=production PORT=3000 npm run start

Then request a health endpoint from another shell and stop the foreground process after verification:

curl --fail --show-error http://127.0.0.1:3000/health

Keep production secrets outside Git

Provide secrets through the server's protected environment or secret-management system. Do not commit a complete .env file, database URL, signing key, or API token. Restrict any server-side environment file to the deployment user and ensure logs do not print its values.

An ecosystem file can safely describe process behavior while reading values already present in the environment:

module.exports = {
  apps: [
    {
      name: "example-api",
      cwd: "/srv/example-api",
      script: "dist/server.js",
      instances: 1,
      exec_mode: "fork",
      env: {
        NODE_ENV: "production",
        PORT: "3000",
      },
      time: true,
      autorestart: true,
      max_memory_restart: "500M",
    },
  ],
};

cwd makes relative paths predictable. script must match the built entry point. The memory threshold is only an example—set it below the server's safe limit and investigate repeated restarts rather than treating them as normal. Keep credentials out of this committed file.

Start and persist the PM2 process

Run PM2 as the unprivileged deployment user that owns the application:

pm2 start ecosystem.config.js
pm2 status
pm2 logs example-api --lines 100

Generate the startup configuration without sudo first:

pm2 startup

PM2 prints a platform-specific command. Review it, then run that exact command with the required privilege. Save the current process list afterward:

pm2 save

The startup service and saved process list solve different problems: the service starts PM2 after boot, and the saved list tells PM2 which applications to restore. If the Node.js path changes after an upgrade, regenerate the PM2 startup script as the official guide recommends.

Configure Nginx as a reverse proxy

Create a site configuration appropriate for the distribution, for example /etc/nginx/sites-available/example-api on Ubuntu:

server {
    listen 80;
    listen [::]:80;
    server_name api.example.com;
 
    client_max_body_size 10m;
 
    location / {
        proxy_pass http://127.0.0.1:3000;
        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;
 
        proxy_connect_timeout 5s;
        proxy_read_timeout 60s;
        proxy_send_timeout 60s;
    }
}

proxy_pass targets the private listener, so the Node.js app should normally bind to 127.0.0.1 rather than the public interface. Host preserves the requested hostname. $proxy_add_x_forwarded_for appends the connecting address to the forwarding chain, and X-Forwarded-Proto tells a proxy-aware app whether the original request used HTTPS.

Only trust forwarded headers when requests reach the app through a controlled proxy. In Express, configure trust proxy for the known topology, not blindly for arbitrary internet traffic. This matters for secure cookies, client IPs, and rate limiting. The existing CORS troubleshooting guide explains the separate browser-origin layer.

client_max_body_size limits request bodies at Nginx. Match it to the application's own lower or equal limit. Timeouts depend on endpoint behavior; long exports, uploads, streaming, and WebSockets need deliberate settings rather than very large global values.

Enable the site according to the distribution, then validate before reloading:

sudo ln -s /etc/nginx/sites-available/example-api /etc/nginx/sites-enabled/example-api
sudo nginx -t
sudo systemctl reload nginx

Do not reload after a failed nginx -t. If the platform does not use sites-available, place the server block in its documented include directory.

HTTPS responsibility

Nginx or an upstream load balancer must terminate HTTPS with a valid certificate and redirect plain HTTP where appropriate. PM2 does not provide public TLS by itself. Use the certificate automation supported by the server or hosting provider, test renewal, and keep the origin protected if a CDN terminates TLS in front of it.

Restart versus reload

pm2 restart example-api
pm2 reload example-api

restart stops and starts the process and can cause brief downtime. PM2 documents reload as zero-downtime for networked applications in cluster mode; it may fall back to a restart. A single fork-mode instance does not gain zero-downtime behavior merely because reload was typed.

Handle termination signals, stop accepting new work, finish bounded in-flight requests, and close database connections. After deploying new code, this command starts the app if absent or reloads it if present:

pm2 startOrReload ecosystem.config.js
pm2 save

Inspect logs at every layer

pm2 logs example-api --lines 200
sudo journalctl -u nginx --since "15 minutes ago"
sudo tail -n 100 /var/log/nginx/error.log

Locations vary by distribution and configuration. Add log rotation and retention; PM2 and Nginx logs can otherwise fill the disk. Never log authorization headers, cookies, environment variables, or full database connection strings.

Common deployment failures

  • PM2 starts source code although only compiled output can run.
  • The app listens on a different port than proxy_pass targets.
  • A secret works in an interactive shell but is missing from the startup service environment.
  • Nginx forwards spoofable headers and the app trusts every proxy.
  • The body limit or timeout differs between CDN, Nginx, and application.
  • pm2 save was omitted after changing the process list.
  • The firewall exposes port 3000 even though only Nginx should reach it.

Verification checklist

  • A locked production install and build complete for the deployed commit.
  • The app responds on 127.0.0.1 before Nginx is involved.
  • pm2 status reports the intended entry point and user.
  • Startup behavior and the saved process list survive a controlled reboot test.
  • nginx -t succeeds before reload.
  • Public HTTP and HTTPS health checks return the expected status.
  • Client IP, scheme, upload size, timeouts, and logs behave as designed.
  • Only ports 80/443 and required administration ports are publicly reachable.

References

Documentation checked on 2026-08-12:

Related writing

Share