A minimal nftables ruleset for a single-purpose VPS
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.