A default-deny firewall for a small server with ufw

A default-deny firewall flips the question. Instead of listing what to block, you list what to allow, and everything else is dropped. On a small server that's a short list: SSH, HTTP and HTTPS. ufw is a front end for the Linux firewall that keeps the rules readable.

Check what is listening first

Before you write rules, see what is actually listening, so you don't close a port the site needs.

bash
sudo ss -tulpn

A firewall script you can rerun

Keep the rules in one script. Run it from a console or with a session open, because it resets the rules before it adds them back.

bash
#!/usr/bin/env bash
# firewall.sh: default-deny rules for a web server. Run as root.
set -euo pipefail

ufw --force reset
ufw default deny incoming
ufw default allow outgoing

ufw limit 22/tcp comment 'ssh, rate limited'
ufw allow 80/tcp comment 'http'
ufw allow 443/tcp comment 'https'

ufw --force enable
ufw status verbose

ufw limit allows SSH but drops an address that opens six or more connections in 30 seconds, which slows brute-force runs without a ban list.

Rollback

If something breaks, turn the firewall off and fix the rules while the server is open:

bash
sudo ufw disable

Check the public listeners with Python

ufw decides what traffic gets through. This check answers a different question: which ports are bound to a public address, even if a service only meant to be local is bound to all interfaces. It only reads ss output, so it's safe to run any time.

python
import subprocess

ALLOWED = {22, 80, 443}
LOCAL = {"127.0.0.1", "::1"}


def public_listeners() -> dict[int, str]:
    out = subprocess.run(["ss", "-tlnH"], capture_output=True, text=True, check=True).stdout
    found = {}
    for line in out.splitlines():
        local = line.split()[3]
        addr, _, port = local.rpartition(":")
        if addr.strip("[]") in LOCAL:
            continue
        found[int(port)] = local
    return found


def main() -> int:
    unexpected = {port: local for port, local in public_listeners().items() if port not in ALLOWED}
    for port, local in sorted(unexpected.items()):
        print(f"public, not in ALLOWED: {local}")
    if not unexpected:
        print("only the allowed ports are open to the network")
    return 1 if unexpected else 0


if __name__ == "__main__":
    raise SystemExit(main())

Docker is the exception

Docker writes its own iptables rules for published ports, so a container started with -p 8080:80 is reachable even when ufw denies 8080. Bind published ports to localhost and let nginx front them: -p 127.0.0.1:8080:80.

Certificates and uptime break quietly, too. osec Monitor watches both for you, free.

Flow: packet in, default deny, allowed ports, listener check, Docker caution, rollback
Default deny for incoming traffic, with a listener check and a rollback.

Sources

• Ubuntu wiki, UFW: https://help.ubuntu.com/community/UFW
• Docker, packet filtering and firewalls: https://docs.docker.com/network/packet-filtering-firewalls/