Skip to main content
RunBook Academy

← All labs in Linux

Lab · intermediate · ~45 min

Lab: Build a basic nftables firewall from scratch

B · Nested virtualisationC · Simulation

Objectives

  • Write an nftables ruleset that allows SSH, HTTP, HTTPS, ICMP and ICMPv6
  • Arm a timed auto-revert before applying a default-drop ruleset
  • Persist the ruleset so it survives a reboot
  • Validate the ruleset with nc and nmap
  • Test a deny rule with an unexpected port

Prerequisites

This lab builds a basic nftables firewall from scratch. By the end you will have written, persisted, and validated the ruleset.

Objective

By the end of this lab, you can:

  • Write an nftables ruleset that allows SSH, HTTP, HTTPS, ICMP and ICMPv6 from any source.
  • Default-deny everything else.
  • Arm a timed auto-revert before the first apply.
  • Persist the ruleset so it survives a reboot.
  • Validate the ruleset with external scans.

Architecture

The host has:

  • One Ethernet interface (eth0).
  • A public-facing role (or testing in a private network).
  • The ability to run nftables.

Tasks

Task 1: Write the ruleset

Create /etc/nftables.d/basic.conf:

#!/usr/sbin/nft -f

flush ruleset

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

        # Allow established/related
        ct state established,related accept

        # Allow loopback
        iif lo accept

        # Allow ICMP (IPv4)
        ip protocol icmp accept

        # Allow ICMPv6 (IPv6) - required, not optional
        meta l4proto ipv6-icmp accept

        # Allow SSH
        tcp dport 22 accept

        # Allow HTTP/HTTPS
        tcp dport { 80, 443 } accept

        # Log and drop everything else
        log prefix "nft-drop: " level warn
    }

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

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

Why there are two ICMP rules

The table is in the inet family, so it filters IPv4 and IPv6 in the same chain. ip protocol icmp is an IPv4 header match. It never matches an IPv6 packet. If it is the only ICMP rule, every ICMPv6 packet falls through to policy drop, and IPv6 stops working on the host.

IPv6 depends on ICMPv6 far more than IPv4 depends on ICMP:

  • Neighbour Discovery replaces ARP. Without nd-neighbor-solicit and nd-neighbor-advert the host cannot resolve the link-layer address of an on-link peer.
  • Router Advertisements drive SLAAC. Block them and the host loses its address and default route when the current lifetime expires.
  • Packet Too Big is the only PMTU signal IPv6 has, because IPv6 routers never fragment. Block it and you get a PMTU black hole: the TCP handshake completes, small requests succeed, and the first large response hangs forever. That symptom is one of the hardest to diagnose in production, because every cheap health check passes.

meta l4proto ipv6-icmp accept is the simple form and is what this lab uses. For a tighter production posture, accept only the ICMPv6 types that must never be dropped:

        # Minimum ICMPv6 set - do not narrow this further
        icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert,
                      nd-router-solicit, nd-router-advert,
                      packet-too-big, time-exceeded,
                      parameter-problem, echo-request,
                      echo-reply } accept

Task 2: Validate the syntax

sudo nft -c -f /etc/nftables.d/basic.conf

If syntax errors, fix and retry.

Task 3: Arm a safety net, then apply the ruleset

A clean parse is not proof that you will still have SSH after the load. nft -c checks syntax; it cannot know that your management network is missing from the allow list. On a remote host, apply a policy drop ruleset only with a timed auto-revert already armed.

# 1. Snapshot the known-good ruleset
sudo nft list ruleset | sudo tee /root/nft-rollback.conf >/dev/null

# 2. Arm the auto-revert BEFORE applying anything
sudo systemd-run --on-active=5min --unit=nft-rollback \
     /usr/sbin/nft -f /root/nft-rollback.conf

# 3. Apply the new ruleset
sudo nft -f /etc/nftables.d/basic.conf
sudo nft list ruleset

Verify the rules appear in the output.

Now prove you still have access. Leave the current SSH session open and open a second, independent session from your workstation. If it connects, disarm the revert:

# 4. Only after a NEW session has connected
sudo systemctl stop nft-rollback.timer

If the new session does not connect, do nothing. In five minutes the timer restores the snapshot and your original session is still there to try again.

Note the snapshot file needs a flush ruleset header to replace rather than append; Task 7 covers that. For the five-minute revert the append is harmless, because the snapshot is a superset of the live rules.

Task 4: Validate the behaviour

From a known-good source (your workstation or another host):

# Substitute your own values before running:
TARGET=server01.example.com
TARGET_V6=2001:db8::1

# Should succeed (open or filtered depending on whether the service is running)
nc -vz "$TARGET" 22
nc -vz "$TARGET" 80
nc -vz "$TARGET" 443
ping -c 3 "$TARGET"
ping -6 -c 3 "$TARGET_V6"     # ICMPv6 echo must work too

# Should be filtered (no response)
nc -vz -w 3 "$TARGET" 23
nc -vz -w 3 "$TARGET" 3306
nc -vz -w 3 "$TARGET" 8080

If a service is not running on the host, nc returns “Connection refused” (closed) instead of timing out (filtered). Start a test service first:

# On the target host, start a simple listener
sudo python3 -m http.server 80 --bind 0.0.0.0 &

For SSH:

sudo systemctl start sshd

For HTTPS (test cert). s_server needs the private key as well as the certificate — with -cert alone it exits immediately:

$ openssl s_server -cert lab.crt -accept 443 -www
Could not find server certificate private key from lab.crt

