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-solicitandnd-neighbor-advertthe 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 -6works andip -6 neighshows reachable entries. /etc/nftables.confstarts with#!/usr/sbin/nft -fandflush ruleset, andsudo nft -c -f /etc/nftables.confparses 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
inetfamily,ip protocol icmpcovers 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.