DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Workshop: Harden Your VPS in One Session — Step-by-Step with Working Commands

What We Are Building

By the end of this workshop, you will take a default VPS — the kind with seven or more publicly exposed services — and reduce its attack surface to exactly three open ports. We will set up a WireGuard VPN, move every admin service behind it, lock down SSH, configure a firewall, and add application-level hardening to an Express.js server.

Let me show you a pattern I use on every production server I provision. It takes one session and the results are measurable: public-facing services drop from 7+ to 3.

Prerequisites

  • A VPS running Ubuntu 22.04 or 24.04 (Debian-based works too)
  • Root or sudo access
  • A local machine running Linux, macOS, or WSL
  • Node.js and Docker installed on the server
  • Basic comfort with the terminal

Step 1: Install and Configure WireGuard VPN

Everything else depends on this. WireGuard creates a private tunnel between your machine and the server so you can pull services off the public internet entirely.

apt update && apt install wireguard -y

wg genkey | tee /etc/wireguard/server_private.key | wg pubkey > /etc/wireguard/server_public.key
Enter fullscreen mode Exit fullscreen mode

Create the server config:

# /etc/wireguard/wg0.conf
[Interface]
Address = 10.66.66.1/24
ListenPort = 51820
PrivateKey = <server_private_key>

[Peer]
PublicKey = <client_public_key>
AllowedIPs = 10.66.66.2/32
PersistentKeepalive = 25
Enter fullscreen mode Exit fullscreen mode

Enable and start it:

systemctl enable --now wg-quick@wg0
Enter fullscreen mode Exit fullscreen mode

On your local machine, set up the client peer with address 10.66.66.2/24 and point the endpoint to your server's public IP on port 51820. Test with ping 10.66.66.1 — if it responds, your tunnel is live.

Step 2: Bind Docker Ports to the VPN Interface

Here is the minimal setup to get this working. In your Docker Compose files (or Coolify config), change port bindings from the default 0.0.0.0 to the WireGuard IP:

ports:
  - "10.66.66.1:8000:8000"
  - "10.66.66.1:8080:8080"
  - "10.66.66.1:6001:6001"
  - "10.66.66.1:6002:6002"
Enter fullscreen mode Exit fullscreen mode

Keep ports 80 and 443 on 0.0.0.0 — those serve your actual web traffic. Everything else disappears from the public internet.

Step 3: Lock Down SSH

The docs do not mention this, but on Ubuntu 24.04, SSH uses a systemd socket by default. Editing sshd_config alone will not change the listening address — you need a socket override too.

# /etc/ssh/sshd_config
PermitRootLogin no
PasswordAuthentication no
MaxAuthTries 3
ListenAddress 10.66.66.1
Enter fullscreen mode Exit fullscreen mode

Now the critical part — the socket override:

mkdir -p /etc/systemd/system/ssh.socket.d
cat > /etc/systemd/system/ssh.socket.d/override.conf << 'EOF'
[Socket]
ListenStream=
ListenStream=10.66.66.1:22
EOF

systemctl daemon-reload
systemctl restart ssh.socket
Enter fullscreen mode Exit fullscreen mode

The empty ListenStream= line clears the default before adding your VPN-only binding. Skip that line and you will end up with SSH listening on both addresses.

Step 4: Configure UFW

ufw default deny incoming
ufw default allow outgoing
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 51820/udp
ufw enable
Enter fullscreen mode Exit fullscreen mode

Three ports. That is your entire public surface.

Step 5: Harden Express.js

const helmet = require('helmet');
const rateLimit = require('express-rate-limit');

app.use(helmet());

const triggerLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  standardHeaders: true,
  legacyHeaders: false,
});

app.use('/api/trigger', triggerLimiter);

// Bind to loopback — only your reverse proxy reaches this
app.listen(3000, '127.0.0.1');
Enter fullscreen mode Exit fullscreen mode

Binding to 127.0.0.1 means the Node process is never directly reachable from the network. Your reverse proxy (Nginx or Caddy) handles public traffic and forwards to localhost.

Step 6: Rotate Secrets and Lock Permissions

openssl rand -hex 32   # Generate a fresh token

chmod 600 .env
Enter fullscreen mode Exit fullscreen mode

If your secrets have ever appeared in a git commit or a log file, rotate them now. Assume compromise.

Gotchas

Here is the gotcha that will save you hours:

  • The empty ListenStream= line is not optional. Without it, systemd appends your new address to the existing defaults instead of replacing them. SSH stays public and you will not notice until you run ss -tlnp.
  • Docker bypasses UFW by default. Docker writes its own iptables rules. Binding to 10.66.66.1 at the Docker level is the real fix — do not rely on UFW alone to block Docker-exposed ports.
  • Test your VPN before restricting SSH. If your WireGuard tunnel fails after you bind SSH to the VPN IP, you are locked out. Always verify the tunnel works, then keep a console session open through your hosting provider as a fallback.
  • helmet() does not replace a reverse proxy. Helmet sets security headers, but it does not handle TLS termination or request filtering. Pair it with Caddy or Nginx in front.

Conclusion

The key principle behind this entire workshop: reduce the surface, then harden what remains. WireGuard shrinks your public exposure. UFW enforces it at the network layer. Application-level hardening with Helmet and rate limiting handles what gets through.

Start with WireGuard — that single step eliminates the majority of your attack surface. Then bind, do not just block. Firewalls can be misconfigured. Binding services to 10.66.66.1 or 127.0.0.1 means they physically cannot respond on public interfaces regardless of firewall state.

Your VPS is being scanned right now. This workshop takes one session. Ship it today.

Further reading:

Top comments (0)