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 buildnpm 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 startThen request a health endpoint from another shell and stop the foreground process after verification:
curl --fail --show-error http://127.0.0.1:3000/healthKeep 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 100Generate the startup configuration without sudo first:
pm2 startupPM2 prints a platform-specific command. Review it, then run that exact command with the required privilege. Save the current process list afterward:
pm2 saveThe 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 nginxDo 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-apirestart 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 saveInspect 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.logLocations 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_passtargets. - 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 savewas 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.1before Nginx is involved. -
pm2 statusreports the intended entry point and user. - Startup behavior and the saved process list survive a controlled reboot test.
-
nginx -tsucceeds 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
- Deploy a Node.js Application on DirectAdmin or cPanel Shared HostingEvaluate shared-hosting Node.js support and deploy through cPanel Passenger or DirectAdmin Nginx Unit with realistic platform limits.
- Store and Serve User-Uploaded Images in Node.jsAccept, validate, store, serve, replace, and back up public images without exposing private documents or trusting upload metadata.
- Deploy a FastAPI OCR Service with Docker and TesseractPackage a FastAPI OCR endpoint with Tesseract, safe upload handling, health checks, limits, and production verification.