·10 min read·Updated Sep 2, 2026

nftables Firewall on a VPS: A Practical Baseline Ruleset That Won't Lock You Out

A working nftables baseline for Linux VPS: inet tables, IPv6-safe ICMP rules, SSH allowlists, rate limiting, Docker conflicts, persistence and lockout recovery.

Most VPS images ship with either no firewall at all or a distro wrapper (ufw, firewalld) that hides what is actually happening in the kernel. That's fine until you need to debug a dropped packet, run Docker, or write a rule the wrapper doesn't support. This guide gives you a complete, production-usable nftables baseline for a single Linux VPS, explains every line, and covers the two things that break real deployments: IPv6 and Docker.

The short answer

For a typical VPS you want one table inet filter with a default-drop input chain, connection tracking, loopback accepted, the ICMP/ICMPv6 types that keep the network working, an allowlist for SSH, and explicit accepts for the services you actually publish. Everything else drops silently.

#!/usr/sbin/nft -f

flush ruleset

table inet filter {
    set ssh_allow {
        type ipv4_addr
        flags interval
        elements { 203.0.113.10, 198.51.100.0/24 }
    }

    chain inbound {
        type filter hook input priority filter; policy drop;

        # Connection tracking first — cheapest and matches most packets
        ct state vmap { established : accept, related : accept, invalid : drop }

        # Loopback is trusted, anything claiming to be lo from outside is not
        iif lo accept
        iif != lo ip daddr 127.0.0.0/8 drop
        iif != lo ip6 daddr ::1 drop

        # ICMPv6 is mandatory for IPv6 to work at all
        icmpv6 type { destination-unreachable, packet-too-big, time-exceeded,
                      parameter-problem, nd-neighbor-solicit, nd-neighbor-advert,
                      nd-router-solicit, nd-router-advert } accept
        icmpv6 type echo-request limit rate 20/second accept

        # IPv4 ICMP: keep PMTU and traceroute usable
        icmp type { destination-unreachable, time-exceeded, parameter-problem } accept
        icmp type echo-request limit rate 20/second accept

        # Management
        tcp dport 22 ip saddr @ssh_allow accept

        # Public services
        tcp dport { 80, 443 } accept

        # WireGuard, if you run it
        udp dport 51820 accept

        # Rate-limited logging so a scan can't fill the disk
        limit rate 5/minute burst 10 packets log prefix "nft-in-drop " level info
    }

    chain forward {
        type filter hook forward priority filter; policy drop;
    }

    chain outbound {
        type filter hook output priority filter; policy accept;
    }
}

Save this as /etc/nftables.conf (Debian/Ubuntu) and load it — but read the lockout section before you do.

Why inet instead of separate ip and ip6 tables

The inet family applies one rule set to both IPv4 and IPv6. This matters more than it sounds. The single most common firewall failure on VPS hosting is a carefully written IPv4 rule set combined with a wide-open IPv6 stack. Your provider hands you a /64, your service binds to ::, and the port that "isn't reachable" is fully reachable over IPv6. Scanners do find these — they harvest addresses from DNS, certificate transparency logs and leaked logs rather than brute-forcing the address space.

Using inet means a rule like tcp dport { 80, 443 } accept covers both protocols, and a default policy drop closes both. Where a rule is protocol-specific (an ip saddr match), nftables simply won't match the other family, which is why the SSH allowlist above only covers IPv4. If you manage the box over IPv6, add a second set:

set ssh_allow6 {
    type ipv6_addr
    flags interval
    elements { 2001:db8:1234::/48 }
}

and a matching tcp dport 22 ip6 saddr @ssh_allow6 accept rule.

Don't block ICMP

Dropping all ICMP is cargo-cult hardening. Blocking ICMP type 3 code 4 (fragmentation needed) breaks path MTU discovery, which produces the classic symptom of "SSH connects but hangs on large output" or TLS handshakes that stall on specific networks. Blocking ICMPv6 breaks neighbour discovery, meaning IPv6 stops working entirely on some networks after the neighbour cache expires.

Echo requests are the only part worth restricting, and even that buys almost nothing: your IP is already in your provider's published ranges, and a filtered port still reveals a live host to anyone doing a TCP scan. Rate limit echo instead of dropping it, so you keep ping as a diagnostic tool.

