Technology

The nftables Firewall Cookbook for VPS: Rate Limiting, Port Knocking, and IPv6 Rules (Without the Drama)

So there I was, a sleepy Sunday morning, coffee in hand, poking through a small VPS I use for side projects. A handful of weird SSH connection attempts popped up in the logs, then a few aggressive HTTP requests with random query strings—clearly not from my friends. You’ve probably had that moment too: you open your VPS, and there’s a steady drumbeat of the internet keeping your ports warm. It’s not personal; it’s just the background noise of the web. Still, it’s your server, and you want it calm, predictable, and safe.

That’s when nftables clicked for me. It felt like moving from tangled headphone wires to a tidy wireless setup. Powerful, consistent, and way less drama. In this guide, I’ll share a practical, step-by-step nftables cookbook I use on real VPSes—covering per‑IP rate limiting that doesn’t lock out legit users, a simple port knocking flow for when you want stealth without ceremony, and IPv6 rules that “just work.” We’ll keep it friendly and focused, and I’ll sprinkle in real-world notes where it matters. Ready?

Why nftables? And What We’re Going to Build

I remember struggling with iptables rules that felt like a pile of sticky notes. They worked… until the day they didn’t. Nftables brings a cleaner model. One ruleset, one syntax, families that handle both IPv4 and IPv6, and thoughtful features like sets, maps, meters, and timeouts. Think of it like going from a drawer full of mismatched forks to a simple, matching cutlery set. You still cook the meal, but you’re not wrestling your tools every time.

Here’s the vibe of what we’ll build together:

First, a calm baseline: default deny on inbound traffic with only what we need allowed, per-family ICMP configured properly (especially crucial for IPv6), and a clean separation of input, forward, and output behavior. Then, pragmatic rate limiting: per‑IP guards on SSH, sensible protection on web ports, and a few patterns you can adapt to other services. After that, we’ll add a simple port knocking flow. Not as a magic bullet—more like a small velvet rope at a private door. Finally, IPv6 rules that don’t break neighbor discovery, ping, or your sanity.

We’ll also talk about saving, testing, and rolling back safely—because nothing ruins your day like locking yourself out of your own server. Ask me how I know.

A Clean Baseline You Can Trust

Start with a safe apply-and-rollback habit

Before we write a single rule, adopt a ritual: apply changes with a built‑in rollback window. I usually keep a second SSH session open and run something like this when testing:

sudo bash -c '
  nft -f /etc/nftables.conf.new && 
  cp /etc/nftables.conf /etc/nftables.conf.backup && 
  mv /etc/nftables.conf.new /etc/nftables.conf && 
  ( sleep 30; nft list ruleset > /dev/null || nft -f /etc/nftables.conf.backup ) &
'

The idea is simple: if your connection drops and you can’t get back in, that sleeping command will try to restore the previous ruleset. It’s saved me more than once when I was too clever for my own good.

Enable nftables on boot

On most modern distros, it’s straightforward:

sudo apt-get install nftables   # or your distro’s package manager
sudo systemctl enable --now nftables

From here on, you’ll manage your persistent configuration in /etc/nftables.conf. I like to keep a working file, test it, then swap it in.

Baseline ruleset: default deny inbound, sensible outbound

Let’s sketch a sturdy baseline. We’ll use a single inet table, which can hold rules for both IPv4 and IPv6. It’s convenient, but remember that some data types (like address sets) still need v4/v6-specific types.

# /etc/nftables.conf

flush ruleset

table inet filter {
  # Optional: named counters to see traffic
  counter ssh_hits {}
  counter web_hits {}

  # IPv4 and IPv6 allowlists, if you need them later (empty for now)
  set allowed_ssh_v4 {
    type ipv4_addr;
    flags interval;
  }
  set allowed_ssh_v6 {
    type ipv6_addr;
    flags interval;
  }

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

    # Always allow loopback
    iif lo accept

    # Drop obviously broken stuff first
    ct state invalid drop

    # Allow established/related, keeps ongoing connections happy
    ct state established,related accept

    # IPv4 ICMP: allow basic ping and fragmentation needed bits
    ip protocol icmp icmp type { echo-request, echo-reply, time-exceeded, destination-unreachable } accept

    # IPv6 ICMPv6: absolutely essential for neighbor discovery, MTU, etc.
    ip6 nexthdr icmpv6 icmpv6 type {
      echo-request, echo-reply,
      nd-neighbor-solicit, nd-neighbor-advert,
      nd-router-solicit, nd-router-advert,
      packet-too-big, time-exceeded, parameter-problem
    } accept

    # SSH, HTTP/HTTPS will be layered below with rate limits
    # (We’ll add those rules in the next sections.)

    # Example: allow DNS replies if you run a local resolver on UDP/53 (optional)
    # udp dport 53 accept

    # Everything else inbound: drop by default
  }

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

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

