
Sensible Default Firewall Rules for a Linux Server
A fresh Linux server ships with no firewall rules at all — every listener you start is instantly reachable from the whole internet. The fix is a short, well-understood baseline: default-deny inbound, allow established traffic, and an explicit allow-list for the services you actually run. This guide builds that baseline in nftables rule by rule, explains the parts everyone gets wrong (ICMP and IPv6), shows the same policy in ufw and firewalld, and covers how not to lock yourself out — plus the Docker port-publishing trap.
08 July 2026
by Jesse Schokker
Firewall
nftables
Security
Linux
Loading...
Answer first: the three-rule philosophy
Every good server firewall is built on the same three decisions, and once you internalise them the rest is detail:
- Default-deny inbound. The policy on incoming traffic is drop. Anything not explicitly allowed never reaches a service — including the service you forgot you were running.
- Allow established and related traffic. Replies to connections your server initiated, and the return half of connections you accepted, flow freely. This one stateful rule is what makes default-deny practical.
- Explicitly allow-list your services. SSH, your web ports, and nothing else until something else genuinely needs to be reachable.
Outbound traffic stays default-allow on most servers — outbound filtering has real uses, but it's an opt-in hardening step, not part of a sensible default (more on that near the end).
This matters because a fresh install gives you none of it. Debian and Ubuntu ship with no active firewall rules; every daemon that binds 0.0.0.0 is exposed to the internet the moment it starts, and honeypot studies show that first probes arrive within minutes of an IP going live. A baseline firewall is a ten-minute job. Here it is.
The baseline ruleset, in nftables
nftables is the native firewalling framework on every current major distro (the iptables command on a modern system is actually a compatibility shim on top of nftables), and one inet table covers IPv4 and IPv6 together — which kills the most common firewall mistake on the spot. Save this as /etc/nftables.conf:
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
# 1. Loopback is always fine
iif "lo" accept
# 2. The stateful core: replies and related traffic
ct state established,related accept
ct state invalid drop
# 3. ICMP — the minimum the internet needs to work
icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept
icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem,
nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
# 4. Your services — the explicit allow-list
tcp dport 22 accept comment "SSH"
tcp dport { 80, 443 } accept comment "HTTP/HTTPS"
udp dport 443 accept comment "HTTP/3 (remove if unused)"
# 5. Optional: see what you're dropping, without flooding the log
limit rate 5/minute log prefix "input drop: "
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}
Apply it with nft -f /etc/nftables.conf and make it persistent with systemctl enable nftables. (On Debian and Ubuntu the nftables package wires exactly this file to that service.)
Now the reasoning, rule by rule.
Rule 1: loopback
Countless things on a Linux system talk to themselves over 127.0.0.1 and ::1 — resolvers, databases, metrics agents. Accepting lo unconditionally is safe (loopback traffic never comes from the wire) and forgetting it produces baffling breakage under a drop policy.
Rule 2: the stateful core
ct state established,related accept is the rule doing most of the work. Connection tracking (conntrack) remembers every flow the firewall has approved; this rule lets the rest of each approved conversation through without re-evaluating the allow-list per packet. related covers protocols that legitimately spawn secondary flows (an ICMP error about your connection, FTP data channels).
ct state invalid drop discards packets that belong to no known flow and can't start a valid new one — out-of-window stray ACKs, mid-connection junk from scanners, leftovers from attacks. Dropping them early is free hygiene.
One honest caveat: conntrack itself is state, and state can be exhausted — that's exactly what protocol-family DDoS attacks target. For a normal server the defaults plus SYN cookies are fine; if you run a very high-connection-rate service, raising net.netfilter.nf_conntrack_max belongs on your tuning list.
Rule 3: ICMP, done right
The most persistent piece of firewall folklore is "block ping, block ICMP, it's safer." It isn't — it breaks the internet's control plane:
destination-unreachable(and in it, IPv4's fragmentation-needed) is how Path MTU Discovery works. Drop it and connections through any smaller-MTU path (VPNs, tunnels, some transit) hang mysteriously on large transfers — the classic "SSH works but SCP stalls" bug.packet-too-bigis the IPv6 equivalent, and IPv6 is stricter: routers never fragment in IPv6, so PMTUD is mandatory. Blanket-dropping ICMPv6 breaks IPv6, full stop.- Neighbour discovery (
nd-neighbor-solicit,nd-neighbor-advert,nd-router-advert) is IPv6's ARP. Drop it and your server loses its default gateway when the neighbour cache expires. echo-request(ping) is worth allowing on a server: it costs nothing meaningful — your address is discoverable by scanners regardless — and you will want to ping your own box while debugging. If ping floods worry you, rate-limit it (icmp type echo-request limit rate 10/second accept) rather than dropping it.
The baseline above allows exactly the control messages the protocols require, and nothing else — no timestamps, no redirects, no router solicitations you don't need inbound.
Rule 4: the allow-list
This section should be an accurate inventory of what the server is for. Two habits keep it honest:
- Add a
commentto every rule. Six months from now,tcp dport 8472 accept comment "flannel VXLAN"is documentation; a bare port number is archaeology. - When a service is internal, say so in the rule. If PostgreSQL should only be reachable from your app servers on a private network, encode that:
ip saddr 10.0.0.0/24 tcp dport 5432 accept— not a bare port-5432 allow. (Addresses here and below are documentation ranges; substitute your own.)
Rule 5: logging the drops
Full drop-logging on an internet-facing server is a disk-filling machine — background scanning is constant. The limit rate 5/minute sample gives you enough in journalctl -k to notice patterns (and to debug "why can't I reach my new service" moments: if you see the drop logged, the firewall is your problem) without the noise.
The forgotten half: IPv6 parity
The historic failure mode: a careful iptables ruleset for IPv4, and ip6tables never touched — leaving every service wide open over IPv6 on dual-stack machines. Surveys of that era regularly found v6 exposure people didn't know they had.
The baseline above is immune by construction: an nftables inet table applies every rule to both families at once, and the ICMPv6 rules give v6 the specific control messages it needs. If you use ufw or firewalld instead, both also manage v6 alongside v4 by default. The rule of thumb survives any tooling choice: every firewall decision you make must answer for both address families. If your server has a AAAA record, test your services over v6 after applying rules, not just v4.
Rate-limiting SSH (and where fail2ban fits)
Port 22 will receive brute-force attempts forever; with key-only authentication those attempts are noise, but throttling them cheaply is still worthwhile. nftables can do per-source rate limiting natively with a dynamic set — replace the plain SSH rule with:
set ssh_ratelimit {
type ipv4_addr
flags dynamic
timeout 10m
}
set ssh_ratelimit6 {
type ipv6_addr
flags dynamic
timeout 10m
}
tcp dport 22 ct state new add @ssh_ratelimit { ip saddr limit rate 4/minute } accept comment "SSH v4, per-IP throttle"
tcp dport 22 ct state new add @ssh_ratelimit6 { ip6 saddr limit rate 4/minute } accept comment "SSH v6, per-IP throttle"
Each source address gets four new SSH connections per minute; the fifth is silently dropped until its rate falls. This is stateless of intent — it doesn't know a failed login from a successful one — which is why fail2ban remains complementary: it reads the auth log and bans addresses that actually fail authentication. Rate limit in the firewall, ban on evidence with fail2ban, and require keys; the combination ends the SSH-noise problem.
The same baseline in ufw and firewalld
If your distro's convention is a frontend, use the frontend — mixing hand-written rules with a frontend's rules is where firewalls become archaeology. The same policy:
ufw (Ubuntu's default frontend; it drives iptables under the hood, which lands in nftables via the compatibility layer):
ufw default deny incoming
ufw default allow outgoing
ufw limit 22/tcp comment 'SSH, rate-limited'
ufw allow 80/tcp
ufw allow 443
ufw enable
ufw limit applies a built-in brute-force throttle (six connections in thirty seconds per source). ufw handles loopback, conntrack, and sane ICMP defaults for you.
firewalld (the RHEL-family default, nftables-backed since 2018):
firewall-cmd --set-default-zone=public
firewall-cmd --permanent --zone=public --add-service=ssh
firewall-cmd --permanent --zone=public --add-service=http
firewall-cmd --permanent --zone=public --add-service=https
firewall-cmd --reload
firewalld thinks in zones and services rather than rules; the public zone is default-deny with the listed services opened, and ICMP is handled sensibly out of the box.
How not to lock yourself out
Firewall changes on a remote server are the classic self-inflicted outage. The habits that make them boring:
- Syntax-check before applying:
nft -c -f /etc/nftables.confvalidates the file without loading it. - Schedule an automatic rollback before risky changes, then cancel it once you confirm you still have access:
# revert to the known-good ruleset in 3 minutes unless cancelled
systemd-run --on-active=3m --unit=fw-rollback nft -f /etc/nftables.known-good
# ...apply your change, confirm SSH still works, then cancel the rollback:
systemctl stop fw-rollback.timer
And keep a second SSH session open throughout — only save the new ruleset as known-good after a fresh connection succeeds.
- Never test a default-drop policy change without out-of-band access. On a dedicated server that means KVM-over-IP/IPMI console access — our servers include it — which turns a lockout from an incident into a two-minute fix.
- Order matters less in nftables than you fear, but the accept-established rule must precede your drops — and the whole file is applied atomically by
nft -f, so a half-loaded ruleset can't strand you the way sequentialiptablescommands historically could.
The Docker trap
If you run Docker with published ports (-p 8080:80), know this: Docker programs the kernel's NAT and forwarding rules directly, and published ports bypass ufw and your input chain entirely. Your carefully default-deny firewall does not apply to -p-published containers — traffic is redirected before your input chain ever sees it. This is long-standing, documented behaviour, not a bug being fixed next release (Docker Engine 29's native nftables backend is experimental and doesn't change the semantics).
The sanctioned answers:
- Bind ports you don't want public to localhost or a private address:
-p 127.0.0.1:8080:80or-p 10.0.0.5:5432:5432. - Put restrictions in the
DOCKER-USERchain, which Docker guarantees to evaluate before its own rules — that's the supported hook for "firewall my containers". - Treat every
-pwith a bare port as "this is now on the internet", because it is.
Outbound filtering: when it's worth it
A default-accept output policy is the right default — servers legitimately talk outbound to package mirrors, DNS, NTP, APIs, and monitoring. Outbound filtering earns its complexity in specific situations: compliance regimes that require egress control, servers processing untrusted input where you want to blunt reverse shells and data exfiltration, and single-purpose machines whose complete traffic profile is knowable (a database server has no business opening connections to arbitrary internet hosts).
If you adopt it, do it the same way as inbound: enumerate what the server needs (DNS to your resolvers, 80/443 to mirrors, your monitoring endpoint), allow those, default-drop the rest, and log the drops for a week before enforcing — the log will teach you what you forgot.
Frequently asked questions
Should I use nftables directly, ufw, or firewalld?
Follow your distro's convention and your fleet's habits. On Debian, native nftables is clean and first-class; on Ubuntu, ufw is the well-trodden path; on RHEL/AlmaLinux/Rocky, firewalld is the supported interface and the docs assume it. All three produce nftables rules in the kernel in the end. The one real anti-pattern is mixing layers — hand-adding nft rules on a box firewalld manages leads to rules that vanish on reload.
Is blocking ping a good security measure?
No. Your server's existence isn't hidden by dropping echo requests — scanners find live hosts by many other means — and you lose a basic diagnostic you'll want yourself. Worse, people who set out to "block ping" usually block ICMP wholesale and break Path MTU Discovery and IPv6 neighbour discovery along the way. Allow echo-request (rate-limited if you like) and the error/control messages; drop the rest.
Do these rules protect me from DDoS attacks?
Partially, and it's important to be precise about which part. A default-deny ruleset removes attack surface (junk aimed at closed ports dies cheaply) and rate limits blunt small connection floods. But no on-host rule helps once an attack saturates your uplink — the packets have already traversed the wire your rules run behind. Volumetric attacks are stopped upstream or not at all, which is why our network puts always-on DDoS mitigation and a self-service edge firewall in front of your port.
I applied a firewall and something broke. How do I debug it?
Check the drop log first: journalctl -k | grep "input drop" (with the baseline's logging rule). If the broken traffic appears there, you're missing an allow rule — note the port and protocol from the log line. If it doesn't appear, the firewall isn't your problem; look at the service binding (ss -tlnp) and, for IPv6-reachable hosts, confirm you tested the same address family that's failing.
Deploying on Serverside
On our dedicated servers, the on-host ruleset in this guide is one of two firewall layers: every server also gets a self-service firewall at the network edge, so traffic to ports you never serve is dropped upstream before it touches your machine — alongside always-on DDoS mitigation on our ASN 55285 network. And if a firewall change ever does lock you out, included KVM-over-IP console access means recovery is minutes, not a support ticket. Provisioning is sub-minute across Ubuntu, Debian, AlmaLinux/Rocky, RHEL and more.
Next in this series: choosing your rule tooling in iptables vs nftables, the wider threat picture in DDoS attacks explained, and identifying what's actually hitting your server in capturing and analysing a DDoS with Wireshark.

About the author
Jesse SchokkerCo-founder & CTO, Serverside.com
Jesse is the co-founder and CTO of Serverside.com, where he leads the engineering behind the company's bare-metal cloud — from the ASN 55285 backbone to sub-minute server provisioning. He writes about dedicated servers, operating systems, and running production workloads on bare metal.


