DEV Community

Cover image for I hardened my Hetzner VPS from scratch — here's everything I did (and the tools I built along the way)
UPinar
UPinar

Posted on

I hardened my Hetzner VPS from scratch — here's everything I did (and the tools I built along the way)

I run a production server on Hetzner (Ubuntu 24.04) and get hit with thousands of attack attempts daily. After 3 months of hardening, I've blocked 8,000+ IPs from 132 countries with zero successful intrusions.

Here's every step I applied, what actually worked, and the open-source tools I built to make it easier.


1. SSH (biggest single impact)

# /etc/ssh/sshd_config
Port 2222
PasswordAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
LoginGraceTime 30
AllowUsers myuser
Enter fullscreen mode Exit fullscreen mode

Moving SSH port sounds like security through obscurity, but it dropped 90% of automated scans overnight. Real attackers scan all ports anyway — this just filters out the lazy bots.


2. Firewall + IP blacklisting

# Create ipset blacklist
ipset create blacklist_set hash:ip hashsize 65536 maxelem 131072

# Add to iptables
iptables -I INPUT -m set --match-set blacklist_set src -j DROP

# Atomic swap for zero-downtime updates
ipset create blacklist_tmp hash:ip hashsize 65536 maxelem 131072
# ... populate blacklist_tmp ...
ipset swap blacklist_tmp blacklist_set
ipset destroy blacklist_tmp
Enter fullscreen mode Exit fullscreen mode

~8,000 IPs blocked and growing. ipset gives you O(1) lookup regardless of list size — regular iptables chains would crawl at this scale.


3. fail2ban (custom jails)

# /etc/fail2ban/jail.local
[sshd]
enabled = true
maxretry = 1
bantime = -1
action = %(action_)s
         ipset-blacklist
Enter fullscreen mode Exit fullscreen mode
  • maxretry=1 — sounds aggressive, zero false positives in 3 months with key-only auth
  • bantime = -1 — permanent ban
  • 7 jails: SSH + 5 nginx patterns + Suricata integration
  • Bans feed into ipset automatically

4. Suricata IDS

# Suricata + fail2ban integration
# /etc/fail2ban/filter.d/suricata.conf
[Definition]
failregex = \[.*\] .* \[Priority: [12]\] .* <HOST>
Enter fullscreen mode Exit fullscreen mode

Running in IDS mode with ~32K rules. The integration chain: Suricata detects → fail2ban bans → ipset blocks. Each layer feeds the next.


5. nginx hardening

# block-exploits.conf — 37+ patterns, silent drop
location ~* (\.env|wp-login|phpMyAdmin|\.git|\.sql|/admin) {
    return 444;
}

# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
Enter fullscreen mode Exit fullscreen mode

Status 444 = nginx closes the connection silently. No response body, no headers, no information leakage. Bots get nothing.


6. File integrity + Kernel hardening

# AIDE — monitors ~210K files
aide --init
cp /var/lib/aide/aide.db.new /var/lib/aide/aide.db
# Daily cron checks for unauthorized changes

# /etc/sysctl.d/99-hardening.conf
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.rp_filter = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
Enter fullscreen mode Exit fullscreen mode

Aligned with CIS benchmarks. SYN cookies alone prevent most SYN flood attempts.


7. Real-time monitoring

Telegram bot sends instant alerts for every ban:

fail2ban [sshd]
IP: 45.133.xx.xx
Action: Ban
Time: 2026-03-27 14:32:01
Enter fullscreen mode Exit fullscreen mode

Plus a custom status script — uptime, disk, fail2ban stats, SSL expiry, recent attacks — all in one command.


Results after 3 months

Metric Value
IPs blocked 8,000+
Countries 132
Successful intrusions 0
False positives (SSH) 0
Avg server load Minimal

The tool I built to verify all this

After setting everything up, I wanted a quick way to check if my headers, SSL, and DNS were actually correct.

Every existing tool was either:

  • Enterprise SaaS with a sales call
  • A CLI that dumps 200 lines of raw output
  • Free but limited to SSL-only

So I built ContrastScan — a free, open-source security scanner. Paste a domain, get an A-F grade covering 11 checks in under 3 seconds.

ContrastScan — Free Security Scanner

Scan any domain in seconds. Get an A-F security grade covering 11 security checks. Free, no signup required.

favicon contrastcyber.com

The core scanner is 2,300 lines of C — raw TCP handshakes for SSL, direct DNS queries via libresolv, HTTP header parsing with libcurl. No frameworks, no runtime bloat.

What it checks

Module Points What It Checks
Security Headers 25 CSP, HSTS, X-Frame-Options, Referrer-Policy, Permissions-Policy
SSL/TLS 20 Protocol version, cipher strength, cert validity
DNS Security 15 SPF, DKIM, DMARC
HTTPS Redirect 8 HTTP → HTTPS redirect
Info Disclosure 5 Server/X-Powered-By exposure
Cookie Security 5 Secure, HttpOnly, SameSite
DNSSEC 5 DNS response signatures
HTTP Methods 5 TRACE, DELETE, PUT detection
CORS 5 Cross-origin misconfig
HTML Analysis 5 Mixed content, inline scripts, SRI
CSP Analysis 2 unsafe-inline, unsafe-eval, wildcards

It also runs passive recon in the background: WHOIS, tech stack detection, WAF fingerprinting, subdomain enumeration, and subdomain takeover detection (checks for dangling CNAMEs across 30 services like GitHub Pages, Heroku, AWS S3, Netlify).

API access

# JSON
curl "https://contrastcyber.com/api/scan?domain=yourdomain.com"

# Text report
curl "https://contrastcyber.com/api/report?domain=yourdomain.com" -o report.txt
Enter fullscreen mode Exit fullscreen mode

For AI agents (MCP)

I also built ContrastAPI — 20 security tools in one API:

claude mcp add --transport http contrastapi https://api.contrastcyber.com/mcp
Enter fullscreen mode Exit fullscreen mode

CVE lookup, domain recon, tech fingerprinting, IP reputation, code security — no key required.


What surprised me

Moving SSH port had more impact than any other single change. Not "real" security, but eliminates 90% of log noise.

Most attacks are dumb bots. 95% hit wp-login.php on a server that doesn't run WordPress.

maxretry=1 is not aggressive. With key-only SSH, a single failed attempt is always suspicious.

A single grade changes behavior. Raw headers → eyes glaze over. "Grade: D" → they want to fix it immediately.


Stack

Layer Tech
Scanner C (libcurl, openssl, libresolv, cJSON)
Backend Python, FastAPI, SQLite
API Python, FastAPI, MCP
Frontend Vanilla HTML/CSS/JS
Infra Hetzner VPS, nginx, systemd, Let's Encrypt
Security fail2ban, Suricata, ipset, AIDE

No Docker. No Kubernetes. One VPS, two systemd services.


Try it

No signup, no API key. Happy to answer questions about any of these steps.

Top comments (0)