This already calms the noise. You’ll still need to explicitly open ports you care about, but the shape is clear: keep what’s working, and say no to the random drive‑bys.

Rate Limiting That Helps (and Doesn’t Bite You)

Here’s the thing about rate limiting: it’s a balancing act. Too strict and you lock out real users during bursts (or yourself when your connection hiccups). Too loose and it’s just decorative. The trick is to be just protective enough without being precious.

Per‑IP SSH guard with meters

For SSH, I like a per‑IP meter on new connection attempts. It lets one person type their password wrong a couple of times without drama, but squashes the fog of bots hammering you from one address.

table inet filter {
  # ... previous content ...

  chain input {
    type filter hook input priority 0; policy drop;
    iif lo accept
    ct state invalid drop
    ct state established,related accept

    # ICMP/ICMPv6 as before

    # SSH allowlist if you use it (optional)
    ip saddr @allowed_ssh_v4 tcp dport 22 accept
    ip6 saddr @allowed_ssh_v6 tcp dport 22 accept

    # Per‑IP SSH rate limit (IPv4)
    tcp dport 22 ct state new meter ssh_v4 { ip saddr limit rate 20/minute burst 20 } counter name ssh_hits accept
    # Per‑IP SSH rate limit (IPv6)
    tcp dport 22 ct state new meter ssh_v6 { ip6 saddr limit rate 20/minute burst 20 } counter name ssh_hits accept

    # If a sender exceeds the limit, this rule catches the overflow
    tcp dport 22 counter drop

    # Web ports (rate limit lightly on new connections, not on in‑flow traffic)
    tcp dport {80, 443} ct state new meter web_v4 { ip saddr limit rate 200/second burst 400 } counter name web_hits accept
    tcp dport {80, 443} ct state new meter web_v6 { ip6 saddr limit rate 200/second burst 400 } counter name web_hits accept

    # If you run UDP services (e.g., DNS), a light limit helps
    # udp dport 53 meter dns_v4 { ip saddr limit rate 50/second burst 100 } accept
    # udp dport 53 meter dns_v6 { ip6 saddr limit rate 50/second burst 100 } accept

    # ... rest as before ...
  }
}

In my experience, these values are friendly. Twenty SSH connection attempts per minute per IP is generous enough for humans and aggressive enough for lazy bots. And for web traffic, remember this limits new connections only. Persistent connections (or already established flows) aren’t touched.

One more practical suggestion: pair network‑level rate limiting with app‑level protections. For example, if your SSH is public, consider using modern auth patterns. I shared my battle‑tested setup in a no‑drama SSH hardening workflow with FIDO2 keys. It’s honestly a joy once it’s in place.

Optional: log overflow without spamming yourself

Sometimes you want to see who’s getting rate‑limited. A small log line is enough—just don’t spam your disks.

# Example: right before the "drop" rule for SSH
limit rate 5/second over tcp dport 22 log prefix "nft-ssh-overflow " flags all level info

If your distro uses rate‑limited kernel logging by default, great. Otherwise, keep the log limit low. You can also pipe kernel logs into something civilized later. If you want a neat path to organized logs, I’ve written about lightweight setups that play nice with Grafana and Loki.

Port Knocking Without the Ceremony

Port knocking sometimes sparks heated debates. I treat it as a light seatbelt—handy on hobby servers or small team boxes with static admin IPs changing occasionally. It’s not a replacement for strong auth; it’s a little curtain you can draw, so the door isn’t obviously there.

Here’s a simple three‑step knock that opens SSH for a limited window per source IP. We’ll do it for both IPv4 and IPv6 with dynamic sets that expire on their own. The flow is: knock A, then knock B within 10 seconds, then knock C within 10 seconds. If you complete the sequence, your IP gets into the “open” set for, say, 30 minutes.