The port never opens, so the scan in Task 5 reports 443 filtered, and you spend the next twenty minutes debugging a firewall rule that was correct all along:

# Debian/Ubuntu: both files come from the ssl-cert package
sudo apt install -y ssl-cert
sudo openssl s_server \
  -cert /etc/ssl/certs/ssl-cert-snakeoil.pem \
  -key  /etc/ssl/private/ssl-cert-snakeoil.key \
  -accept 443 -www

RHEL family has no snakeoil package; generate a throwaway pair first:

sudo openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
  -subj '/CN=lab.example.com' \
  -keyout /tmp/lab.key -out /tmp/lab.crt
sudo openssl s_server -cert /tmp/lab.crt -key /tmp/lab.key -accept 443 -www

Confirm it is actually listening before you scan from anywhere else — otherwise the scan is testing the wrong thing:

sudo ss -tlnp '( sport = :443 )'

Task 5: External scan

From a known-good source:

nmap -p 22,23,80,443,3306,8080 <target>

Output should show:

  • 22/tcp: open (sshd running)
  • 80/tcp: open (python http server)
  • 443/tcp: open (openssl s_server)
  • 23/tcp: filtered (no listener, firewall drops)
  • 3306/tcp: filtered
  • 8080/tcp: filtered

Task 6: Capture traffic for denied ports

On the target host:

sudo tcpdump -i eth0 -nn -s 0 'host <scanner-ip> and tcp port 23'

From the scanner:

nc -vz <target> 23

The tcpdump output should show SYN with no SYN-ACK or RST - the firewall is silently dropping.

Also confirm IPv6 neighbour discovery still works on the target, because that is what a missing ICMPv6 rule breaks first:

ip -6 neigh show          # entries should be REACHABLE or STALE, not FAILED
nft list ruleset | grep -i ipv6-icmp

Task 7: Persist the ruleset

nft list ruleset prints only table ... { } blocks. It does not print the #!/usr/sbin/nft -f interpreter line or the flush ruleset directive that the shipped /etc/nftables.conf carries. Piping it straight over the file destroys both, and the next boot then adds the rules on top of whatever is already loaded instead of replacing them - duplicated chains, duplicated rules, and counters that do not match what you tested.

Write the header back yourself, check the parse, then install:

{ printf '#!/usr/sbin/nft -f\n\nflush ruleset\n\n'; sudo nft list ruleset; } \
  | sudo tee /etc/nftables.conf.new >/dev/null

# Parse-only. Mutates nothing.
sudo nft -c -f /etc/nftables.conf.new

# Install with a backup of the previous file
sudo install -m 0755 -b /etc/nftables.conf.new /etc/nftables.conf

sudo systemctl enable nftables
sudo systemctl restart nftables
sudo reboot

After reboot, verify the ruleset is loaded:

sudo nft list ruleset | head -20

The rules should be present and applied.

Task 8: Document the ruleset

In /etc/nftables.d/README.md:

# Firewall ruleset

## Allowed services
- SSH (TCP 22) - all sources
- HTTP (TCP 80) - all sources
- HTTPS (TCP 443) - all sources
- ICMP (IPv4) - all sources
- ICMPv6 (IPv6) - all sources; required for ND, SLAAC, PMTUD
- Loopback - all traffic

## Default policy
- INPUT: drop
- FORWARD: drop
- OUTPUT: accept

## Files
- /etc/nftables.d/basic.conf - the ruleset
- /etc/nftables.conf - the loaded copy

## Validation
- External: nc -vz <target> {22, 80, 443} (open), {23, 3306, 8080} (filtered)
- Internal: tcpdump shows SYN-no-reply for filtered

Validation

  • The ruleset is in /etc/nftables.d/basic.conf.
  • It loads with no errors.
  • An auto-revert was armed before the first apply and disarmed only after a new SSH session connected.
  • ICMPv6 is accepted; ping -6 works and ip -6 neigh shows reachable entries.
  • /etc/nftables.conf starts with #!/usr/sbin/nft -f and flush ruleset, and sudo nft -c -f /etc/nftables.conf parses cleanly.
  • It persists across reboot with no duplicated chains.
  • External scans show the expected open and filtered ports.

Cleanup

Disarm any leftover revert, then remove only what this lab created:

sudo systemctl stop nft-rollback.timer 2>/dev/null

# Delete the lab's own tables. Never `nft flush ruleset` here.
sudo nft delete table inet filter 2>/dev/null || true

# Restore the ruleset captured before the lab started
if [ -s /root/nft-rollback.conf ]; then
    sudo nft -c -f /root/nft-rollback.conf && sudo nft -f /root/nft-rollback.conf
fi

sudo rm -f /etc/nftables.d/basic.conf /etc/nftables.conf.new
sudo nft list ruleset          # confirm the host's own rules are back

Stop any test services:

pkill -f "python3 -m http.server"
pkill -f "openssl s_server"

What you learned

  • nftables rulesets are structured as tables and chains.
  • Default policy matters: drop with explicit allows is the safe posture.
  • In the inet family, ip protocol icmp covers IPv4 only. ICMPv6 needs its own rule or IPv6 breaks.
  • Arm a timed auto-revert before applying a default-drop ruleset to a remote host, and test from a second session.
  • External scans are the only validation that matters.
  • Persistence requires a file that still contains the interpreter line and flush ruleset.

Deliverables

  • · A working nftables ruleset
  • · A persisted /etc/nftables.conf file
  • · Test results from external and internal scans
  • · A documented validation procedure

Verification status

Last reviewed
2026-08-09
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.