I Hardened My VPS in One Session — Here's the Checklist
Meta description: Step-by-step VPS security hardening checklist for indie developers — covering WireGuard VPN, Docker isolation, SSH lockdown, and Express.js middleware.
TL;DR
Most indie developers deploy to a VPS and never touch security until something breaks. I spent one session hardening a production server running Node.js, Docker, and Coolify — and reduced the public attack surface from 7+ exposed services to just 3 ports. This is the exact checklist, with real commands, that took the server from “it works” to “it’s defensible.”
The Problem: Your VPS Is Wide Open by Default
In my experience building production systems, the default state of a fresh VPS is shockingly permissive. Every port Docker exposes is public. SSH accepts root logins with passwords. Your admin dashboards sit on the open internet waiting to be brute-forced.
Here is what most teams get wrong about this: they treat security as a future task. But attack scanners don’t wait for your roadmap. Shodan indexes new IPs within hours.
Here’s the before-and-after from a real production server:
| Surface | Before Hardening | After Hardening |
|---|---|---|
| SSH (22) | Public, root login enabled, password auth on | VPN-only (10.66.66.1), key-only, MaxAuthTries 3 |
| Coolify Dashboard (8000/8080) | Public, accessible to anyone | VPN-only, bound to WireGuard IP |
| Realtime Ports (6001-6002) | Public via Docker | VPN-only, bound to WireGuard IP |
| HTTP/HTTPS (80/443) | Public | Public (required for web traffic) |
| WireGuard (51820/udp) | N/A | Public (VPN entry point) |
| Express.js trigger endpoint | Bound to 0.0.0.0, no rate limiting | Bound to 127.0.0.1, 5 req/15min limit |
| .env file | Default permissions (644) | chmod 600, new rotated secrets |
The numbers tell a clear story here. Public-facing services dropped from 7+ to 3.
The Checklist
1. Set Up WireGuard VPN
Everything else depends on this. WireGuard gives you a private network between your machine and the server so you can move services off the public internet.
# Install WireGuard
apt install wireguard
# Generate server keys
wg genkey | tee /etc/wireguard/server_private.key | wg pubkey > /etc/wireguard/server_public.key
# /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
systemctl enable --now wg-quick@wg0
On your local machine, configure the client peer with 10.66.66.2/24 and point the endpoint to your server’s public IP on port 51820.
2. Isolate Docker Ports Behind VPN
Coolify and similar platforms expose admin ports publicly by default. Bind them to the WireGuard interface IP instead.
In your Docker Compose or Coolify configuration, change port mappings from 0.0.0.0:8000:8000 to the VPN 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"
Ports 80 and 443 stay on 0.0.0.0 — those serve your actual web traffic.
3. Lock Down SSH
On Ubuntu 24.04, SSH uses a systemd socket by default, so binding ListenAddress requires a socket override — not just editing sshd_config.
# /etc/ssh/sshd_config changes
PermitRootLogin no
PasswordAuthentication no
MaxAuthTries 3
ListenAddress 10.66.66.1
# Override the systemd socket to match
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 is critical — it clears the default before adding the VPN-only binding.
4. Configure UFW Firewall
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’s your entire public surface.
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 — reverse proxy handles public traffic
app.listen(3000, '127.0.0.1');
Binding to 127.0.0.1 means the Node process is never directly reachable from the network. Only your reverse proxy (Nginx/Caddy via Coolify) talks to it.
6. Defend Against Prompt Injection
If your app passes external content to an LLM — RSS feeds, scraped articles — sanitize it before it reaches the model.
function sanitizeForLLM(content, maxLength = 8000) {
return content
.replace(/<[^>]*>/g, '') // Strip HTML tags
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/\{[\s\S]*?\}/g, '') // Remove JSON-like blocks
.slice(0, maxLength);
}
This won’t stop every injection, but it eliminates the low-hanging vectors — embedded HTML directives, hidden code blocks, and oversized payloads designed to overflow context windows.
7. Rotate Secrets and Lock Permissions
# Generate a new admin token
openssl rand -hex 32
# Update .env and restrict permissions
chmod 600 .env
If your secrets have ever been in a git commit, a log file, or a public-facing error page, rotate them. Assume compromise.
Conclusion
Let me walk you through the key principle behind this checklist: reduce the surface, then harden what remains. WireGuard shrinks your public exposure. UFW enforces it at the network layer. Application-level hardening (Helmet, rate limiting, input sanitization) handles what gets through.
3 Actionable Takeaways
-
Install WireGuard first, then move every non-public service behind it. This single step eliminates the majority of your attack surface. SSH, admin dashboards, database ports — none of these need to face the internet.
-
Bind, don’t just block. Firewalls can be misconfigured or bypassed. Binding services to
10.66.66.1or127.0.0.1means they physically cannot respond on public interfaces, regardless of firewall state. Defense in depth starts at the socket level. -
Treat external content as hostile input. If your application ingests RSS feeds, webhooks, or any third-party data that reaches an LLM or gets rendered, sanitize it at the boundary. Strip HTML, limit length, and never trust the payload structure.
This entire session took one sitting. There’s no reason to leave it for later — your VPS is being scanned right now.