Define the sets

table inet filter {
  set knock1_v4 { type ipv4_addr; flags timeout; timeout 10s; }
  set knock2_v4 { type ipv4_addr; flags timeout; timeout 10s; }
  set knock_open_v4 { type ipv4_addr; flags timeout; timeout 30m; }

  set knock1_v6 { type ipv6_addr; flags timeout; timeout 10s; }
  set knock2_v6 { type ipv6_addr; flags timeout; timeout 10s; }
  set knock_open_v6 { type ipv6_addr; flags timeout; timeout 30m; }

  # ... chains ...
}

Create the knocking sequence rules

We’ll use dynsets to add the source address dynamically when a port gets hit. Choose three high, non‑conflicting UDP or TCP ports that you don’t use for anything else. I’ll use 42111, 42112, 42113 below. If you’re behind a tricky NAT, TCP works more reliably for some clients, but UDP is slightly lighter.

chain input {
  type filter hook input priority 0; policy drop;
  # ... baseline rules above ...

  # Stage 1: hit port 42111 to start
  tcp dport 42111 add @knock1_v4 { ip saddr timeout 10s } drop
  tcp dport 42111 add @knock1_v6 { ip6 saddr timeout 10s } drop

  # Stage 2: hit port 42112 within 10s of stage 1
  tcp dport 42112 ip saddr @knock1_v4 add @knock2_v4 { ip saddr timeout 10s } drop
  tcp dport 42112 ip6 saddr @knock1_v6 add @knock2_v6 { ip6 saddr timeout 10s } drop

  # Stage 3: hit port 42113 within 10s of stage 2; open SSH for 30m
  tcp dport 42113 ip saddr @knock2_v4 add @knock_open_v4 { ip saddr timeout 30m } drop
  tcp dport 42113 ip6 saddr @knock2_v6 add @knock_open_v6 { ip6 saddr timeout 30m } drop

  # SSH open only for IPs that completed knocking or are allowlisted
  tcp dport 22 ip saddr @knock_open_v4 accept
  tcp dport 22 ip6 saddr @knock_open_v6 accept

  # You can still keep per‑IP meters on top, if you like
  tcp dport 22 ct state new meter ssh_v4 { ip saddr limit rate 20/minute burst 20 } accept
  tcp dport 22 ct state new meter ssh_v6 { ip6 saddr limit rate 20/minute burst 20 } accept

  # ... rest of input chain ...
}

A couple of real-world notes. First, the knock rules deliberately drop the knock packets; that’s the point. They don’t reveal anything. Second, you don’t have to remove addresses from earlier stages; timeouts will expire them quickly. Third, write down your chosen ports somewhere safe. I’ve forgotten mine once and spent midnight guessing—a fun game I don’t recommend.

Is port knocking perfect? Of course not. But used lightly and paired with strong SSH auth, it’s a surprisingly effective layer that makes scans much less noisy. It’s also a nice way to keep SSH effectively “closed” most of the time on lab servers.

IPv6 Rules That Just Work

When I first enabled IPv6 on a VPS years ago, I tried to treat it exactly like IPv4. Every five minutes I’d break something subtle and wonder why pings failed or neighbors didn’t resolve. The truth is, IPv6 wants to be understood a little. Once you get the rhythm, it’s actually friendlier.

Let ICMPv6 breathe

IPv6 leans heavily on ICMPv6 for neighbor discovery, router advertisements, and path MTU discovery. Blocking these is like trying to drive with your eyes closed. The baseline we wrote earlier already allows the common types: echo request/reply, neighbor solicit/advertise, router solicit/advertise, packet-too-big, time-exceeded, and parameter-problem. That’s a healthy core.

If you’re curious and like official documentation, the official nftables wiki and the nft(8) man page are excellent to keep handy. I often keep a tab open while I tweak rules.

Don’t forget DNS over IPv6

If your VPS runs a resolver or you need outbound DNS over IPv6, you don’t need a special firewall dance—our outbound policy is accept. But if you host a public DNS service on v6, remember to open UDP/53 (and TCP/53 if needed) with the same per‑IP limits you’d use for v4.

