What you will have at the end
By the end of this chapter your slice runs its first safe base ruleset: a default-drop input chain that admits loopback, your established connections, ssh and http, and refuses everything else, applied under a revert that hands your session back if a rule ever goes wrong.
This is the real starting point of the series. Right now the box has no policy at all, so every service answers whoever asks; here you put the drop-by-default policy in front of it and prove, from an outside client, that it exposes only what you listed.
The danger is the reason most people never do this on a remote box: a default-drop input chain in the wrong order cuts the very SSH session you are editing over, and then the box is gone. The whole chapter is built so that cannot happen to you.
- A base ruleset decides what may reach the box at all; the safe shape defaults to drop and then names the few things allowed.
- Order is the safety:
iif "lo" acceptandct state established,related acceptgo first, so loopback and your live session never fall to the policy. - Arm a dead-man revert before you apply, confirm your session in a second terminal, then cancel it; if a rule locks you out, the timer restores access.
- Prove it from outside: an allowed port answers and, once you drop it from the list, the identical request times out, which is the default-drop policy doing its job.
The base ruleset, in the correct order
nftables reads a chain top to bottom and stops at the first terminal verdict, so the rules that keep you connected have to come before the rule that drops. The first rule accepts loopback, because local services talk to each other over lo. The second accepts established and related packets, which is what your current SSH connection already is.
Only after those does the chain start refusing traffic: an explicit drop for invalid packets, then the accept rules for the ports you serve, ssh on 22, web on 80 and 443, and ICMP echo so the host answers a ping. Anything the chain does not name falls through to the policy, which is drop.
Written out, the base ruleset is short, and it is the artifact you keep in version control.
table inet scdemo {
chain input {
type filter hook input priority filter; policy drop;
iif "lo" accept
ct state established,related accept
ct state invalid drop
tcp dport { 22, 80, 443 } accept
ip protocol icmp icmp type echo-request accept
ip6 nexthdr ipv6-icmp accept
}
}
Apply it atomically, under a dead-man revert
Never apply a first drop policy without a way back. Before you load anything that can affect input, arm a background job that removes the demo table after two minutes. If the apply goes wrong and your next command hangs, the timer clears the rules and your session returns. Then load the ruleset with nft -f, which applies the whole file in one atomic transaction, and immediately check that sshd still listens and your shell is still yours before you trust anything else.
sudo nft -f base-firewall.nft

The apply returns exit zero, ss still shows sshd on port 22, and id -un still answers with your user, so the connection held. Once a fresh login in a second terminal also works, you cancel the revert. That is the workflow behind every change in this series: arm, apply, verify from a second session, cancel on success.
Right after the apply, run id -un in your current session and open a second SSH login to the same box. If both work, the base ruleset is safe and you can cancel the revert. If the new login hangs, do nothing: wait out the timer, and the demo table is removed for you so you get back in.
Use it: an allowed port answers, an unlisted one is refused
The honest test is from another machine, not from the box itself, because loopback is always accepted and would tell you nothing. Using an isolated client, a request to an allowed port returns a normal response.
curl -sI http://web-01/

The first request returns HTTP/1.1 200 OK. Now prove the default drop is real: remove tcp dport 80 from the allow list, reload the same file, and run the identical request. It hangs and times out, because nothing in the chain accepts it and the policy drops the packet.
nginx never moved, so the only thing that changed is your rule, which is what makes this a clean proof rather than a coincidence. Put the port back, reload, and the response returns. That contrast is the whole lesson: the ruleset serves exactly what you list and refuses the rest, and you just watched it do both.
One detail matters when other tools are present. This slice also runs ufw, whose own input chain sits on the same hook. When two chains share the input hook, a drop in either one refuses the packet, so your chain can only ever be as permissive as the strictest chain on that hook. That is another reason the test above ran from a separate client: it exercises the real path a remote request takes, rather than the loopback shortcut that every chain waves through.
Read the whole ruleset back
Never assume an edit took effect. Print the live table and read it as the kernel holds it, in rule order, so you can confirm the safety rules sit above the drops.
sudo nft list table inet scdemo

The listing shows policy drop on the chain, loopback and established accepts at the top, the invalid drop, then the port and ICMP allows. Reading it back is how you catch a rule that landed in the wrong place before it costs you anything.
Going further: why ct state does the heavy lifting
The two connection-tracking rules carry more weight than their length suggests. ct state established,related accept near the top is what lets a strict inbound policy still hold a working server together: every reply to a connection the box itself opened, every data channel a protocol negotiates, rides in on that one rule without a port being opened for it. It is the difference between a firewall that filters new connections and one that also breaks half your outbound traffic.
ct state invalid drop is the cheap companion. Invalid packets belong to no tracked connection, which covers a broad class of stray, spoofed and scan traffic, and dropping them before the accept rules keeps them out of the rest of the chain. Neither rule is about a specific port; both are about the state of a connection, which is the model iptables users often meet here for the first time and the reason a stateful ruleset is shorter and safer than a stack of per-port rules.
Frequently asked questions
Why accept established and related traffic first?
Your live SSH session is an established connection. If the chain drops before it reaches an accept for established traffic, your own packets are refused and you lose the box. Accepting established and related traffic near the top keeps every open connection alive.
What does ct state invalid drop protect against?
Invalid packets belong to no tracked connection, which covers many stray, spoofed or scan packets. Dropping them early keeps them out of the rest of the chain. It is a cheap rule that rarely affects legitimate traffic.
Do I still need loopback accept if I allow the service ports?
Yes. Local daemons, health checks and database sockets often talk over 127.0.0.1. Without iif "lo" accept a policy-drop chain can break those local connections even though remote ports look fine.
How do I add a new allowed port without downtime?
Edit the file, add the port to the tcp dport set, and reload with nft -f. The atomic load swaps the ruleset in one step, so there is no window where the old and new rules are both half applied.
What you can do now
You started from a wide-open box and gave it its first policy: a default-drop input chain with the survival rules on top, applied under a dead-man revert, and then proven from an outside client that answers on the ports you allowed and times out on the one you removed. Your slice now exposes only what you listed, and you watched it prove that rather than taking it on faith.
The chain so far treats every source the same and grows a line for every port. The next chapter keeps it short as it scales: named sets for an allowlist you edit in one place, a verdict map that routes by port in a single rule, and a rate limit that caps a flood, all exercised against real traffic: sets, maps and rate limiting.