Every production server I have ever inherited had one of three firewall states: wide open because someone was "going to configure it later," locked down so aggressively that monitoring agents couldn't phone home, or configured once in 2019 and never touched again. A host-based firewall is one of the cheapest security controls you can deploy, but it is also one of the easiest to get wrong in a way that takes a box off the network entirely. This guide walks through configuring nftables (and its iptables equivalent, since a lot of production fleets still run it) on a server like sw-infrarunbook-01, with an emphasis on not locking yourself out over SSH at 2am.
Prerequisites
Before touching any firewall rules, make sure you have the following in place. Skipping these is exactly how people end up filing an emergency console-access ticket.
- Out-of-band access to the server — IPMI, a cloud provider's serial console, or a hypervisor console. If your only path in is SSH and you mess up the SSH rule, you are locked out with no recovery path except a reboot into rescue mode.
- Root or sudo access, and a clear picture of which services are actually listening. Run
ss -tulpn
first and write down every port that is genuinely in use — don't guess. - A known list of admin source IPs or CIDR ranges that need SSH access. In this guide I will use 10.20.0.0/24 as the management subnet and 172.16.5.10 as a jump host.
- nftables installed (most modern distros ship it by default; on Debian/Ubuntu it's
apt install nftables
, on RHEL-family it'sdnf install nftables
). If you're still on iptables, decide up front whether you're migrating or maintaining the legacy stack — don't run both nft and iptables-legacy rules against the same interface unless you fully understand how they interact, because in my experience that combination produces the most confusing debugging sessions of anyone's career. - A maintenance window, even a short one. Firewall changes on a live production box should never be a "quick edit," because a typo in a CIDR mask can silently drop an entire subnet's traffic.
Step-by-step setup
I'll build this using nftables as the primary example since it's the direction the ecosystem has been moving for years, then show the iptables equivalent for anyone maintaining older systems.
Start by checking whether nftables is already running and what tables exist:
nft list ruleset
If that comes back empty, you are starting from a clean slate, which is the easiest case. Create a table and a base chain for inbound filtering:
nft add table inet filter
nft add chain inet filter input { type filter hook input priority 0 \; policy drop \; }
Here is the part everyone forgets, and it's the single most common way people lock themselves out: you set the policy to drop before you've added the accept rule for your own SSH session. The moment that chain is loaded with policy drop and no exceptions, your current SSH connection may survive (established connections aren't necessarily killed), but any new connection attempt will fail immediately. So build the essential allow rules in the same breath, or better, stage everything in a file and load it atomically rather than typing commands interactively.
Add the loopback and established-connection rules first — these two lines alone prevent 90% of self-inflicted outages:
nft add rule inet filter input iif lo accept
nft add rule inet filter input ct state established,related accept
Now add SSH access, scoped to your management subnet rather than opened to the world:
nft add rule inet filter input ip saddr 10.20.0.0/24 tcp dport 22 accept
nft add rule inet filter input ip saddr 172.16.5.10 tcp dport 22 accept
If this server is public-facing — say a web node behind a load balancer at 10.30.0.5 — add the application ports scoped to the load balancer's subnet, not to 0.0.0.0/0:
nft add rule inet filter input ip saddr 10.30.0.0/24 tcp dport { 80, 443 } accept
Allow ICMP for path MTU discovery and basic reachability testing — dropping all ICMP is a classic overcorrection that breaks more than it protects:
nft add rule inet filter input ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded } accept
And finally, log dropped packets before they're dropped, so you have something to look at when a legitimate service mysteriously stops working:
nft add rule inet filter input log prefix "nft-drop: " counter drop
For iptables, the equivalent sequence looks like this. Note the order matters just as much — accept rules for SSH and established connections must exist before you set the default policy to DROP:
iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -s 10.20.0.0/24 -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -s 172.16.5.10 -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -s 10.30.0.0/24 -p tcp -m multiport --dports 80,443 -j ACCEPT
iptables -A INPUT -p icmp --icmp-type echo-request -j ACCEPT
iptables -A INPUT -j LOG --log-prefix "iptables-drop: "
iptables -P INPUT DROP
I deliberately set the policy last in the iptables version — with iptables, the default policy takes effect immediately when you set it, so you want every accept rule already appended before you flip it to DROP.
Full configuration example
For anything you intend to keep long-term, don't build rules interactively — write a declarative config file and load it with
nft -f. This is also what makes the ruleset reviewable in code review and reproducible across the fleet. Here's a complete working example for sw-infrarunbook-01, a server running SSH, an internal API on port 8443, and node_exporter for metrics scraping on 9100:
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iif lo accept
ct state established,related accept
ct state invalid drop
# Management access from the jump host and admin subnet
ip saddr 10.20.0.0/24 tcp dport 22 accept
ip saddr 172.16.5.10 tcp dport 22 accept
# Internal API, restricted to the app-tier subnet
ip saddr 10.30.0.0/24 tcp dport 8443 accept
# Prometheus scraping from the monitoring host only
ip saddr 10.20.0.50 tcp dport 9100 accept
# Basic ICMP for diagnostics
ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded } accept
# Rate-limit new SSH connections to blunt brute-force attempts
ip saddr 10.20.0.0/24 tcp dport 22 ct state new limit rate 10/minute accept
log prefix "nft-drop: " counter drop
}
chain forward {
type filter hook forward priority 0; policy drop;
}
chain output {
type filter hook output priority 0; policy accept;
}
}
A few things worth calling out in that file. The
ct state invalid dropline matters more than people give it credit for — it silently drops malformed packets that don't correspond to any real connection state, which cuts down on log noise from scanners. I also leave the output chain on policy accept deliberately; locking down outbound traffic is a legitimate hardening step, but it's a separate project with its own failure modes (package managers, NTP, DNS, log shipping all break if you get it wrong), and bundling it with inbound hardening in one change is how maintenance windows blow past their limits.
Save this as
/etc/nftables.conf(or wherever your distro expects it), and enable it to load on boot:
systemctl enable nftables
systemctl restart nftables
Verification steps
Never trust that a ruleset does what you think it does just because it loaded without errors. Verify from multiple angles.
First, confirm the ruleset actually loaded as written:
nft list ruleset
Second, and this is the step people skip: test from a machine that is not in your allowed source range, and confirm the connection is actually refused. Testing only from an allowed IP tells you the happy path works, not that the restriction is real. From an unauthorized host:
nc -zv -w 5 sw-infrarunbook-01.solvethenetwork.com 22
That should time out or report the connection refused/filtered, not succeed. Then test from an authorized source in 10.20.0.0/24 and confirm SSH still works normally.
Third, watch the counters. nftables tracks packet and byte counts per rule if you add
counterto the rule (as I did on the drop rule above), which makes it trivial to see which rules are actually matching traffic in production:
nft list ruleset -a
watch -n 2 'nft list counters'
Fourth, check the logs for drops that shouldn't be happening. If your monitoring agent, backup job, or log shipper suddenly stops reporting in, this is the first place to look:
journalctl -k -f | grep nft-drop
Finally, and I cannot stress this enough: after any firewall change, open a second SSH session in a separate terminal before closing your first one. If the new session connects fine, you're safe to close the original. If it doesn't, your existing session is your lifeline to fix the mistake — don't close it prematurely.
Common mistakes
I have seen every one of these happen on a production system, usually during what someone described beforehand as "a quick five-minute change."
Setting the drop policy before the accept rules exist. This is the number one cause of SSH lockouts. Always load allow rules for your own access first, or load the entire ruleset as one atomic file with
nft -frather than issuing commands one at a time.
Forgetting the established/related rule. Without it, every reply packet for a connection your server initiated outbound (DNS lookups, package manager updates, outbound API calls) gets treated as a fresh inbound packet and dropped. This produces bizarre, hard-to-diagnose failures where "the server can't reach anything" even though outbound connections were never blocked.
Opening ports to 0.0.0.0/0 "temporarily" and forgetting to narrow it. I have found rules like this dating back years, on servers where nobody remaining at the company remembers why they were added. If a rule needs a broad scope for a debugging session, put a calendar reminder to revert it, or better, don't merge it into the persistent config file at all.
Mixing iptables-legacy and nftables on the same host without understanding the interaction. Many distros now implement iptables commands as a compatibility shim over the nf_tables kernel subsystem (iptables-nft), while others still run the legacy netfilter path. Running both against the same chains produces rules that silently don't do what either tool's output suggests. Check which backend you're actually on with
iptables --versionbefore assuming anything.
Not persisting the ruleset across reboots. A ruleset built interactively with
nft add rulecommands vanishes on reboot unless it's also saved to a config file that loads at boot. I've seen firewalls that worked perfectly for months suddenly vanish after a routine kernel update reboot, because the rules were never actually persisted anywhere.
Restricting outbound traffic too aggressively without mapping dependencies first. Locking down the output chain is good practice, but only after you've inventoried what the server legitimately needs to reach — NTP servers, DNS resolvers, internal package mirrors, log aggregators, monitoring endpoints. Flip output to policy drop without that inventory and you'll spend the rest of the day chasing broken cron jobs and stalled certificate renewals.
No rate limiting on SSH. Even with source-IP restrictions, if that source is a shared jump host or VPN concentrator, a single compromised account attempting rapid connections can still hammer the port. The
limit rateclause in the example ruleset costs nothing and blunts basic brute-force noise.
A host-based firewall is not a substitute for network segmentation, and it won't stop an attacker who already has a foothold with root. What it does well is reduce your exposed surface to exactly the traffic you expect, and give you an audit trail of everything else that showed up uninvited. Treat the ruleset like code — version it, review changes, and test from an unprivileged vantage point before you trust it.
