Securing Your VPS: A Practical Guide to Hardening Node.js Apps in Production
If you’re running Node.js apps on a VPS, there’s a good chance your setup has security holes you haven’t thought about. I know because I just audited mine and found several.
This post covers the practical steps I took to harden a single Ubuntu VPS running 8+ Node.js applications behind Nginx. No theoretical fluff — just the real commands and config changes.
The Problem: Everything Was Wide Open#
Here’s what my VPS looked like before hardening:
- Multiple Node.js apps listening on
0.0.0.0(all interfaces) - No firewall configured
- Apps directly reachable on their ports from the internet
- Admin panels with no IP restrictions
Sound familiar? If you’ve ever spun up a VPS, installed Node, ran npm start, and called it a day — you probably have the same issues.
Why 0.0.0.0 Is Dangerous#
When a Node.js app listens on 0.0.0.0:3000, it’s accessible on every network interface — including the public IP. Even if Nginx is your “front door,” anyone can bypass it by hitting YOUR_SERVER_IP:3000 directly.
This means:
- No SSL/TLS protection (bypassing your Nginx certs)
- No rate limiting
- No access logs through your reverse proxy
- Direct exposure of your application to the internet
Step 1: Set Up UFW (Uncomplicated Firewall)#
First things first — block everything except what you need.
# Install UFW (usually pre-installed on Ubuntu)
sudo apt install ufw
# Set default policies
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow only essential ports
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
# Enable the firewall
sudo ufw enable
# Verify
sudo ufw status verbose
Output should look like:
Status: active
To Action From
-- ------ ----
22/tcp ALLOW Anywhere
80/tcp ALLOW Anywhere
443/tcp ALLOW Anywhere
Now only SSH, HTTP, and HTTPS are accessible from the outside. All your Node.js app ports (3000, 3100, 5678, etc.) are blocked.
Additional Ports#
If you run other services, add them explicitly:
# Example: Jitsi Meet (UDP for WebRTC)
sudo ufw allow 10000/udp
Important: Always add your SSH rule before enabling UFW. Lock yourself out once and you’ll never forget.
Step 2: Bind Node.js Apps to 127.0.0.1#
Even with a firewall, binding to localhost is defense in depth. If the firewall fails or gets misconfigured, your apps still aren’t exposed.
Next.js Apps#
If you’re using PM2 (you should be), update your ecosystem config:
// ecosystem.config.js
module.exports = {
apps: [
{
name: 'my-nextjs-app',
script: 'node_modules/.bin/next',
args: 'start -H 127.0.0.1 -p 3100',
cwd: '/var/www/my-app',
env: {
NODE_ENV: 'production',
},
},
],
};
The key flag is -H 127.0.0.1 — this tells Next.js to only listen on the loopback interface.
Express / Fastify / Raw HTTP Apps#
For custom Node.js servers, change the listen call:
// ❌ Before — listens on all interfaces
app.listen(3000, () => {
console.log('Server running on port 3000');
});
// ✅ After — localhost only
app.listen(3000, '127.0.0.1', () => {
console.log('Server running on 127.0.0.1:3000');
});
n8n (Workflow Automation)#
If you’re running n8n, set the listen address in your PM2 ecosystem config:
{
name: 'n8n',
script: 'n8n',
args: 'start',
env: {
N8N_LISTEN_ADDRESS: '127.0.0.1',
N8N_PORT: 5678,
N8N_PROTOCOL: 'https',
N8N_HOST: 'n8n.yourdomain.com',
},
}
Verifying the Change#
After restarting each app, confirm it’s bound correctly:
# Check what's listening and where
sudo ss -tlnp | grep node
# You should see 127.0.0.1:PORT, NOT 0.0.0.0:PORT
# ✅ Good: 127.0.0.1:3100
# ❌ Bad: 0.0.0.0:3100
Run ss -tlnp after every change. Trust but verify.
Step 3: Configure Nginx as a Secure Reverse Proxy#
Your Nginx config should be the only thing talking to your Node apps. Here’s a solid template:
server {
listen 443 ssl http2;
server_name myapp.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/myapp.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/myapp.yourdomain.com/privkey.pem;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Rate limiting (define in http block)
limit_req zone=general burst=20 nodelay;
location / {
proxy_pass http://127.0.0.1:3100;
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;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name myapp.yourdomain.com;
return 301 https://$host$request_uri;
}
IP Whitelisting for Admin Panels#
For dashboards and admin tools you don’t want public:
location / {
# Only allow specific IPs
allow YOUR_HOME_IP;
allow YOUR_OFFICE_IP;
deny all;
# Also add basic auth as a second layer
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://127.0.0.1:9090;
}
Create the htpasswd file:
sudo apt install apache2-utils
sudo htpasswd -c /etc/nginx/.htpasswd admin
Pro tip: Layer your security. IP whitelist + basic auth + application auth = three locks on the door. Any one of them might fail; all three won’t.
Step 4: Secure Cockpit (or Any Systemd Service)#
If you run Cockpit for server management, it listens on port 9090 by default — on all interfaces. Here’s how to lock it down:
# Create an override directory
sudo mkdir -p /etc/systemd/system/cockpit.socket.d/
# Create the override config
sudo cat > /etc/systemd/system/cockpit.socket.d/listen.conf << 'EOF'
[Socket]
ListenStream=
ListenStream=127.0.0.1:9090
EOF
# Reload and restart
sudo systemctl daemon-reload
sudo systemctl restart cockpit.socket
The empty ListenStream= line clears the default, and the second line sets the new binding. This pattern works for any systemd socket-activated service.
Step 5: Audit Everything#
After making all changes, do a full audit:
# Check all listening services
sudo ss -tlnp
# Scan your own server from outside (or use nmap)
nmap -Pn YOUR_SERVER_IP
# Check UFW status
sudo ufw status numbered
# Test that direct port access is blocked
curl -m 5 http://YOUR_SERVER_IP:3100 # Should timeout/refuse
curl https://myapp.yourdomain.com # Should work
The Checklist#
Run through this for every app on your VPS:
- App binds to
127.0.0.1, not0.0.0.0 - App port is NOT in UFW allow list
- Nginx proxies to
127.0.0.1:PORT - SSL certificate is valid and auto-renewing
- Security headers are set
- Admin panels have IP restrictions + auth
- PM2 is configured to restart apps on crash
-
ss -tlnpshows no0.0.0.0bindings for your apps
Common Mistakes#
1. Forgetting about PM2 restarts
After changing bind addresses, just restarting the app isn’t enough if PM2 has cached the old config:
pm2 delete my-app
pm2 start ecosystem.config.js
pm2 save
2. Not checking after reboot
Your server will reboot eventually. Make sure PM2 startup is configured:
pm2 startup
pm2 save
3. Blocking yourself out with UFW
Always ensure SSH (port 22) is allowed before enabling UFW. If you’re using a non-standard SSH port, allow that instead.
4. Trusting the firewall alone
Firewalls can be misconfigured, reset, or bypassed. Always bind to localhost as well — defense in depth.
Bonus: Quick Security Wins#
A few more things that take 5 minutes each:
# Disable root SSH login
sudo sed -i 's/^PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
sudo systemctl restart sshd
# Auto-install security updates
sudo apt install unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
# Fail2ban for SSH brute force protection
sudo apt install fail2ban
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
Wrapping Up#
The whole process took about an hour. The changes are:
- UFW firewall — only SSH, HTTP, HTTPS open
- Localhost binding — every Node.js app on
127.0.0.1 - Nginx hardening — security headers, rate limiting, IP whitelisting for admin panels
- Service lockdown — Cockpit and other tools bound to localhost
- Audit — verify everything with
ss -tlnpand external port scans
None of this is complicated. The hard part is actually doing it instead of telling yourself you’ll get to it later.
If you’re running a VPS with Node.js apps, go check ss -tlnp right now. You might be surprised what’s exposed.
Running multiple apps on a single VPS? Check out our other posts on building Telegram bots and deploying Next.js apps in production.