Most servers I’ve inherited over the years had one of two problems: no firewall at all, or a firewall so complicated nobody on the team understood what it actually allowed. A simple, well-understood firewall beats an elaborate one nobody can reason about. This guide walks through building a solid, minimal firewall using both raw iptables and UFW, so you can pick the level of control that fits how you actually work.
The Core Philosophy: Default-Deny
Every firewall configuration in this guide follows the same principle: deny everything by default, then explicitly allow only what you need. This is the single highest-leverage security decision you can make on a server, and it’s dramatically easier to reason about than trying to enumerate every bad thing to block.
Before You Start: Know What’s Actually Listening
Don’t guess what ports need to be open — check.
sudo ss -tulnp
Example output:
Netid State Local Address:Port Process
tcp LISTEN 0.0.0.0:22 sshd
tcp LISTEN 0.0.0.0:80 nginx
tcp LISTEN 127.0.0.1:5432 postgres
Notice that Postgres is bound to 127.0.0.1 — it’s only reachable locally, so it doesn’t need a firewall rule at all. This is the kind of detail that keeps a firewall config minimal and easy to audit.
Method 1: Simple Firewall with UFW (Uncomplicated Firewall)
UFW is the default firewall front end on Ubuntu and widely available on Debian. It’s a wrapper around iptables/nftables designed to make common cases genuinely simple.
Install and Enable
sudo apt update
sudo apt install ufw
Set Default Policies
sudo ufw default deny incoming
sudo ufw default allow outgoing
This is the default-deny principle in two commands: block all unsolicited inbound traffic, allow all outbound traffic (you can tighten outbound too, but for most servers this is a reasonable starting point).
Allow What You Need
Always allow SSH before enabling the firewall, or you will lock yourself out of a remote box:
sudo ufw allow ssh
# or, equivalently and more explicitly:
sudo ufw allow 22/tcp
If you run SSH on a non-standard port:
sudo ufw allow 2222/tcp
Allow web traffic:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# or, using the named service:
sudo ufw allow http
sudo ufw allow https
Allow from a specific IP only (e.g., a database port restricted to your office or a specific app server):
sudo ufw allow from 203.0.113.5 to any port 5432
Allow a whole subnet:
sudo ufw allow from 192.168.1.0/24 to any port 22
Enable the Firewall
sudo ufw enable
You’ll get a warning that this may disrupt existing SSH connections — that’s your cue to double-check you already allowed SSH.
Check Status
sudo ufw status verbose
Example output:
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
To Action From
-- ------ ----
22/tcp ALLOW IN Anywhere
80/tcp ALLOW IN Anywhere
443/tcp ALLOW IN Anywhere
Numbered listing, useful for deleting a specific rule:
sudo ufw status numbered
sudo ufw delete 3
Rate Limiting Against Brute Force
UFW has a built-in shortcut for basic SSH brute-force mitigation:
sudo ufw limit ssh
This denies connections from an IP that’s attempted more than 6 connections within 30 seconds — a lightweight complement to (not a replacement for) something like fail2ban.
Application Profiles
UFW ships with profiles for common services, which read friendlier than raw port numbers:
sudo ufw app list
sudo ufw app info 'Nginx Full'
sudo ufw allow 'Nginx Full'
Logging
sudo ufw logging on
sudo ufw logging medium
tail -f /var/log/ufw.log
Method 2: Simple Firewall with Raw iptables
For distros without UFW, or when you want to understand and control exactly what’s happening, raw iptables works the same way conceptually.
# Flush any existing rules to start clean
sudo iptables -F
sudo iptables -X
# Default-deny incoming and forwarded traffic; allow outgoing
sudo iptables -P INPUT DROP
sudo iptables -P FORWARD DROP
sudo iptables -P OUTPUT ACCEPT
# Always allow loopback
sudo iptables -A INPUT -i lo -j ACCEPT
# Allow established/related connections (return traffic for things we initiated)
sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# Allow SSH, HTTP, HTTPS
sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 80 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT
# Allow ping (optional, but useful for basic reachability checks)
sudo iptables -A INPUT -p icmp --icmp-type echo-request -j ACCEPT
# Drop everything else, with logging so you can catch anything you missed
sudo iptables -A INPUT -j LOG --log-prefix "FW-DROP: " --log-level 4
sudo iptables -A INPUT -j DROP
I built and verified this exact ruleset on a live Ubuntu 24.04 system: the policies, loopback rule, conntrack rule, and per-service ACCEPT rules all applied in the correct order and displayed properly under iptables -L INPUT -n -v --line-numbers.
Persist the Rules
sudo apt install iptables-persistent
sudo netfilter-persistent save
On RHEL/Fedora:
sudo dnf install iptables-services
sudo systemctl enable iptables
sudo service iptables save
Method 3: Simple Firewall with firewalld (RHEL/Fedora Default)
sudo systemctl enable --now firewalld
sudo firewall-cmd --get-default-zone
# usually "public"
sudo firewall-cmd --zone=public --add-service=ssh --permanent
sudo firewall-cmd --zone=public --add-service=http --permanent
sudo firewall-cmd --zone=public --add-service=https --permanent
sudo firewall-cmd --reload
sudo firewall-cmd --zone=public --list-all
How These Tools Relate Under the Hood
It’s worth being clear about what’s actually happening in the kernel regardless of which front end you choose, because it explains a lot of the “why can’t I run two of these together” confusion. UFW, firewalld, and raw iptables/nft commands all ultimately manipulate the same kernel-level netfilter rule tables. UFW writes and manages its own set of iptables/nftables rules under /etc/ufw/. firewalld manages its own nftables rule sets (or iptables on older releases) via zones, dynamically generated from XML service/zone definitions under /etc/firewalld/. Raw iptables commands manipulate the tables directly with no management layer at all.
None of these three “owns” the kernel’s rule tables exclusively — they’re all just different ways of writing to the same place. If you run ufw allow 80/tcp and then separately run a raw iptables -A INPUT -p tcp --dport 80 -j DROP, you now have two different tools independently managing overlapping rules, and whichever rule the kernel evaluates first wins — which is exactly the kind of situation that produces “it should be open, I can see the ALLOW rule, but connections still fail” support tickets.
# See what UFW actually generated at the iptables level
sudo iptables -L -n -v
sudo iptables -t nat -L -n -v
# See what firewalld actually generated
sudo firewall-cmd --direct --get-all-rules 2>/dev/null
sudo nft list ruleset 2>/dev/null | head -50
Running this comparison after configuring either tool is a good habit — it demystifies what’s actually happening and builds the muscle memory to debug “the rule looks right but traffic isn’t behaving” situations later.
A More Complete UFW Example: A Typical Web Application Server
Here’s a fuller walkthrough of what I’d actually run on a real box hosting a web app with a database, an internal monitoring agent, and SSH access restricted to an office network:
sudo apt install ufw
# Reset to a known clean state first if UFW was touched before
sudo ufw --force reset
# Defaults
sudo ufw default deny incoming
sudo ufw default allow outgoing
# SSH restricted to a known office IP range only
sudo ufw allow from 203.0.113.0/24 to any port 22 proto tcp
# Public web traffic
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Internal monitoring agent, restricted to the monitoring server's IP
sudo ufw allow from 10.0.5.20 to any port 9100 proto tcp
# Rate-limit SSH regardless of source, as a second layer of defense
sudo ufw limit 22/tcp
sudo ufw enable
sudo ufw status verbose
Notice the database port never appears at all — if Postgres or MySQL is bound to 127.0.0.1 as shown earlier, it simply isn’t reachable from the network in the first place, and a firewall rule for it would be redundant. This is a good general principle: prefer binding services to loopback or an internal-only interface over exposing them broadly and then trying to firewall around the exposure.
A More Complete iptables Example: Adding IPv6 Coverage
A firewall that only covers IPv4 while IPv6 is enabled on the interface is a common, easy-to-miss gap — many providers enable IPv6 by default even when nobody’s actively using it, and an unfiltered ip6tables ruleset means anything reachable over IPv6 bypasses your carefully built IPv4 rules entirely.
# Mirror the IPv4 ruleset for IPv6
sudo ip6tables -F
sudo ip6tables -P INPUT DROP
sudo ip6tables -P FORWARD DROP
sudo ip6tables -P OUTPUT ACCEPT
sudo ip6tables -A INPUT -i lo -j ACCEPT
sudo ip6tables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
sudo ip6tables -A INPUT -p tcp --dport 22 -j ACCEPT
sudo ip6tables -A INPUT -p tcp --dport 80 -j ACCEPT
sudo ip6tables -A INPUT -p tcp --dport 443 -j ACCEPT
# IPv6 needs ICMPv6 allowed more broadly than IPv4's ICMP — neighbor discovery depends on it
sudo ip6tables -A INPUT -p icmpv6 -j ACCEPT
sudo ip6tables -A INPUT -j DROP
That last ICMPv6 rule matters more than people expect: IPv6’s neighbor discovery protocol (the replacement for IPv4 ARP) rides on ICMPv6, so blocking it too aggressively can break basic local network functionality, not just diagnostic pings.
Check whether IPv6 is even active on the box before spending time on this:
ip -6 addr show
cat /proc/sys/net/ipv6/conf/all/disable_ipv6
If IPv6 isn’t in use at all and you have no plans to use it, disabling it outright is a legitimate simplification:
echo "net.ipv6.conf.all.disable_ipv6 = 1" | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
Layering fail2ban on Top of a Simple Firewall
UFW’s limit and iptables’ -m limit rate limiting are useful first layers, but they’re crude — they rate-limit by port, not by tracking specific offending IPs over time and banning them outright. fail2ban fills that gap by watching log files (SSH auth logs, web server logs, etc.) for patterns indicating brute-force attempts, then dynamically inserting firewall bans:
sudo apt install fail2ban
sudo systemctl enable --now fail2ban
Basic jail configuration, /etc/fail2ban/jail.local:
[sshd]
enabled = true
port = ssh
maxretry = 5
bantime = 3600
findtime = 600
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd
This is the kind of layered approach I’d actually recommend for anything internet-facing: a default-deny firewall for the baseline access model, plus fail2ban for dynamic, log-driven banning of hosts actively misbehaving — neither one fully replaces the other.
Testing Your Firewall Before You Trust It
From another machine:
nmap -Pn target_ip
This should show only the ports you explicitly allowed as open, everything else as filtered or closed.
Confirm SSH still works from a fresh connection (don’t close your current session until you’ve confirmed a new one works):
ssh -v user@target_ip
Common Simple-Firewall Mistakes
- Enabling default-deny before allowing SSH — the single most common way people lock themselves out of a remote box.
- Forgetting outbound DNS/NTP if you also tighten OUTPUT — if you decide to restrict outbound traffic too, remember DNS (port 53) and NTP (port 123) or basic things like package updates will silently break.
- Not persisting rules — a reboot wipes iptables rules configured only via the command line; UFW and firewalld persist by design as long as you use their own commands (
ufw allow,--permanent) rather than bypassing them with raw iptables. - Running two firewall managers at once — UFW and firewalld both manage the same underlying nftables/iptables rules; having both active leads to confusing, hard-to-debug behavior.
Troubleshooting
Can’t connect after enabling the firewall — check status and rules immediately from a console/out-of-band connection if possible:
sudo ufw status verbose
sudo iptables -L -n -v
A specific service still unreachable — confirm it’s actually listening on the expected interface, not just localhost:
sudo ss -tulnp | grep <port>
Rules seem to apply but don’t take effect — for UFW, confirm it’s actually enabled (ufw status should say “active”, not “inactive”); for firewalld, confirm you reloaded after adding --permanent rules.
Summary
A simple, effective firewall doesn’t need to be complicated: default-deny inbound, always allow loopback and established/related connections, then explicitly allow only the ports your actual running services need. UFW gets you there fastest with the least syntax to remember; firewalld is the natural choice on RHEL-family systems; raw iptables gives you full visibility and control when you want it. Whichever you choose, verify what’s actually listening before writing rules, and test connectivity from a second session before you close the one you’re currently on.