Skip to main content

Deploying Express to Production

What Happens When You Deploy

Locally, you run node index.js and your app lives on localhost:3000. Deployment means:

  1. Getting your code onto a server accessible on the internet
  2. Running Node.js as a background process (restarts on crash, survives server reboots)
  3. Putting Nginx in front of it (handles SSL, serves static files, load balancing)
  4. Pointing your domain at the server
Internet → Domain (DNS) → Server IP → Nginx (:443) → Express (:3000)

Option A: VPS Deployment (Ubuntu + Nginx + PM2)

A VPS (Virtual Private Server) gives you a full Linux machine. DigitalOcean, Linode, and AWS EC2 all offer these. This is the most educational approach — you understand exactly what's running.

Step 1: Server Setup

SSH into your fresh Ubuntu server:

ssh root@your-server-ip

Update and install Node.js:

apt update && apt upgrade -y

# Install Node.js 20 (via NodeSource)
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs

node --version # v20.x.x
npm --version

Install PM2 globally:

npm install -g pm2

Step 2: Get Your Code on the Server

Option 1 — Clone from GitHub:

cd /var/www
git clone https://github.com/your-username/your-repo.git app
cd app
npm install --production

Option 2 — rsync from local machine:

# Run from your local machine
rsync -avz --exclude node_modules ./my-app/ root@your-server-ip:/var/www/app/
ssh root@your-server-ip "cd /var/www/app && npm install --production"

Step 3: Environment Variables

Create a .env file on the server (never commit this):

nano /var/www/app/.env
NODE_ENV=production
PORT=3000
DATABASE_URL=mongodb+srv://...
JWT_SECRET=your-long-random-secret

Step 4: Run with PM2

PM2 keeps your app running, restarts on crashes, and starts on server reboot:

cd /var/www/app
pm2 start index.js --name "my-api"

# Save process list for auto-start on reboot
pm2 save
pm2 startup # follow the command it outputs

# Useful PM2 commands
pm2 status # see all running processes
pm2 logs my-api # tail logs
pm2 restart my-api # restart the app
pm2 stop my-api # stop the app
pm2 reload my-api # zero-downtime reload

Step 5: Install and Configure Nginx

apt install -y nginx
systemctl start nginx
systemctl enable nginx # start on boot

Create a site config:

nano /etc/nginx/sites-available/my-api
/etc/nginx/sites-available/my-api
server {
listen 80;
server_name api.yourdomain.com;

location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
}
}

Enable the site and reload Nginx:

ln -s /etc/nginx/sites-available/my-api /etc/nginx/sites-enabled/
nginx -t # test config — fix any errors before reloading
systemctl reload nginx

Step 6: Free SSL with Certbot

apt install -y certbot python3-certbot-nginx
certbot --nginx -d api.yourdomain.com

Certbot automatically edits your Nginx config to redirect HTTP → HTTPS and add SSL certs. It also sets up auto-renewal.

Your API is now live at https://api.yourdomain.com.

Option B: Platform-as-a-Service (Easier)

If you don't want to manage a server, these platforms handle infrastructure for you:

PlatformFree TierBest For
RailwayGenerous free tierQuick deploys, hobby projects
RenderFree (spins down after inactivity)APIs, background workers
HerokuPaid only (changed 2022)Battle-tested, more expensive
Fly.ioFree allowanceContainers, global edge

Deploying to Railway:

npm install -g @railway/cli
railway login
railway init # link to Railway project
railway up # deploy
railway domain # get a public URL

Set environment variables in the Railway dashboard under Variables.

Deploying Updates

With the VPS approach, update your deployment:

# On your server
cd /var/www/app
git pull
npm install --production
pm2 reload my-api # zero-downtime reload

Automate this with a GitHub Actions workflow:

.github/workflows/deploy.yml
name: Deploy

on:
push:
branches: [main]

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/app
git pull
npm install --production
pm2 reload my-api

Add your server IP and SSH private key as GitHub repository secrets (Settings → Secrets).

Production Checklist

Before going live:

  • NODE_ENV=production is set (disables stack traces in error responses)
  • All secrets in environment variables, not hardcoded
  • Rate limiting enabled on all API routes
  • Helmet middleware configured
  • Logging to a file (PM2 handles this automatically)
  • Error handler doesn't expose internals in production
  • Database connection string is the production database, not dev
  • SSL certificate installed (HTTPS)
  • Domain DNS points to the server IP
  • Firewall: only ports 22, 80, 443 open (ufw allow 22 && ufw allow 80 && ufw allow 443 && ufw enable)
  • Automatic deploys on push to main (GitHub Actions)

Monitoring Your App

# View real-time logs
pm2 logs my-api --lines 100

# Monitor CPU and memory
pm2 monit

# Check if app is responding
curl https://api.yourdomain.com/health

Add a health check endpoint to your Express app:

app.get("/health", (req, res) => {
res.json({ status: "ok", uptime: process.uptime() });
});

PM2 Plus and services like Datadog or Better Uptime can alert you when the app goes down.