Learn › The Network Stack › How Packets Find Their Way
nft - a hands-on Linux lab on a real virtual machine.
A firewall inspects Layer 3 and Layer 4 headers and makes a decision per packet. nftables is how Linux does it.
The server namespace in this lab accepts every packet that arrives. Ping it, it answers. Request a page, it serves one. Probe a port where nothing listens, and its TCP stack still politely replies that the port is closed.
Nothing is checking, and not because someone configured allow everything. No firewall exists on that machine at all. There is a difference between a door someone propped open and a wall that was never built, and in a few minutes you will be able to see that difference in a single command.
This lesson is a build-test-teardown run. You will build a firewall from nothing, watch it kill ping while HTTP keeps answering on the same wire, then remove every trace. One packet type dies, another lives, and you are the one who decides which.
The lab uses the two-namespace topology you know from this module:
[client] 10.0.1.10 <--------> 10.0.1.20 [server]
The server runs an HTTP service on port 80. Your firewall goes on the server, because the server is the machine being protected.
The black boxes below are a practice terminal: a safe sandbox that checks the one command each step teaches. The real VM comes at the end. The lab user is student on the host filter-lab, and three helpers wrap the long namespace commands: server-nft runs nft inside the server namespace, send-ping and send-curl send traffic from the client. Your progress in the lab is tracked automatically, so type commands naturally.
Tab completion works on command names: type send- and press Tab twice to see both helpers, or serv and Tab to get server-nft. The nft keywords after it, like ruleset, you type yourself.
A firewall is code that reads the headers of every packet entering or leaving a machine and delivers a verdict: let it through, silently discard it, or refuse it with a reply. Rules you write drive the verdict, and they match the header fields you already know from this track: source and destination address, protocol, port.
Where does that code run? Inside the Linux kernel, the packet path has fixed checkpoints called netfilter hooks. nftables is the rule engine that plugs into those checkpoints, and nft is the command that talks to it.
nftables organizes everything in three layers:
1. Table - a container that groups related chains. A table has a family (which protocols it sees) and a name. 2. Chain - an ordered list of rules, attached to a hook point such as input. 3. Rule - one match-and-verdict statement. If the packet is an ICMP echo request, drop it.
Think of a filing cabinet. The table is the cabinet, the chain is a folder inside it, and the rules are the pages in the folder, read top to bottom.
One paragraph of history, because it explains the tool you have. The netfilter team has replaced Linux firewalling three times: ipchains arrived with kernel 2.2 in 1999, iptables with kernel 2.4 in 2001, and nftables arrived in kernel 3.13, released January 2014, the same team retiring its own tool. Debian has shipped it as the default since 2019, so nft is what you are expected to speak on a modern box.
Two of those three were the work of one person. Rusty Russell wrote ipchains and then netfilter and iptables, having decided the previous design was worth throwing away rather than patching. Twice. nftables came from the same project years later, under Patrick McHardy and Pablo Neira Ayuso, on the same reasoning: a smaller kernel engine with the cleverness moved out into userspace. The command you are about to run is the third draft of an idea its own authors kept refusing to settle for.
Every firewall job starts the same way, on a machine you built or a machine you inherited: list what is already there. The subcommand prints every table, chain, and rule in one screen.
server-nft list ruleset
prompt: student@filter-lab:~$ answer: server-nft list ruleset ||| sudo ip netns exec server nft list ruleset output: hint: The helper is server-nft, and the subcommand that prints everything is: server-nft list ruleset
Nothing. Not an empty table, not a default policy. Nothing at all.
That blank screen says more than it looks. nftables only inspects traffic where a chain is attached to a kernel hook, and this kernel has no chains, so no filtering code runs anywhere on the packet path. Zero rules does not mean allow everything as a decision. It means the question is never asked.
There is a performance edge hiding in that: a host with an empty ruleset pays no per-packet filtering cost at all. The moment you attach your first chain, every packet through that hook starts paying the toll, rules or no rules. Keep that in mind for the predict question coming up.
Rules need a chain to live in, and chains need a table. Cabinet first.
server-nft add table inet filter
The word after add table is the family: which protocols the table can see. inet means both IPv4 and IPv6 in one table. The narrower families are ip (IPv4 only) and ip6 (IPv6 only), and there are special-purpose ones like arp and bridge.
filter is only a name, and you could call the table anything. nftables has no built-in tables or chains at all: every one on a Linux box was created by someone, which is exactly why your first listing was empty. The name filter is a convention inherited from iptables, which did ship fixed built-in tables with that name.
prompt: student@filter-lab:~$ answer: server-nft add table inet filter ||| sudo ip netns exec server nft add table inet filter output: hint: The shape is: server-nft add table <family> <name>. Family inet, name filter.
No output. In the Unix tradition, silence is success: the kernel state changed and there was nothing else to report. Every add, flush, and delete in this lesson behaves the same way. Errors, when they come, are loud, and this lesson has a whole step for reading them.
The inet family is worth a sentence of respect. Under iptables this object took two tools: iptables wrote the IPv4 rules and ip6tables wrote a separate IPv6 copy, and on real machines the two copies drifted apart. inet exists so one ruleset can cover both stacks.
Prove the table exists. There is a listing narrower than the whole ruleset:
server-nft list tables
prompt: student@filter-lab:~$ answer: server-nft list tables ||| sudo ip netns exec server nft list tables output: table inet filter hint: Same helper, and the subcommand is list tables, plural.
One line, two facts: the family and the name. Read the family every time you list tables, not just the name. Two tables can share a name in different families and be complete strangers to each other.
That detail turns into a famous error message later in this lesson. Hold on to it.
Now the folder inside the cabinet: a chain named input, attached to the kernel's input hook. Read the anatomy before you run it:
server-nft add chain inet filter input '{ type filter hook input priority 0 ; }'
nat and routeThe brace block is what makes this a base chain, one wired to a hook so packets flow through it on their own. A chain created without the braces is a regular chain: a container other rules can jump to, which no packet ever visits by itself.
The quotes around the brace block are not decoration. To bash, an unquoted ; ends one command and begins another, which leaves a lone } trying to start a command of its own. Bash reads the whole line before it runs any of it, so it refuses the line entire and nft is never called. You will meet that exact refusal before the lesson ends.
prompt: student@filter-lab:~$ answer: server-nft add chain inet filter input '{ type filter hook input priority 0 ; }' ||| server-nft add chain inet filter input "{ type filter hook input priority 0 ; }" ||| server-nft add chain inet filter input '{ type filter hook input priority 0; }' ||| server-nft add chain inet filter input "{ type filter hook input priority 0; }" output: hint: server-nft add chain inet filter input, then the brace block in single quotes: '{ type filter hook input priority 0 ; }'
Silent again, and this is the moment the machine actually changed. Attaching a base chain is what registers filtering code at the hook. Before you pressed Enter, packets entered the server unexamined. From now on, every packet addressed to it walks your chain.
List the whole ruleset again and compare it with what you typed.
server-nft list ruleset
prompt: student@filter-lab:~$ answer: server-nft list ruleset ||| sudo ip netns exec server nft list ruleset output: table inet filter { chain input { type filter hook input priority filter; policy accept; } } hint: The same listing command as the first step: server-nft list ruleset
Look closely at the middle line. You typed priority 0 and the listing says priority filter. nft did not save your text anywhere. It compiled your command, sent it to the kernel over netlink, and this listing is decompiled back from what the kernel actually holds. On the way out, well-known priority numbers get printed as names: 0 is filter, and other named marks on the same line include raw at -300, mangle at -150, and security at 50.
Priorities are signed, and negative runs earlier. The kernel's own machinery sits on this number line too: connection tracking runs at -200, well before your chain, so by the time a packet reaches priority 0 it already carries connection state.
The other new words are policy accept. The policy is the chain's verdict for packets that match none of its rules. It belongs to the chain, not to the system. policy drop would turn this into a default-deny firewall, the standard posture for serious perimeters, and also the fastest way to lock yourself out of a remote machine. This lesson keeps accept.
The chain exists, holds zero rules, and its policy is accept. Take a position before moving on.
>>> Every packet now walks the chain and leaves through policy accept, so the server behaves exactly as before while the kernel quietly does new work per packet. If you picked the first answer: a hook fires whether the chain has rules or not, so emptiness is not invisibility, and this is why attaching the first base chain is the moment filtering cost begins. If you picked the third: blocked-by-default is what policy drop would give you. Accept is the default precisely so that adding a chain never breaks a machine by itself.
A rule is a match plus a verdict. Remember from the ICMP lesson: an Echo Request is ICMP type 8, and the reply is type 0. The rule you are about to write names the request by its field name instead of its number:
server-nft add rule inet filter input icmp type echo-request drop
prompt: student@filter-lab:~$ answer: server-nft add rule inet filter input icmp type echo-request drop ||| sudo ip netns exec server nft add rule inet filter input icmp type echo-request drop output: hint: The shape is: server-nft add rule <family> <table> <chain> <match> <verdict>. The match is icmp type echo-request, the verdict is drop.
Silence, so it landed. add rule appends to the end of the chain. Its sibling insert rule prepends instead, and position matters because evaluation runs top to bottom and the first matching verdict wins.
One nuance separates the two verdicts you will use most, and it is worth carrying for life. A drop is final for the packet, everywhere. An accept is only final for this chain: if another base chain sits on the same hook at a later priority, an accepted packet still has to survive that one too. Accept means I have no objection. It does not mean the packet is through.
Read the ruleset one more time and find your rule in it.
server-nft list ruleset
prompt: student@filter-lab:~$ answer: server-nft list ruleset ||| sudo ip netns exec server nft list ruleset output: table inet filter { chain input { type filter hook input priority filter; policy accept; icmp type echo-request drop } } hint: The listing command you have used twice already: server-nft list ruleset
There it sits, one page inside the folder inside the cabinet. Every ICMP echo request that reaches the input hook will now match it and die.
Notice what the listing does not show: whether the rule has ever matched anything. nftables counts nothing by default. Add the counter keyword to a rule and its listing grows a live packets and bytes readout, which is how engineers answer the question is this rule even firing. A listed rule proves presence, never traffic.
From the client you are about to send three pings to 10.0.1.20, and the server will drop each request at its input hook. Commit to the client's experience before you look.
>>> Silence, then a loss summary. Drop means discard and say nothing, so the client sends, waits out its timeout, and learns only that no answer came. If you picked Destination Unreachable, that is exactly what the reject verdict would produce, and the difference between the two is a decision you will make as an engineer in a few steps. If you picked an explanation from the server, no such mechanism exists: a dropped packet generates no reply of any kind, which is the point of drop.
Run the test you just predicted.
send-ping
prompt: student@filter-lab:~$ answer: send-ping ||| send-ping 3 ||| sudo ip netns exec client ping -c 3 -W 2 10.0.1.20 output: PING 10.0.1.20 (10.0.1.20) 56(84) bytes of data.
--- 10.0.1.20 ping statistics --- 3 packets transmitted, 0 received, 100% packet loss, time 4005ms hint: The helper that pings the server from the client is send-ping.
Read the summary line like an engineer. 3 packets transmitted, 0 received, 100% packet loss, and the elapsed time is longer than the sending took: three requests a second apart is two seconds, then ping sits out the -W 2 timeout waiting on a reply that never comes. The successful run earlier finished the moment the last reply landed. Silence costs you the full timeout, every time.
Now the part most people get wrong. The server received all three packets. They crossed the wire perfectly, entered the server's stack, and were executed at your chain, after delivery. A dead link and a drop rule print identical ping statistics, and the way to tell them apart is the capture skill from the watching-traffic lesson: capture at the receiver, and a drop shows requests arriving with no replies leaving, while a dead link shows nothing arriving at all.
Same wire, same server, different protocol.
send-curl
prompt: student@filter-lab:~$ answer: send-curl ||| sudo ip netns exec client curl -s --max-time 3 http://10.0.1.20/ output: Hello from the server namespace hint: The helper that sends an HTTP request from the client is send-curl.
HTTP lives. The request is TCP to port 80, matches nothing in your chain, and falls through to policy accept. One protocol dead, one alive, on the same interface, because one rule names one match. That precision is the entire craft of firewalling: the rules say exactly what they say, nothing more.
One honest caveat about that precision. In an inet table, icmp matches IPv4's ICMP, IP protocol number 1. IPv6 ping is a different protocol, ICMPv6, number 58, matched with icmpv6 type echo-request. A dual-stack host with this exact ruleset still answers every IPv6 ping. The inet family lets one table see both stacks; it does not make one rule mean both.
Place what you built on the map this module has been drawing.
A packet from the client arrives at the server's interface and passes the prerouting hook. Then comes the routing decision you studied in the routing lesson: this packet is addressed to me. That hands it to the input hook, where your chain waits. Match means drop. No match means the policy accepts it, and only then does the payload reach a socket.
Now recall the forwarding lesson, where a namespace routed packets between two others. Traffic passing through a router takes a different path: prerouting, then the forward hook, then postrouting. It never visits input. Rules in an input chain are invisible to transit traffic, which is why an engineer who firewalls a router with input rules alone protects the router itself and leaves the network behind it wide open.
drop and reject are both refusals with different manners. Drop discards silently, and the sender burns its full timeout learning nothing. Reject answers immediately with an ICMP error, by default destination unreachable, and reject with tcp reset fakes the reset a closed port would send. The working rule of thumb: drop toward the hostile internet, where silence wastes a scanner's time and confirms nothing exists. Reject inside networks you own, so a misconfigured client fails in milliseconds instead of hanging while someone stares at a spinner.
Your table was created as family inet, name filter. Suppose you now ask for it without naming the family: server-nft list table filter. Take a position.
>>> An error. Leave the family out and nft does not search all families: it assumes ip. There is no filter table in the ip family, the kernel answers ENOENT, and nft prints that errno as text: Error: Could not process rule: No such file or directory. If you picked the first answer, the assumption is the trap: table names are only unique within a family, so nft never gu
Practice Packet Filtering in a real Linux terminal at The Linux Camp. Progress is verified automatically as you type commands on the machine.