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

  1. Disable UFW → avoid rule chaos.
  2. iptables default-DROP → allow only 80, 443, 2222, 53.
  3. Run cloudflare-only.sh → origin visible to Cloudflare only.
  4. SSH keys + Fail2Ban → brute-force junk gone.
  5. Nginx headers + dot-file ban → no accidental leaks.
  6. systemd-resolved → apt keeps working.
  7. 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.

2 Likes

While this seems obvious for you and me, you should really add a note that users will need to use port 2222 instead of 22 in their clients for the people for which it is not an obvious thing …

3 Likes

You are absolutely right. I will fix that immediately.

3 Likes

This topic was automatically closed 3 days after the last reply. New replies are no longer allowed.