DeployEasy
ProductionIntermediate

Deploy Node.js to a VPS with PM2 and Nginx from A to Z

A practical Node.js deployment guide for Ubuntu using PM2 and Nginx, including builds, environment variables, HTTPS, logs, health checks, and rollback.

· 3 min read· 496 words
Table of contents

PM2 keeps a Node.js process alive and Nginx handles the public HTTP layer. Together they make a simple production setup for an API, server-rendered application, or Node.js backend.

This guide uses /var/www/myapp, port 3000, and example.com as examples.

1. Prepare the server

sudo apt update && sudo apt upgrade -y
sudo apt install -y nginx git curl
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Install the Node.js version used by your team and keep it aligned with package.json and CI.

2. Install and build the application

sudo mkdir -p /var/www/myapp
sudo chown -R "$USER":"$USER" /var/www/myapp
cd /var/www/myapp
git clone https://github.com/your-org/your-app.git .
npm ci
npm run build

Use npm ci when a lockfile exists so production dependencies are reproducible.

3. Keep runtime configuration outside Git

Create a protected .env.production file:

NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://app_user:change-me@127.0.0.1:5432/app
chmod 600 .env.production

Confirm which variables are required at build time and which are read only when the process starts.

4. Start Node.js with PM2

For an entry point such as dist/server.js:

npm install --global pm2
pm2 start dist/server.js --name myapp --time
pm2 save
pm2 startup systemd

Run the command printed by pm2 startup, then verify:

pm2 status
pm2 logs myapp --lines 100
curl http://127.0.0.1:3000/health

For an npm start script, use pm2 start npm --name myapp -- start and document the exact command.

5. Configure Nginx as a reverse proxy

Create /etc/nginx/sites-available/myapp:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    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;
    }
}

Enable and validate it before reloading:

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

6. Add HTTPS

After DNS points to the VPS and HTTP works, issue a Let’s Encrypt certificate:

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run

7. Make deployments repeatable

A safe release fetches a known revision, installs locked dependencies, builds outside the running process, runs migrations deliberately, reloads PM2, calls /health, and rolls back when the health check fails.

git fetch --tags origin
git checkout v1.2.3
npm ci
npm run build
pm2 reload myapp --update-env
curl --fail https://example.com/health

Do not make an irreversible database migration part of a release until its rollback impact is understood.

Troubleshooting checklist

  • 502 Bad Gateway: inspect pm2 status, pm2 logs, and curl http://127.0.0.1:3000.
  • EADDRINUSE: another process owns port 3000; inspect it with ss -ltnp.
  • The wrong site is served: verify server_name, DNS, and enabled Nginx sites.
  • The app dies after reboot: run pm2 save and complete pm2 startup.
  • Environment values are missing: check the working directory and the framework’s env loading rules.

Keep the operational commands in the project README so another person can deploy, diagnose, and recover the service without guesswork.

Continue reading