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
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
Enable and start it:
systemctl enable --now wg-quick@wg0
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"
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
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
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
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');
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
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 runss -tlnp. -
Docker bypasses UFW by default. Docker writes its own iptables rules. Binding to
10.66.66.1at 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)