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
Arecord 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
- Read
docs/DEPLOY.mdand tick off each one-time step for your own server (you can do this on any small test server). - Replace
jobtracker.example.comwith your domain indeploy/nginx.conf. - Run the deploy steps by hand once, then with
bash deploy/deploy.sh. - 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/healthanswersok: 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=productionandTRUST_PROXY=1; keep.envout of Git with strict permissions. - Script the deploy and finish with a health check; back up the database and the uploads folder.