Skip to content

Writing firewall rules on Linux

Firewall and access control argued for default-deny and least privilege. This article is the part where you actually type the rules, on the machine, and it carries a risk the previous one didn't: a firewall rule applied over SSH takes effect on the packet carrying your keystrokes.

Everything below assumes Ubuntu Server. Read the safety section before running anything on a host you can't walk over to.

Assume every command here can end your session

A default-deny policy applied before an SSH allow rule locks you out instantly and permanently — there's no undo, and no warning.

Three rules, always:

  1. Save the current ruleset first. sudo nft list ruleset > ~/nft-backup.$(date +%F-%H%M).nft — restoring is sudo nft flush ruleset && sudo nft -f ~/nft-backup.….nft.
  2. Arm a rollback before you change anything. sudo systemd-run --on-active=300 --unit=fw-rollback nft flush ruleset gives you five minutes; if you lock yourself out, the ruleset clears on its own and access returns. Cancel it with sudo systemctl stop fw-rollback.timer once you've confirmed the new rules work — from a second, separate SSH session, never from the one you're editing in.
  3. Validate config files before loading them. sudo nft -c -f /etc/nftables.conf parses without applying.

On a cloud VM, learn where the provider's serial console is before you need it.

Three interfaces, one engine

Linux packet filtering is one kernel subsystem, netfilter, with three user-facing tools stacked in front of it:

Tool What it is When to use it
nft (nftables) The current native syntax for netfilter New work, and anything you want to read six months later
iptables The classic syntax. On Ubuntu it's iptables-nft, a translation layer that writes nftables rules underneath Reading existing systems, and tooling that hasn't migrated
ufw A wrapper that generates rules for you Single-purpose servers where the rules are simple

Knowing that iptables on a modern Ubuntu is a shim matters practically: rules added with iptables show up in nft list ruleset, in tables named filter, nat, and so on. Mixing the two tools works but produces a ruleset that's confusing to read. Pick one per host.

Where a packet meets the rules

Netfilter evaluates rules at fixed points — hooks — in the kernel's packet path. Three of them cover nearly everything:

        ┌─────────┐
        │  input  │  ← packets addressed to this host
        └─────────┘
  NIC → routing decision → ┌──────────┐
             ↓             │ forward  │ ← packets passing through (routers, Docker, VPN gateways)
        ┌─────────┐        └──────────┘
        │ output  │  ← packets this host generates
        └─────────┘

This distinction is the source of a great many "my rules do nothing" complaints. Rules on the input hook protect this machine's services. They have no effect on traffic being routed through the machine to somewhere else — that's forward, and it's the hook that matters on a host running containers or acting as a gateway.

A complete, minimal ruleset