Ordering and performance

nftables evaluates rules in order within a chain, so put the highest-hit-rate matches first. The ct state rule handles nearly every packet of an established connection, which means an SSH session or an HTTPS transfer costs one rule lookup instead of ten. Dropping invalid early also discards out-of-window and malformed segments before they reach anything else.

For long allowlists or blocklists, always use sets rather than one rule per address. Sets use hash or interval lookups instead of linear evaluation, so a 10,000-entry set is not meaningfully slower than a 10-entry one. Adding an element at runtime is a single command:

nft add element inet filter ssh_allow { 192.0.2.55 }

Use flags interval when the set holds CIDR ranges, and flags dynamic, timeout when the kernel itself will populate it.

Rate limiting brute force without fail2ban

nftables can maintain its own dynamic blocklist, which is lighter than a log-parsing daemon and reacts instantly:

set bruteforce {
    type ipv4_addr
    flags dynamic, timeout
    timeout 1h
    size 65535
}

chain inbound {
    ...
    ip saddr @bruteforce drop
    tcp dport 22 ct state new \
        update @bruteforce { ip saddr limit rate over 4/minute burst 5 packets } drop
    tcp dport 22 ip saddr @ssh_allow accept
    ...
}

The update statement adds the source address to the set only when its rate of new SSH connections exceeds the threshold, then drops that packet; the earlier ip saddr @bruteforce drop rule handles subsequent packets for an hour. This throttles noise, but it is not authentication. If you accept password logins, rate limiting only slows an attacker down. The real fix is keys-only access and gating the port, which is covered in more detail in the guide on SSH hardening that actually reduces risk.

A stronger pattern for a single-admin server: don't expose port 22 to the internet at all. Bind sshd to a WireGuard interface address and remove the public accept rule. See the self-hosted WireGuard setup for the interface and NAT side of that; on the firewall side you then need iifname "wg0" tcp dport 22 accept and nothing on the public interface.

Docker will bypass your input chain

This trips up almost everyone. When you publish a container port with -p 8080:80, Docker inserts a DNAT rule in the nat prerouting chain. The packet is translated and then routed to the container, so it traverses the forward hook, not input. Your beautiful default-drop input chain never sees it.

Two things follow:

  1. A policy drop on your forward chain does not stop Docker traffic either, because Docker installs its own filter rules with its own priority and uses iptables-nft under the hood. You will see table ip filter with DOCKER, DOCKER-USER and DOCKER-ISOLATION chains alongside your table inet filter in nft list ruleset.
  2. The supported place to filter container ingress is the DOCKER-USER chain, which Docker creates but never touches.

The simplest and most robust answer on a single VPS is to stop publishing ports to 0.0.0.0 entirely. Bind them to loopback:

ports:
  - "127.0.0.1:8080:80"

and put a reverse proxy on the host in front. Then your host firewall regains full authority, because only the proxy's ports 80/443 are exposed and those do go through the input chain.

If you genuinely need a container reachable directly, add rules to DOCKER-USER with iptables (or nft against table ip filter), and remember that DOCKER-USER is IPv4-only unless you have enabled IPv6 in the daemon config, in which case you need the ip6tables equivalent too.

Not locking yourself out

Loading a bad rule set over SSH is the classic way to lose a server. nftables applies a file atomically, which is good, but atomic also means the mistake takes effect instantly.

Three habits prevent most incidents:

Syntax-check first. nft -c -f /etc/nftables.conf parses without applying. It catches typos, not logic errors.

Arm a dead man's switch. Because your input chain's policy comes from the rule set itself, flushing the ruleset returns the kernel to accept-everything:

systemd-run --on-active=300 --timer-property=AccuracySec=1s \
    /usr/sbin/nft flush ruleset

Load your new rules, confirm you can open a new SSH session (an existing session survives on connection tracking and proves nothing), then cancel the timer with systemctl stop run-*.timer or just let it fire and reload the working file.

Know your out-of-band path. Any competent provider gives you serial or VNC console access to the KVM instance. Verify that it works before you need it. On IronBalkans VPS instances you get full root on a KVM guest plus console access, so a firewall mistake is a five-minute fix rather than a reinstall — but only if you've checked the console works while you're not panicking.

