Deploy a MERN App to a VPS with pm2, nginx and HTTPS (Let's Encrypt)

MERN Stack Full Course: MongoDB, Express, React and Node.js · Deploy and Launch · 8 min read · Updated

The production setup

  1. A visitor opens your domain. DNS points the name to your server.
  2. nginx receives the request on port 443 and handles the HTTPS certificate from Let's Encrypt.
  3. nginx proxies the request to your Node app and passes the visitor address in headers.
  4. pm2 keeps the app running, restarts it after a crash and starts it again after a reboot.
  5. The app reads and writes data in Atlas, and the deploy script ends by checking /api/health.

Everything so far runs on your own computer. To let other people use it, the app needs a computer that is always on and reachable. This lesson uses a VPS (a small rented Linux server, offered by many hosting companies) with the classic setup that many production sites still use: pm2 keeps Node.js running, nginx receives visitors and talks to it, and Let's Encrypt provides a free HTTPS certificate. The files in this lesson's zip are templates: replace the example domain and paths with yours.

Other options exist, such as platform services that deploy from Git for you. They are convenient, and prices and features change often, so check their sites. Knowing the manual way means you understand what those services do for you.

The pieces

  • A domain name pointing to the server's IP address (an A record in your DNS settings).
  • Node.js 20+, nginx, git and certbot installed on the server, and pm2 (npm install -g pm2).
  • MongoDB Atlas for the database, with the server's IP address allowed in Network Access, and a database user limited to this one database.
  • A non-root user for running the app, and a firewall that opens only SSH, HTTP and HTTPS.

The environment file

# Copy to server/.env on the server and fill it in. This file is a list of names only: never put real secrets in Git.
NODE_ENV=production
PORT=5000
TRUST_PROXY=1
# Your Atlas connection string (create a database user with read/write on this one database only)
MONGODB_URI=mongodb+srv://USER:PASSWORD@cluster0.xxxxx.mongodb.net/job_tracker
# 48 random bytes:  node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_SECRET=
JWT_EXPIRES_IN=7d
# The public address of the site (only needed if the React app is served from a different origin)
CLIENT_ORIGIN=https://jobtracker.example.com

On the server, copy it to server/.env and fill it in. NODE_ENV=production turns on the production behaviour from lesson 37 (JSON logs, indexes off). TRUST_PROXY=1 is essential behind nginx (lesson 23). Create the secret with the command in the comment, and run chmod 600 server/.env so only your user can read it. The real file never goes into Git.

pm2: keep the app running

// pm2 keeps the server running, restarts it if it crashes, and starts it again after a reboot.
//   pm2 start deploy/ecosystem.config.cjs --env production
//   pm2 save && pm2 startup
module.exports = {
  apps: [
    {
      name: 'job-tracker',
      cwd: './server',
      script: 'index.js',
      instances: 1,            // one is plenty for a small app. Several need the "cluster" mode and no in-memory state.
      max_memory_restart: '300M',
      kill_timeout: 12000,     // a little longer than our 10 second graceful shutdown
      env: { NODE_ENV: 'development' },
      env_production: { NODE_ENV: 'production', TRUST_PROXY: '1' },
    },
  ],
};

If the Node.js process crashes, nothing restarts it by default. pm2 does: it starts the app, restarts it after a crash or when it uses too much memory, keeps the logs (pm2 logs), and with pm2 startup plus pm2 save starts it again after a server reboot. kill_timeout gives the graceful shutdown from lesson 37 time to finish. The file ends in .cjs because pm2 reads it as a classic module, even though the app itself uses ES modules.

nginx: the front door

# nginx in front of Node: handles HTTPS, compression of static files and large uploads.
# Save as /etc/nginx/sites-available/jobtracker.example.com.conf, link it into sites-enabled, then:
#   sudo nginx -t && sudo systemctl reload nginx
#   sudo certbot --nginx -d jobtracker.example.com        (adds the HTTPS part and renewals)
server {
    listen 80;
    server_name jobtracker.example.com;

    client_max_body_size 3m;   # our uploads are 2 MB at most; nginx's default of 1 MB would answer 413 first

    location / {
        proxy_pass http://127.0.0.1:5000;
        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;   # Express reads this because TRUST_PROXY=1
    }
}

nginx listens on ports 80 and 443 and proxies requests to your app on port 5000. The X-Forwarded-* headers tell Express the real visitor IP address and whether the connection was HTTPS. client_max_body_size 3m matters for our resume uploads: nginx's default is 1 MB and it would answer 413 before your app saw the request. After changing nginx, always run sudo nginx -t to check the file, then sudo systemctl reload nginx.

For HTTPS, sudo certbot --nginx -d your-domain gets a free certificate from Let's Encrypt, edits the nginx file to use it, adds a redirect from HTTP to HTTPS, and sets up automatic renewal. Test that renewal works with sudo certbot renew --dry-run.

A repeatable deploy

#!/usr/bin/env bash
# Run on the server from the project folder:  bash deploy/deploy.sh
set -euo pipefail

git pull --ff-only
npm install --prefix server --omit=dev
npm install --prefix client            # the build needs the dev tools (Vite)
npm run build --prefix client
(cd server && node scripts/sync-indexes.js)
pm2 startOrReload deploy/ecosystem.config.cjs --env production
pm2 save
curl -fsS http://127.0.0.1:5000/api/health && echo "  <- healthy"

The script runs, in order: pull the latest code (--ff-only refuses to make an unexpected merge), install server packages without development tools, install client packages and build the React app, sync the database indexes, reload pm2, and finally call the health check. set -euo pipefail stops at the first error, so a failed build never reaches “reload”. pm2 startOrReload replaces the process without a long gap.

# Deploying the Job Tracker to a VPS

You need: a small Linux server (Ubuntu), a domain name pointing at its IP address, and a MongoDB Atlas cluster.

## One time

1. Create a non-root user, enable the firewall (`ufw allow OpenSSH`, `ufw allow 'Nginx Full'`, `ufw enable`) and update the system.
2. Install Node.js 20+, nginx, git, certbot and pm2 (`npm install -g pm2`).
3. In Atlas: Network Access must allow your server's IP address. Create a database user that can read and write only the `job_tracker` database.
4. `git clone` your repository to `/var/www/job-tracker`.
5. `cp server/.env.production.example server/.env` and fill in every value. Then `chmod 600 server/.env`.
6. Copy `deploy/nginx.conf` into `/etc/nginx/sites-available/`, change the domain, link it into `sites-enabled`, run `sudo nginx -t` and reload nginx.
7. `sudo certbot --nginx -d your-domain` for HTTPS.
8. `pm2 startup` and run the command it prints, so the app starts after a reboot.

## Every release

`bash deploy/deploy.sh`

It pulls the code, installs packages, builds the React app, syncs MongoDB indexes, reloads pm2 and checks `/api/health`.

## If something is wrong

| Symptom | Look at |
|---|---|
| 502 Bad Gateway | `pm2 logs job-tracker`: the app is not running or the port is not 5000 |
| App starts then exits | the first log line: usually a missing variable in `.env` or Atlas refusing your IP |
| Login works but you are signed out at once | `TRUST_PROXY=1` missing or `JWT_SECRET` changed |
| Upload answers 413 | `client_max_body_size` in nginx |
| Page refresh shows JSON "No route" | the React build is missing: run `npm run build --prefix client` |

## Backups

Turn on Atlas backups (or run `mongodump` on a schedule) and copy `server/uploads/` somewhere safe. Uploaded files are not in MongoDB.

The file above has the one-time steps and a troubleshooting table. The symptoms in it are the ones people actually meet: a 502 (the app is not running), a crash at start (usually a missing variable or Atlas refusing your IP), an upload 413 (nginx limit), and being signed out immediately (a missing TRUST_PROXY or a changed secret).

Your task

  1. Read docs/DEPLOY.md and tick off each one-time step for your own server (you can do this on any small test server).
  2. Replace jobtracker.example.com with your domain in deploy/nginx.conf.
  3. Run the deploy steps by hand once, then with bash deploy/deploy.sh.
  4. After deploying, stop the app with pm2 stop job-tracker, visit the site (what do you see?), and start it again.

How to check it worked

  • https://your-domain/api/health answers ok: true.
  • The site works over HTTPS, and HTTP redirects to it.
  • After sudo reboot, the app comes back by itself.

Summary

  • pm2 keeps Node.js running; nginx handles HTTPS and large uploads; Atlas holds the data.
  • Set NODE_ENV=production and TRUST_PROXY=1; keep .env out of Git with strict permissions.
  • Script the deploy and finish with a health check; back up the database and the uploads folder.

Download the solution (mern-lesson-39.zip)

Frequently asked questions

Do I need nginx and pm2, or can I use a hosting platform?

Hosting platforms that deploy from Git do the same jobs for you and are quicker to start with. Doing it by hand on a VPS teaches what those platforms automate and gives you full control. Compare current prices and limits on their sites.

Why do I get 502 Bad Gateway?

nginx could not reach your app. Check that it is running (pm2 status), that it listens on the port in the nginx file, and read pm2 logs for the first error, often a missing environment variable.

How do I renew the HTTPS certificate?

Certbot sets up automatic renewal when you install the certificate. Check it with certbot renew --dry-run, and make sure the nginx server name still matches your domain.