sudo nft list ruleset
table inet filter {
    chain input {
        type filter hook input priority filter; policy drop;
        ct state established,related accept
        iif "lo" accept
        ct state invalid drop
        ip protocol icmp accept
        tcp dport 22 accept
        tcp dport { 80, 443 } accept
    }

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

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

inet means the table covers IPv4 and IPv6 together — a single rule set instead of two that drift apart. Forgetting IPv6 is a classic way to leave a port open on a server you believe is locked down, because the service listens on [::] and only the IPv4 rules were written.

Rules are evaluated top to bottom and the first terminal verdict wins, so order is meaning:

ct state established,related accept goes first, and it's what makes the whole thing work. ct is connection tracking: the kernel remembers connections this host initiated and lets their replies back in. Without this line, your outbound DNS query goes out and the answer is dropped by the default policy — the server appears to have no internet access at all. Putting it first also means the overwhelming majority of packets match on rule one instead of walking the whole chain.

iif "lo" accept permits loopback. Services talking to each other over 127.0.0.1 — a web app to its local database, anything reading 127.0.0.53 for DNS — break without it, in ways that look like application bugs.

ct state invalid drop discards packets that don't belong to any tracked connection and don't look like a valid new one.

tcp dport 22 accept comes before anything else you might get wrong.

policy drop on the chain is the default-deny the previous article argued for: anything not explicitly accepted is silently discarded. That silence is a decision — as the connection failure modes article showed, drop gives the client a timeout and reject gives it an immediate refusal. Dropping is the standard choice on an internet-facing host because it tells a scanner nothing; rejecting is friendlier on an internal network where a fast, clear failure saves debugging time.

Adding rules one at a time on a live host:

sudo nft add rule inet filter input tcp dport 5432 ip saddr 10.20.0.0/24 accept

Read it right to left: accept TCP traffic to port 5432, but only from the 10.20.0.0/24 subnet. That source restriction is the least-privilege principle in one clause, and it's the difference between "the database is available to the app servers" and "the database is on the internet."

To make the ruleset survive a reboot, write it to /etc/nftables.conf and enable the service:

sudo nft list ruleset > /etc/nftables.conf
sudo nft -c -f /etc/nftables.conf
sudo systemctl enable --now nftables

The middle command is the validation step. Skipping it means finding out about a syntax error at the next boot, when the whole ruleset fails to load and the host comes up with no firewall at all.

The same thing with ufw

For a server with a handful of rules, ufw is less to get wrong:

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow from 10.20.0.0/24 to any port 5432 proto tcp
sudo ufw enable

sudo ufw allow OpenSSH uses a named application profile from /etc/ufw/applications.d rather than a bare port number, which is worth preferring — it documents intent, and it stays correct if the port changes. Note the ordering: the allow rules are added before enable, so the deny policy never exists without an SSH exception. ufw enable does warn about disrupting SSH and asks for confirmation, but don't rely on a prompt as your safety mechanism.

sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW IN    Anywhere
5432/tcp                   ALLOW IN    10.20.0.0/24
22/tcp (v6)                ALLOW IN    Anywhere (v6)

Read Default: … disabled (routed) carefully — that's the forward hook, and it's off. Which leads directly to the trap that catches more people than every other item in this article combined.

Docker publishes ports straight past ufw

docker run -d -p 5432:5432 postgres:16
sudo ufw status

ufw still says port 5432 is only allowed from 10.20.0.0/24. The container is nevertheless reachable from the entire internet.

This is not a bug in either tool. When you publish a port, Docker writes its own rules into netfilter's nat and forward paths so traffic can reach the container. ufw's rules live on the input hook, governing traffic destined for the host itself — and a packet on its way to a container is forwarded, not input. It never meets the rules you wrote.

Confirm what's actually exposed rather than trusting the firewall's summary:

sudo ss -tlpn | grep -E '0\.0\.0\.0|\[::\]'

Then fix it at the source, by publishing to a specific address instead of every address:

docker run -d -p 127.0.0.1:5432:5432 postgres:16
docker run -d -p 10.20.0.9:5432:5432 postgres:16

The first form makes the database reachable only from the host itself; the second, only via the internal interface. Both are enforced by the bind address, which no firewall ordering issue can undo — and that reliability is why binding narrowly beats filtering broadly whenever you have the choice.

Reading a ruleset you didn't write

sudo nft list ruleset -a
table inet filter {
    chain input { # handle 1
        type filter hook input priority filter; policy drop;
        ct state established,related accept # handle 4
        iif "lo" accept # handle 5
        tcp dport 22 accept # handle 6
        tcp dport 8080 accept # handle 9
    }
}

-a prints handles, which is how you delete a single rule without rewriting the file:

sudo nft delete rule inet filter input handle 9

Counters tell you whether a rule is doing anything at all:

sudo nft add rule inet filter input tcp dport 8080 counter drop
sudo nft list ruleset
        tcp dport 8080 counter packets 1482 bytes 88920 drop

A rule with zero packets after a week is either unnecessary or shadowed by an earlier rule that already accepted the traffic — and shadowed rules are how a ruleset ends up meaning something different from what it appears to say. Counters are the cheapest audit there is.

Practice

  1. In a lab VM with console access, build the minimal ruleset above from an empty table, in the order shown, and verify with nc from another machine after each rule. Then rebuild it with the established,related line last and explain what breaks.
  2. Apply the same policy twice — once with policy drop, once with an explicit reject — and record the exact client-side error each produces. Tie each back to the failure modes article.
  3. Run a container publishing a port, confirm it's reachable from another machine despite a ufw rule that should prevent it, then re-publish bound to 127.0.0.1 and confirm the difference.
  4. Add a counter to every rule in a ruleset on a machine that gets real traffic. Come back a day later and identify any rule that has never matched.
  5. Write the rules for a three-tier setup — public load balancer on 80/443, application servers reachable only from the load balancer's subnet on 8080, database reachable only from the application subnet on 5432 — as three separate rulesets. Then say which of them still needs a forward chain and why.

Exercise 5 is the one worth doing properly, because it forces the question this article started with: which hook does each rule belong on. Get that wrong and you'll write a beautifully specific ruleset that filters nothing.

A correctly written ruleset blocks what it was told to block. That's its limit: traffic matching an allowed rule sails through even when it's part of an active attack, because a firewall only sees what it was configured to look for. Detecting that traffic after it's already been let in is a different layer of defense entirely, and the next article covers it.

Sources