Persistence across reboots

  • Debian/Ubuntu: put the rule set in /etc/nftables.conf and systemctl enable --now nftables. The unit runs nft -f /etc/nftables.conf at boot.
  • RHEL/Rocky/Alma: the nftables service reads /etc/sysconfig/nftables.conf, which typically includes files from /etc/nftables/. If firewalld is installed and enabled, disable it first — two managers writing to the same kernel subsystem produces rules you didn't write.
  • Never rely on nft add rule commands typed interactively. They vanish at reboot. Edit the file, then reload it.

Keep the file in version control alongside your other configuration. A firewall you can diff is a firewall you can audit.

Debugging: counters, tracing and logs

Add counter to any rule to see whether it's matching:

tcp dport 443 counter accept

Then nft list ruleset shows packet and byte counts. nft -a list ruleset prints rule handles, which you need to delete a specific rule at runtime.

For harder problems, nft monitor trace combined with a temporary meta nftrace set 1 rule shows a packet's full path through every chain, including tables installed by Docker or the distro. Turn it off immediately afterwards; it is verbose and expensive.

Logging deserves a decision, not a default. log prefix "nft-in-drop " is genuinely useful during the first days of a deployment and for spotting a misconfigured service, but on a privacy-focused host it also means you are persistently recording source IPs of everyone who touches your box. If you don't intend to act on those logs, rate-limit them hard or drop the log statement and rely on counters instead. The same reasoning applies if you run a Tor onion service, where the sensible configuration is to publish nothing on the public interface at all and let the firewall enforce that.

What a host firewall does not do

Be honest about the threat model:

  • It doesn't stop volumetric DDoS. Packets are dropped after they've already consumed your uplink and, in the input hook, after some kernel processing. Filtering at 10 Gbit/s of inbound junk has to happen upstream of your VPS.
  • It doesn't inspect application traffic. A default-drop policy with 443 open still forwards SQL injection and credential stuffing to your application.
  • It doesn't hide the host. Filtered ports are still evidence of a live machine, and TLS certificates, DNS records and HTTP headers leak far more than a port scan.
  • It doesn't protect against a compromised process. Your output chain is policy accept, so anything running as any user can phone home. Egress filtering is possible in nftables (match on meta skuid or specific destinations), but on a general-purpose server it usually breaks package managers and ACME renewals faster than it stops an attacker.
  • It doesn't affect what your provider or upstream can observe. Firewall rules are enforced inside your guest; they say nothing about traffic metadata visible on the network path.

FAQ

Should I use ufw or firewalld instead? If you want a two-command firewall on a simple server, ufw is fine — it writes nftables rules underneath on modern distros. Drop to raw nftables when you need sets, vmaps, custom hooks, inet semantics you control, or coexistence with Docker rules you can actually read.

Do I still need fail2ban? Only if you want log-based detection for application-layer abuse (repeated 401s on a web app, mail auth failures). For SSH connection flooding, an nftables dynamic set is simpler and faster.

Is changing the SSH port worth it? It cuts log noise substantially and nothing else. Automated scanners find services on non-standard ports routinely. Treat it as a convenience, not a control.

Do I need to open port 25? Only if you actually run an MTA. Outbound port 25 and inbound mail have their own set of problems well beyond the firewall — see the notes on running a mail server on a VPS.

Takeaway

One inet table, default drop on input and forward, connection tracking first, ICMP left functional, sets for anything longer than a couple of addresses, and services bound to loopback behind a proxy wherever possible. Test with nft -c, arm a flush timer before you reload, and verify console access exists. That configuration is boring, auditable, and it will survive both a reboot and a Docker upgrade — which is more than most VPS firewalls manage.


title: "nftables Firewall on a VPS: A Practical Baseline Ruleset That Won't Lock You Out" description: "A working nftables baseline for Linux VPS: inet tables, IPv6-safe ICMP rules, SSH allowlists, rate limiting, Docker conflicts, persistence and lockout recovery." publishedAt: "" updatedAt: "" author: "IronBalkans" tags:

  • "nftables"
  • "firewall"
  • "linux"
  • "vps"
  • "security" draft: false

Written by IronBalkans. Last reviewed Sep 2, 2026.