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, not 0.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 -tlnp shows no 0.0.0.0 bindings 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:

  1. UFW firewall — only SSH, HTTP, HTTPS open
  2. Localhost binding — every Node.js app on 127.0.0.1
  3. Nginx hardening — security headers, rate limiting, IP whitelisting for admin panels
  4. Service lockdown — Cockpit and other tools bound to localhost
  5. Audit — verify everything with ss -tlnp and 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.