fhxd.de

A minimal nftables ruleset for a single-purpose VPS

2026-02-28

A small host that runs one service does not need a firewall framework. It needs about thirty lines of nftables, loaded at boot, with a default-drop input chain. Here is the whole thing:

#!/usr/sbin/nft -f
flush ruleset

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        ct state established,related accept
        ct state invalid drop
        iif lo accept

        ip protocol icmp accept
        ip6 nexthdr ipv6-icmp accept

        tcp dport 22 accept
        tcp dport { 80, 443 } accept
        udp dport 443 accept

        counter comment "dropped-by-policy"
    }
    chain forward {
        type filter hook forward priority 0; policy drop;
    }
    chain output {
        type filter hook output priority 0; policy accept;
    }
}

Use the inet family rather than separate ip and ip6 tables. One table covers both address families, which means you cannot accidentally secure IPv4 and leave IPv6 wide open — a genuinely common outcome with hand-written iptables rules.

The two rules everyone forgets

ICMP. The instinct is to drop it. Do not. Path MTU discovery depends on destination-unreachable / fragmentation-needed getting back to you. Block it and small packets work, large packets vanish, and you spend an afternoon convinced you have a routing problem. If you must be selective, permit at minimum destination-unreachable, time-exceeded, and parameter-problem.

UDP 443. Every modern web server offers HTTP/3, which is QUIC over UDP on port 443. Open TCP 443 alone and nothing appears broken: clients try QUIC, get no answer, and fall back to TCP. You have silently disabled HTTP/3 and the only symptom is that it is slightly slower than it should be.

Leave output alone

A default-drop output chain on a single-purpose box buys you very little and costs you every debugging session for the rest of the machine's life. DNS, NTP, ACME renewals, and package updates all need egress. If the host's job involves making arbitrary outbound connections, a restrictive output policy is not merely inconvenient, it is incoherent.

Apply it without locking yourself out

Validate first — nft -c -f parses without loading, so a typo cannot take the box away from you:

nft -c -f /etc/nftables.conf

Then set a dead-man's switch before you load, so a rule that is syntactically fine but wrong still gives you the box back:

setsid nohup bash -c 'sleep 180; [ -f /tmp/fw-ok ] || {
    nft flush ruleset
    systemctl disable --now nftables
}' </dev/null >/dev/null 2>&1 &

systemctl enable --now nftables

Now open a brand new connection — not the one you are already sitting in, which ct state established will happily keep alive regardless of whether new connections can get in. If the new session works, touch /tmp/fw-ok. If it does not, wait three minutes and the box comes back on its own.

Confirm it is actually filtering

The counter at the bottom of the input chain is there so you have an answer:

nft list chain inet filter input | grep counter

On any host reachable from the public internet, that number starts climbing within minutes. A counter still reading zero after an hour means the ruleset is not in the path, whatever systemctl says about it.