Build a minimal VPS firewall with nftables
Replace ad-hoc firewall commands with a readable dual-stack nftables policy for SSH, web traffic, loopback, and established connections.

nftables is the modern Linux packet-filtering interface. One ruleset can cover IPv4 and IPv6, and the configuration is concise enough to review before applying it.
Keep a provider console open while changing remote firewall rules. A syntax error is recoverable; locking out the only management path is less fun.
Start with the service inventory
For a normal web VPS, inbound traffic usually needs only:
- SSH from trusted administration addresses
- HTTP and HTTPS from anywhere
- Established reply traffic
- Loopback and essential ICMP/ICMPv6
Databases, Redis, metrics exporters, and application ports should remain private.
Write the ruleset
Install nftables first. On Ubuntu and Debian:
sudo apt install nftables
One caution before you write anything: the ruleset below starts with flush ruleset, which erases every nftables rule on the host the moment the file is applied, including rules installed by UFW, firewalld, Docker, or fail2ban. If another firewall manager owns this host, applying this file silently destroys its rules while the manager still believes they exist. Check for UFW, which ships preinstalled on Ubuntu and is often enabled on VPS images:
sudo ufw status
If it reports active, hand ownership over before continuing. Be aware that disabling the old manager leaves the host briefly unfirewalled, so do not wander off between this step and applying the new ruleset:
sudo ufw disable && sudo systemctl disable --now ufw
On RHEL-family distros, check firewalld instead with sudo systemctl is-active firewalld and, if it is running, disable it with sudo systemctl disable --now firewalld. The same brief exposure applies. A host firewall needs exactly one owner; from here on, that owner is this file.
With that settled, create /etc/nftables.conf:
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
set admin_ipv4 {
type ipv4_addr
elements = { 198.51.100.10 }
}
set admin_ipv6 {
type ipv6_addr
elements = { 2001:db8:200::10 }
}
chain input {
type filter hook input priority 0; policy drop;
iifname "lo" accept
ct state established,related accept
ct state invalid drop
ip protocol icmp accept
meta l4proto ipv6-icmp accept
ip saddr @admin_ipv4 tcp dport 22 ct state new accept
ip6 saddr @admin_ipv6 tcp dport 22 ct state new accept
tcp dport { 80, 443 } ct state new accept
limit rate 5/second counter log prefix "nft-drop: " flags all
}
chain forward {
type filter hook forward priority 0; policy drop;
}
chain output {
type filter hook output priority 0; policy accept;
}
}
Allowing ICMPv6 is important: neighbour discovery and path MTU discovery depend on it. Do not copy IPv4 assumptions into IPv6 rules.
If your administration address changes frequently, use a WireGuard management network or allow SSH broadly with keys and rate limiting rather than maintaining a stale allowlist.
Validate before loading
Before you load anything, replace the example addresses in admin_ipv4 and admin_ipv6 with the address you are actually connecting from. Those are documentation ranges; they match nobody. Confirm your client address with echo $SSH_CONNECTION (the first field is your IP) or with who. If you apply the ruleset with the placeholders left in, the drop policy refuses every new SSH connection and your only way back in is the provider console. Your current session survives on the established-connections rule, which is exactly why you should not close it until a fresh login works.
Applying the file is also the moment flush ruleset runs: whatever rules were on the host before are gone the instant the second command succeeds. If you skipped the firewall manager check in the previous section, do it now, before you load anything.
sudo nft --check --file /etc/nftables.conf
sudo nft --file /etc/nftables.conf
sudo nft list ruleset
Open a second SSH session before closing the first. Once a fresh login works, confirm the site answers on both address families:
curl -4 -I https://example.com
curl -6 -I https://example.com
Both should return an HTTP status line. Then check that private ports really are unreachable from outside. Run the scan from a machine that is not in your admin allowlist, or use a hosted port-scan service:
nmap -Pn -p 22,80,443,3306,6379,9100 203.0.113.5
Replace 203.0.113.5 with your server's address. Ports 80 and 443 should show open; everything else should come back filtered or closed. Port 22 should only look open when you scan from an address inside the admin sets.
Persist across reboot
Enabling the service applies the ruleset immediately and reloads it at every boot, so a broken config file comes back after every restart. It also assumes nftables is the only firewall manager on the host. If UFW or firewalld is still enabled, the two services will fight over the ruleset at boot, and you can end up with no firewall at all, or locked out. Confirm the other manager is disabled (the check from the ruleset section) and that nft --check passes before you enable anything.
sudo systemctl enable --now nftables
sudo systemctl status nftables
The example ruleset only counts drops, via the counter on the log rule. Add counter to any accept rule you want to observe, then read the numbers with sudo nft list ruleset. Keep logging rate-limited so an internet scan cannot fill the disk. Review the rules whenever a new service is deployed; “temporarily” opening a port is how permanent exposure begins.
Add service-specific access
For a private exporter or database, add a named source set rather than a broad port rule. Both pieces go inside /etc/nftables.conf: the set belongs at table inet filter scope, alongside the admin sets, and the rule belongs inside chain input, above the final log rule. Pasted at the top level of the file, either one is a syntax error:
set monitoring_hosts {
type ipv4_addr
elements = { 10.20.0.40 }
}
ip saddr @monitoring_hosts tcp dport 9100 accept
The edit does nothing until you reload the file, so validate and apply it again:
sudo nft --check --file /etc/nftables.conf && sudo nft --file /etc/nftables.conf
Keep the set near the rule that uses it and document who owns the source address. Prefer private or WireGuard addresses over public allowlists. If a service is removed, delete its rule and verify the port with an external scan.
Always keep the provider console open. If a new ruleset locks out SSH, use the console to load the last known-good file or temporarily flush rules while repairing the configuration. Keep dated copies of accepted rulesets and test them after a reboot.
Continue reading