0 · Context
-
OS: Ubuntu 24.04 (LTS = “noble”)
-
Reverse-proxy: Cloudflare (orange-cloud on)
-
Services I actually expose:
- 80/443 – web, proxied by Cloudflare
- 2222 – SSH > deliberately moved from the default port
22
to reduce automated attack noise and scanning.
↳ Access is strictly limited to SSH keys only, with password authentication completely disabled for added security.
- 53 – local DNS stub (needed for
apt update
)
Everything else—FTP, rpcbind, test DBs—can hide behind the firewall.
1 · Kick out the extra firewall (UFW)
sudo ufw disable
sudo systemctl stop ufw
sudo systemctl mask ufw
Why? UFW adds its own nft chains; editing iptables directly is easier when you only have one ruleset.
2 · Build a clean iptables / ip6tables policy
# 2-A global defaults
sudo iptables -P INPUT DROP
sudo iptables -P FORWARD DROP
sudo ip6tables -P INPUT DROP
sudo ip6tables -P FORWARD DROP
# 2-B loopback + established
sudo iptables -I INPUT 1 -i lo -j ACCEPT
sudo iptables -I INPUT 2 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# 2-C the ports I actually want public
sudo iptables -I INPUT 3 -p tcp --dport 80 -j ACCEPT
sudo iptables -I INPUT 3 -p tcp --dport 443 -j ACCEPT
sudo iptables -I INPUT 3 -p tcp --dport 2222 -j ACCEPT
sudo iptables -I INPUT 3 -p tcp --dport 53 -j ACCEPT
sudo iptables -I INPUT 3 -p udp --dport 53 -j ACCEPT
# 2-D (optional) nuke surprise listeners
sudo iptables -I INPUT 3 -p tcp --dport 1024 -j DROP # rpcbind
sudo iptables -I INPUT 3 -p tcp --dport 3050 -j DROP # old Firebird DB
# 2-E persist
sudo netfilter-persistent save
(Mirror the ACCEPT rules in ip6tables
if you have a public IPv6 address.)
3 · Let ONLY Cloudflare hit ports 80/443
Create cloudflare-only.sh
and run it whenever Cloudflare updates their IP list.
#!/usr/bin/env bash
CF4=$(curl -s https://www.cloudflare.com/ips-v4)
CF6=$(curl -s https://www.cloudflare.com/ips-v6)
iptables -F CF_ALLOW 2>/dev/null || iptables -N CF_ALLOW
for ip in $CF4; do
iptables -A CF_ALLOW -p tcp -s $ip --dport 80 -j ACCEPT
iptables -A CF_ALLOW -p tcp -s $ip --dport 443 -j ACCEPT
done
iptables -A CF_ALLOW -j DROP
iptables -I INPUT 3 -p tcp --dport 80 -j CF_ALLOW
iptables -I INPUT 3 -p tcp --dport 443 -j CF_ALLOW
sudo bash cloudflare-only.sh
sudo netfilter-persistent save
Result: direct hits to my IP on 80/443 ⇒ dropped, but Cloudflare’s POPs sail through.
4 · SSH hardening (keys-only, Fail2Ban)
# /etc/ssh/sshd_config
Port 2222
PasswordAuthentication no
PermitRootLogin no
sudo systemctl restart ssh
Add Fail2Ban:
sudo apt install -y fail2ban
cat <<'EOF' | sudo tee /etc/fail2ban/jail.local
[sshd]
enabled = true
port = 2222
maxretry = 3
bantime = 24h
EOF
sudo systemctl restart fail2ban
5 · Nginx security headers & dot-file blocking
/etc/nginx/conf.d/security.conf
:
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Permissions-Policy "geolocation=(), microphone=()" always;
location ~ /\.(git|svn|hg|bzr|env|ht|DS_Store|swp|bak)$ { return 403; }
if ($request_method !~ ^(GET|HEAD|POST)$) { return 405; }
sudo nginx -t && sudo systemctl reload nginx
Now a quick test:
curl -Is https://example.com | grep Strict
curl -I https://example.com/.git/ | head -1 # 403 or 404
6 · Keep DNS & apt working (while 53 is open)
sudo systemctl enable --now systemd-resolved
sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
# /etc/systemd/resolved.conf
[Resolve]
DNS=1.1.1.1 1.0.0.1
FallbackDNS=8.8.8.8 8.8.4.4
DNSStubListener=yes
sudo systemctl restart systemd-resolved
sudo apt update && sudo apt upgrade -y
Inbound port 53 is public, but Cloudflare and Fail2Ban handle real threats while my packages keep updating.
7 · Daily-driver maintenance
Task |
Command |
Why |
Check open ports |
nmap -Pn -p- $(curl -s ifconfig.me) |
sanity |
Fail2Ban status |
sudo fail2ban-client status sshd |
brute-force log |
Renew TLS test |
certbot renew --dry-run |
avoid outages |
Updates |
enabled via unattended-upgrades |
patch CVEs |
Backups |
restic → object storage |
disaster recovery |
The TL;DR flow
- Disable UFW → avoid rule chaos.
- iptables default-DROP → allow only 80, 443, 2222, 53.
- Run
cloudflare-only.sh
→ origin visible to Cloudflare only.
- SSH keys + Fail2Ban → brute-force junk gone.
- Nginx headers + dot-file ban → no accidental leaks.
- systemd-resolved → apt keeps working.
- Auto-patch, auto-renew, backups → sleep at night.
Copy, adapt, deploy your VPS is now a lot harder to poke holes in without making maintenance painful.