MVP Factory
ai startup development

I Hardened My VPS in One Session — Here's the Checklist

KW
Krystian Wiewiór · · 5 min read

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:

SurfaceBefore HardeningAfter Hardening
SSH (22)Public, root login enabled, password auth onVPN-only (10.66.66.1), key-only, MaxAuthTries 3
Coolify Dashboard (8000/8080)Public, accessible to anyoneVPN-only, bound to WireGuard IP
Realtime Ports (6001-6002)Public via DockerVPN-only, bound to WireGuard IP
HTTP/HTTPS (80/443)PublicPublic (required for web traffic)
WireGuard (51820/udp)N/APublic (VPN entry point)
Express.js trigger endpointBound to 0.0.0.0, no rate limitingBound to 127.0.0.1, 5 req/15min limit
.env fileDefault 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

  1. 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.

  2. Bind, don’t just block. Firewalls can be misconfigured or bypassed. Binding services to 10.66.66.1 or 127.0.0.1 means they physically cannot respond on public interfaces, regardless of firewall state. Defense in depth starts at the socket level.

  3. 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.


Share: Twitter LinkedIn