# Example for a public resolver on IPv6
udp dport 53 meter dns_v6 { ip6 saddr limit rate 50/second burst 100 } accept
# For large zone transfers or DNS over TCP
# tcp dport 53 meter dns_tcp_v6 { ip6 saddr limit rate 10/second burst 20 } accept

Services dual‑stacked by default

When you expose a service, remember that many modern clients will try IPv6 first if you publish AAAA records. That’s a win. Just be sure you’ve permitted the corresponding port on IPv6. I’ve helped teams debug “my site is down on mobile data” only to find out IPv6 was half‑open. Consistency is everything.

Save It, Test It, and Sleep Better

Firewall changes are like repairing a plane in flight. You want a plan. In my experience, a few habits make this predictable and calm.

Use a working file and atomic swaps

I keep two files: /etc/nftables.conf (live) and /etc/nftables.work (staging). I edit the work file, validate it with nft -f /etc/nftables.work (which loads the rules into the kernel immediately), then if all’s well I replace the persistent config and restart the service:

sudo nft -f /etc/nftables.work
sudo cp /etc/nftables.work /etc/nftables.conf
sudo systemctl restart nftables

That way, a reboot won’t surprise you. And if you prefer a dry run without applying, you can validate syntax with:

sudo nft -c -f /etc/nftables.work   # -c = check, don't apply

Log a little, trace when needed

If something doesn’t behave, start small. Add a temporary log rule right before a drop. I keep logs rate‑limited to avoid noise. For deeper mysteries, nftables has a built‑in tracer that’s shockingly helpful:

# Enable tracing on a specific packet flow by setting nftrace=1 via iptables/nft
# Or run a live monitor
sudo nft monitor trace

This prints the chain walk for matching packets, line by line. The first time I used it, I found a rule in the wrong chain—one of those forehead‑slap moments that would’ve taken an hour otherwise.

Backups are boring. Perfect.

Every time you’re happy, save a snapshot:

sudo nft list ruleset > ~/nftables-$(date +%F-%H%M).conf

It takes a second and gives you a time machine when you’re experimenting. I keep a few of these in my home directory and rotate them occasionally.

Persistence on boot

Double‑check the boot path once and you’re done. With the service enabled, /etc/nftables.conf will be loaded at startup. If you ever suspect the service didn’t load properly, a quick restart is usually enough:

sudo systemctl status nftables
sudo systemctl restart nftables

If you want to dig deeper into nftables feature details, the official resources are consistently clear and up to date. I’ve linked them above, and they’re worth bookmarking.

Putting It All Together: A Practical Example

To make this concrete, here’s a stitched‑together ruleset that brings the pieces into one file. You will, of course, adapt ports and limits to your world. But this will give you a good starting point that’s both IPv4 and IPv6 aware, with rate limiting and an optional knock flow for SSH.

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

