Deploying Express to Production
What Happens When You Deploy
Locally, you run node index.js and your app lives on localhost:3000. Deployment means:
- Getting your code onto a server accessible on the internet
- Running Node.js as a background process (restarts on crash, survives server reboots)
- Putting Nginx in front of it (handles SSL, serves static files, load balancing)
- 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
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:
| Platform | Free Tier | Best For |
|---|---|---|
| Railway | Generous free tier | Quick deploys, hobby projects |
| Render | Free (spins down after inactivity) | APIs, background workers |
| Heroku | Paid only (changed 2022) | Battle-tested, more expensive |
| Fly.io | Free allowance | Containers, 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:
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=productionis 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.