Deploying a TypeScript Express.js app on a VPS
1. Prepare your application
Make sure your build scripts actually output to the right place before you push to the server.
TypeScript configuration
Check that your tsconfig.json outputs the compiled code to a specific directory (like dist):
{
"compilerOptions": {
"target": "es2022",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Current Node LTS runs es2022 natively. Lower the target only if you deploy to an older Node version.
Package.json scripts
You need standard build and start scripts:
{
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node-dev src/index.ts"
}
}
Tell Express it’s behind a proxy
Nginx is going to sit in front of your app and forward requests to it. Express doesn’t know that by default, so req.ip ends up being the proxy’s address (127.0.0.1) for every single request. That quietly breaks anything that depends on the client IP, and rate limiting is the big one: every visitor looks like the same IP, so the first person to hit the limit locks out everyone else.
Set the trust proxy level near the top of your app, before you register middleware:
app.set("trust proxy", 1);
The 1 means “trust one hop”, which is exactly your single Nginx instance. Don’t set it to true. That tells Express to trust whatever X-Forwarded-For header arrives, and since anyone can forge that header, it hands attackers a trivial way to spoof their IP and walk straight past your rate limiter.
While you’re in there, make sure the app listens on 127.0.0.1 rather than 0.0.0.0. There’s no reason for Node to accept connections from anywhere except the local Nginx, and binding to localhost means a misconfigured firewall can’t accidentally expose port 3000 to the internet.
2. Server setup
SSH into your server and install Node.js. The NodeSource setup script is the easiest way to get the latest LTS version:
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt-get install -y nodejs
3. Clone and build your app
Do this as a normal sudo user, not root. Node has no business running as root in production. If your app gets popped, you don’t want the attacker landing with the keys to the whole machine. The chown below hands the app directory to your own user so the rest works without sudo.
sudo mkdir -p /var/www/your-express-app
sudo chown -R $USER:$USER /var/www/your-express-app
cd /var/www/your-express-app
git clone your-repository-url .
npm ci
npm run build
npm ci installs the exact versions from your lockfile and fails if it disagrees with package.json, so the server runs the same dependency tree you tested locally.
Environment setup
Set up your environment variables. Set NODE_ENV to production so Express caches views and stops leaking stack traces in its error responses.
cp .env.example .env
nano .env
Your .env holds database passwords and API keys, so don’t leave it world-readable. Lock it to your user only:
chmod 600 .env
4. Install and configure PM2
Node is single-threaded and crashes if an unhandled exception occurs. PM2 restarts your app automatically if it goes down.
Install it globally:
sudo npm install -g pm2
Start your compiled app as your normal user (the one that owns the app directory), so the process runs as that user and not root:
pm2 start dist/index.js --name "your-app-name"
Tell PM2 to boot up automatically when the server restarts:
pm2 startup
pm2 save
pm2 startup prints one sudo command for you to copy and run. That’s the one spot you need elevated rights, and it just registers the service. The app itself keeps running as your user.
(If you ever need to check on things, pm2 logs and pm2 monit are your best friends).
5. Configure Nginx as a reverse proxy
You shouldn’t expose your Node app directly to port 80. Use Nginx to handle the incoming connections and pass them to Express.
Create a new Nginx config file:
sudo nano /etc/nginx/sites-available/your-express-app
Add this configuration (adjust the port if your app doesn’t use 3000):
server {
listen 80;
server_name your-domain.com;
server_tokens off;
client_max_body_size 10m;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
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;
}
}
The X-Forwarded-* and X-Real-IP headers are what make trust proxy work, since Express reads the real client IP out of X-Forwarded-For. Without them, your rate limiter is blind. server_tokens off stops Nginx from advertising its exact version, and client_max_body_size caps upload size so nobody ties up your app with a 2GB request. The security headers cover clickjacking and MIME-sniffing.
Enable it and restart Nginx:
sudo ln -s /etc/nginx/sites-available/your-express-app /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
6. Add HTTPS
Right now the site answers on plain HTTP. Don’t leave it there, especially if anyone logs in or sends data you care about. Certbot pulls a free Let’s Encrypt certificate and rewrites your Nginx config to use it:
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d your-domain.com
It also sets up automatic renewal, so the certificate won’t expire out from under you. After this runs, Nginx terminates TLS and your X-Forwarded-Proto header starts reporting https, which is exactly what your app needs to set secure cookies correctly.
7. Automate updates
Write a quick bash script so you don’t have to remember these steps every time you deploy.
nano /var/www/your-express-app/deploy.sh
#!/bin/bash
set -e
cd /var/www/your-express-app
git pull
npm ci
npm run build
pm2 restart your-app-name --update-env
The set -e matters. Without it, a failed npm ci or a broken build won’t stop the script, and you’d restart PM2 onto code that doesn’t compile. With it, the script stops at the first error and leaves the old version running.
Make it executable:
chmod +x deploy.sh
Now you just run ./deploy.sh to push updates live.
Production checklist
Before you call it done, double-check a few things:
NODE_ENVis set toproduction.- The app runs as a normal user, never root.
trust proxyis set, and Nginx forwards theX-Forwarded-*headers.- You aren’t shipping source maps unless you have a specific reason to.
- Rate limiting is in place (something like
express-rate-limit). - Security headers are set in the app (
helmet) on top of the Nginx ones. npm auditcomes back clean, or at least without anything critical.