table inet filter {
  counter ssh_hits {}
  counter web_hits {}

  # Optional allowlists
  set allowed_ssh_v4 { type ipv4_addr; flags interval; }
  set allowed_ssh_v6 { type ipv6_addr; flags interval; }

  # Port knocking sets (optional)
  set knock1_v4 { type ipv4_addr; flags timeout; timeout 10s; }
  set knock2_v4 { type ipv4_addr; flags timeout; timeout 10s; }
  set knock_open_v4 { type ipv4_addr; flags timeout; timeout 30m; }
  set knock1_v6 { type ipv6_addr; flags timeout; timeout 10s; }
  set knock2_v6 { type ipv6_addr; flags timeout; timeout 10s; }
  set knock_open_v6 { type ipv6_addr; flags timeout; timeout 30m; }

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

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

    # ICMP (v4)
    ip protocol icmp icmp type { echo-request, echo-reply, time-exceeded, destination-unreachable } accept

    # ICMPv6 (must-have for IPv6 to work properly)
    ip6 nexthdr icmpv6 icmpv6 type {
      echo-request, echo-reply,
      nd-neighbor-solicit, nd-neighbor-advert,
      nd-router-solicit, nd-router-advert,
      packet-too-big, time-exceeded, parameter-problem
    } accept

    # --- OPTIONAL PORT KNOCKING (three-step) ---
    # Choose your knock ports (example: 42111-42113)
    tcp dport 42111 add @knock1_v4 { ip saddr timeout 10s } drop
    tcp dport 42111 add @knock1_v6 { ip6 saddr timeout 10s } drop

    tcp dport 42112 ip saddr @knock1_v4 add @knock2_v4 { ip saddr timeout 10s } drop
    tcp dport 42112 ip6 saddr @knock1_v6 add @knock2_v6 { ip6 saddr timeout 10s } drop

    tcp dport 42113 ip saddr @knock2_v4 add @knock_open_v4 { ip saddr timeout 30m } drop
    tcp dport 42113 ip6 saddr @knock2_v6 add @knock_open_v6 { ip6 saddr timeout 30m } drop

    # SSH allowlist or opened-by-knock
    ip saddr @allowed_ssh_v4 tcp dport 22 accept
    ip6 saddr @allowed_ssh_v6 tcp dport 22 accept

    tcp dport 22 ip saddr @knock_open_v4 accept
    tcp dport 22 ip6 saddr @knock_open_v6 accept

    # Per-IP SSH rate limits (fallback if you keep SSH public)
    tcp dport 22 ct state new meter ssh_v4 { ip saddr limit rate 20/minute burst 20 } counter name ssh_hits accept
    tcp dport 22 ct state new meter ssh_v6 { ip6 saddr limit rate 20/minute burst 20 } counter name ssh_hits accept

    # Web services (HTTP/HTTPS)
    tcp dport {80, 443} ct state new meter web_v4 { ip saddr limit rate 200/second burst 400 } counter name web_hits accept
    tcp dport {80, 443} ct state new meter web_v6 { ip6 saddr limit rate 200/second burst 400 } counter name web_hits accept

    # Optional: DNS server
    # udp dport 53 meter dns_v4 { ip saddr limit rate 50/second burst 100 } accept
    # udp dport 53 meter dns_v6 { ip6 saddr limit rate 50/second burst 100 } accept

    # Logging of overflows (gentle)
    # limit rate 5/second over tcp dport 22 log prefix "nft-ssh-overflow " flags all level info

    # Default catch-all: drop
  }

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

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

That’s a tidy, readable base. The chains tell a story: we accept what’s clearly safe, keep new connections under control, allow the services we mean to expose, and otherwise remain politely closed. A simple life.

Wrap‑Up: Calm Defenses, Happy Services

If there’s a theme to this whole cookbook, it’s this: your firewall should feel boring in the best way. It should quietly keep strangers at bay, treat your real users with respect, and make debugging easier, not harder. Nftables has grown into a tool that lets us do all that without feeling like we’re juggling chainsaws.

We covered a sane baseline with default deny on inbound, friendly ICMP rules (especially for IPv6), and simple counters so you can peek at what’s happening. We added per‑IP rate limits for SSH and web traffic that protect you without getting in your way. We sprinkled in a no‑drama port knocking flow for teams and hobby setups where it fits. And we wrapped with a few comfort habits: working files, atomic swaps, light logging, and trace tools that tell you exactly what nftables is doing.

My parting advice: start small, test with a second session open, and keep a couple of backups around. Once you have a baseline you trust, the rest becomes a gentle rhythm of tiny changes. If you’re hardening SSH, don’t forget to pair the firewall with strong keys and modern auth—your future self will thank you. Hope this was helpful! If you try these patterns on your VPS and run into something odd, keep those logs handy, and you’ll get to the bottom of it quickly. See you in the next post.

Useful references I keep bookmarked

When I’m tweaking nftables, I often keep the official nftables wiki and the nft(8) man page open in a tab. Clear, current, and to the point.

Frequently Asked Questions

Great question. Keep a second SSH session open and use a timed rollback. Apply the new rules in one session and run a background command that restores the old ruleset after 30 seconds if you don’t confirm. It’s as simple as loading the test file with nft -f, then copying it into /etc/nftables.conf only after you confirm you’re still connected.

It depends on your setup. I treat port knocking as a light extra layer, not a replacement for strong SSH keys. It hides your SSH door from casual scans and reduces noise. If your team is comfortable with the quick knock sequence and you pair it with modern SSH hardening, it’s a nice addition—especially for hobby boxes and admin-only services.

They shine at different layers. Nftables rate limiting is fast and simple for curbing obvious floods or brute-force noise. Fail2ban (or similar) reacts to application logs and can block smart patterns over time. I like a light per-IP limit in nftables plus app-layer protections where needed. Start with nftables for the big wins, add fail2ban if your app needs deeper context.