How to Set Up a Simple Firewall in Linux: Complete iptables and UFW Configuration Guide

how to setup simple firewall in linux

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

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.

References

Exit mobile version