Running an Express app with node server.js in production is asking for downtime. Close your terminal, hit an unhandled promise rejection, or run out of memory, and your API quietly dies with nobody around to restart it.
Here is the battle-tested setup I use to take an Express app live on an Ubuntu VPS: PM2 keeping processes alive across crashes and reboots, Nginx reverse-proxying port 3000, Namecheap pointing DNS, and a CDN edge caching static assets without clobbering your dynamic API routes.

1. Provision the Hostinger VPS and Initial Hardening
Start with a clean Ubuntu 24.04 LTS instance on Hostinger. Once the VPS provisions in hPanel, grab the server IP and SSH in from your local machine.
First thing: update the package indexes so you aren’t building on outdated security patches.
ssh root@YOUR_SERVER_IP
apt update && apt upgrade -y
apt install -y curl git build-essential ufwNever run your Express app as root. If an npm dependency has a vulnerability, a compromised app hands the attacker the entire server. Create a dedicated deploy user with sudo rights instead.
adduser deployer
usermod -aG sudo deployer
rsync --archive --chown=deployer:deployer ~/.ssh /home/deployer
su - deployerNext, lock down the firewall with UFW. We only want SSH, HTTP, and HTTPS accessible to the public internet. Express will bind strictly to localhost (127.0.0.1), so port 3000 won’t be exposed directly to the outside world.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableIf you plan to lock origin traffic strictly to your CDN edge down the road, check out our guide to restrict origin server traffic with UFW so nobody can bypass your caching layer.
2. Install Node.js LTS, PM2, and Nginx
Ubuntu’s default apt repositories usually ship ancient Node versions. Pull the current Active LTS (Node.js 20.x or 22.x) straight from NodeSource.
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs nginx
node -v
npm -vInstall PM2 globally. It will act as your process supervisor—handling cluster mode across CPU cores, auto-restarts on uncaught exceptions, and systemd boot scripts.
sudo npm install -g pm2Confirm Nginx is up and running.
sudo systemctl status nginx3. Prepare the Express.js Application Structure
Create a directory under /var/www/ and hand ownership over to your deployer user. That way, you can deploy or pull Git commits without touching sudo.
sudo mkdir -p /var/www/express-app
sudo chown -R deployer:deployer /var/www/express-app
cd /var/www/express-appIf you’re testing before cloning your actual repo, here is a clean, minimal package.json to get started.
{ "name": "express-vps-api", "version": "1.0.0", "type": "module", "main": "src/server.js", "scripts": { "start": "node src/server.js" }, "dependencies": { "express": "^4.19.2", "helmet": "^7.1.0", "dotenv": "^16.4.5" }
}Install the dependencies inside /var/www/express-app.
npm install --productionNow create your entrypoint at src/server.js. Make sure you set trust proxy—otherwise, Express won’t see the visitor’s real IP behind Nginx and your CDN.
import express from 'express';
import helmet from 'helmet';
import dotenv from 'dotenv'; dotenv.config(); const app = express();
const PORT = process.env.PORT || 3000; // Trust reverse proxy hops (Nginx + CDN edge)
app.set('trust proxy', 1); app.use(helmet());
app.use(express.json()); // Health check endpoint for uptime monitors
app.get('/health', (req, res) => { res.status(200).json({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString() });
}); // Dynamic API route
app.get('/api/v1/data', (req, res) => { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.json({ message: 'Dynamic payload from Express backend', clientIp: req.ip, forwardedFor: req.headers['x-forwarded-for'] || null });
}); app.listen(PORT, '127.0.0.1', () => { console.log(`Express server running locally on port ${PORT}`);
});Notice the 127.0.0.1 host argument in app.listen. That’s deliberate. Express will only listen on the loopback interface, meaning direct external requests on port 3000 get dropped before they even reach Node. Take a look at the official Express performance and security best practices for more hardening tips.
4. Configure PM2 for Production Process Management
Don’t launch PM2 with inline CLI flags in production. Put an ecosystem.config.cjs file in your app root so your process topology stays version-controlled and reproducible.
module.exports = { apps: [ { name: 'express-api', script: './src/server.js', instances: 'max', exec_mode: 'cluster', autorestart: true, watch: false, max_memory_restart: '300M', env: { NODE_ENV: 'production', PORT: 3000 } } ]
};Spin up the app using the ecosystem file and snapshot the process list.
pm2 start ecosystem.config.cjs
pm2 saveNow make sure PM2 survives server reboots. Run the startup hook command:
pm2 startup systemdWatch out here: PM2 will output a command starting with sudo env PATH=$PATH.... You have to copy that generated line and run it manually in your terminal. If you skip that step, your app won’t come back up after a reboot.
Check on your workers using the standard commands from the PM2 CLI documentation:
pm2 list
pm2 logs express-api --lines 205. Configure Nginx as a Reverse Proxy
Nginx sits on the frontline: it terminates TLS, absorbs slow connections, serves static files fast, and forwards dynamic requests to Express on port 3000. It’s the same pattern we used when showing how to deploy Next.js on a VPS.
Wipe the default site placeholder and create a dedicated config file for your app.
sudo rm /etc/nginx/sites-enabled/default
sudo nano /etc/nginx/sites-available/express-api.confDrop in this server block, swapping out api.yourdomain.com for your real domain or subdomain.
server { listen 80; listen [::]:80; server_name api.yourdomain.com; # Security headers server_tokens off; client_max_body_size 10M; location / { proxy_pass http://127.0.0.1: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; # Timeouts proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; }
}Symlink the site into sites-enabled, test the syntax, and reload Nginx.
sudo ln -s /etc/nginx/sites-available/express-api.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginxAlways run nginx -t before reloading. If you have a syntax error or a missing semicolon, Nginx will tell you immediately without breaking live traffic. For deeper tuning on proxy buffers and headers, check the Nginx proxy module documentation.
6. Point Namecheap DNS to Hostinger VPS
Hop into your Namecheap dashboard to wire your domain to the Hostinger server IP. Head to Domain List, hit Manage next to your domain, and switch to the Advanced DNS tab.
- In Host Records, delete any default parking records (like the Namecheap welcome page).
- Click Add New Record.
- Pick A Record.
- Set Host to
@(for apex domain) orapi(if you are running a subdomain for your backend). - Set Value to your Hostinger VPS public IPv4 address.
- Set TTL to
5 minso changes propagate quickly while testing.
If you’re using a commercial SSL certificate instead of Let’s Encrypt, see our guide on how to install Namecheap SSL on Nginx to merge your CRT and CA bundle correctly.
Before touching SSL setup, verify that DNS actually resolves to your server using dig.
dig +short api.yourdomain.comDon’t move forward until that returns your exact VPS IP. If it returns an old record, Certbot’s domain validation challenge will fail.
7. Set Up Free SSL with Certbot ACME
Once DNS points to the server, install Snapd and grab Certbot to issue a Let’s Encrypt TLS certificate.
sudo apt install -y snapd
sudo snap install core && sudo snap refresh core
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbotRun Certbot with the Nginx plugin. It reads your server blocks, handles the ACME challenge, provisions the cert, and configures the TLS block in Nginx automatically.
sudo certbot --nginx -d api.yourdomain.comDo a quick dry run on the renewal hook so you aren’t caught off-guard when the 90-day cert expires.
sudo certbot renew --dry-runIf the dry run fails on the ACME challenge, follow our walkthrough on how to fix Certbot SSL auto-renewal failures on Nginx to clear up common routing issues.
8. Route CDN Caching for Express Static Assets and Dynamic APIs
Dropping a CDN in front of an Express backend absorbs traffic surges and offloads static assets. But if your cache-control headers are sloppy, your edge will happily cache sensitive API payloads or session tokens and serve them to other visitors.
Separate public static assets from dynamic endpoints right inside your Nginx config. Give static assets long cache lives and set Cache-Control: no-store on API routes.
# Inside /etc/nginx/sites-available/express-api.conf # Static asset directory served directly by Nginx
location /public/ { alias /var/www/express-app/public/; expires 30d; add_header Cache-Control "public, no-transform"; access_log off;
} # Dynamic API reverse proxy
location /api/ { 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; # Prevent edge caching of dynamic data add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}In your CDN dashboard, configure cache rules to obey origin cache headers. If you anticipate heavy traffic, look into how to configure CDN origin shield and tiered caching to prevent cache stampedes against your Express processes.
9. Frequently Asked Questions
Why does Express show 127.0.0.1 as the client IP in my logs?
Because Nginx proxies requests over localhost, Express sees Nginx as the client by default. Adding app.set('trust proxy', 1) tells Express to inspect the X-Forwarded-For header set by Nginx and log the actual client IP.
How do I deploy updates without causing downtime?
Pull your latest code, update any dependencies, and trigger a reload instead of a hard restart:
git pull origin main
npm install --production
pm2 reload express-apiBecause PM2 is running in cluster mode, it reloads workers sequentially. One worker handles incoming requests while another restarts, keeping your API accessible throughout deployments.
What should I do if my Express app crashes with an out-of-memory error?
Make sure your ecosystem.config.cjs includes max_memory_restart. If a memory leak pushes a worker past your threshold (e.g., 300M), PM2 cycles the worker before it starves the whole VPS. From there, inspect memory usage locally using Chrome DevTools or Node’s --inspect flag.
Next Steps for Production Hardening
Your Express backend is now running under an isolated PM2 cluster on a Hostinger VPS, proxied securely through Nginx, and mapped with Namecheap DNS. From here, lock down SSH by enforcing key-based authentication, disabling root login in /etc/ssh/sshd_config, and setting up automated database backups via cron.

