A firewall is the cheapest security control you will ever configure. It takes ten minutes, it costs nothing, and it turns “every service on this machine is exposed to the internet” into “two ports are exposed and everything else is invisible”. This guide covers ufw on Debian and Ubuntu, firewalld on Red Hat family systems, and the two situations where a correctly configured firewall still will not protect you.
What a firewall is on Linux
The filtering itself happens in the kernel. Everything you type is a front end that writes rules into it. Knowing which layer you are looking at saves a lot of confusion when two tools disagree about what the rules are.
| Layer | What it is | You touch it when |
|---|---|---|
| Netfilter | The kernel packet filtering framework | Never, directly |
| nftables / iptables | The rule syntax the kernel accepts | Debugging, or writing rules no front end supports |
| ufw | Friendly front end, Debian and Ubuntu | Day to day, on Ubuntu |
| firewalld | Zone based front end, RHEL, Fedora, Rocky, Alma | Day to day, on Red Hat family |
Run one front end, not two. If firewalld is running, do not also install ufw. They both write to the same kernel tables and the result is rules that appear and disappear depending on which service restarted last.
First: do not lock yourself out
The classic mistake is enabling a deny-by-default firewall over SSH without allowing SSH first. The connection drops mid-command and the machine is now unreachable. Two habits prevent it.
- Always add the SSH rule before you enable the firewall, and read it back before pressing enter.
- Know how you would get in without SSH. On a cloud VPS that is the provider’s web console; on hardware it is a keyboard and monitor. If you do not have either, do not touch firewall rules remotely.
A useful safety net for risky changes is a scheduled rollback. Before you start, queue a job that flushes the firewall in ten minutes, and cancel it once you have confirmed you still have a working session. See cron and scheduled tasks for the mechanics.
ufw on Debian and Ubuntu
The complete setup for a typical web server is five commands. Note the order.
# See where you are starting from
sudo ufw status verbose
# Deny everything inbound, allow everything outbound
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow SSH BEFORE enabling. This is the step people skip.
sudo ufw allow OpenSSH
# Web traffic
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Now turn it on
sudo ufw enableufw allow OpenSSH uses an application profile, a small file shipped by the package that says “OpenSSH means port 22/tcp”. List the profiles available to you with sudo ufw app list. If you moved SSH to another port, the profile is wrong for you and you want sudo ufw allow 2222/tcp instead.
Narrowing rules down
An open port is open to the whole internet unless you say otherwise. Anything that does not need to be public should be restricted by source address.
# Postgres, but only from one application server
sudo ufw allow from 203.0.113.20 to any port 5432 proto tcp
# SSH from the office range only
sudo ufw allow from 198.51.100.0/24 to any port 22 proto tcp
# Rate limit SSH: blocks an address after 6 connections in 30 seconds
sudo ufw limit ssh
# Explicitly block a nuisance address
sudo ufw deny from 192.0.2.55Rules are evaluated in order and the first match wins, so a broad allow placed above a specific deny makes the deny useless. Use sudo ufw insert 1 deny from 192.0.2.55 to put a rule at the top.
Removing rules
# Show rules with index numbers
sudo ufw status numbered
# Delete by number (numbers shift after each delete, so re-check between deletes)
sudo ufw delete 3
# Or delete by repeating the rule
sudo ufw delete allow 80/tcp
# Start over completely
sudo ufw resetTurn on logging while you are testing with sudo ufw logging on. Blocked packets then appear in the journal tagged [UFW BLOCK], which answers “is the firewall the reason this is not connecting?” in one search.
firewalld on Red Hat family systems
firewalld organises rules into zones. A zone is a named trust level, and every network interface is assigned to exactly one. On a single-interface server you will spend all your time in one zone, usually public.
| Zone | Default behaviour | Typical use |
|---|---|---|
drop | Drops everything inbound, no reply | Hostile networks |
block | Rejects inbound with an ICMP error | Rarely used |
public | Deny by default, a few services allowed | Internet facing servers |
internal / home | More permissive | Trusted LAN interfaces |
trusted | Allows everything | Private interconnects only |
# What is active right now
sudo firewall-cmd --state
sudo firewall-cmd --get-active-zones
sudo firewall-cmd --list-all
# Allow services by name
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
# Or a raw port
sudo firewall-cmd --permanent --add-port=8080/tcp
# Nothing above takes effect until you reload
sudo firewall-cmd --reloadThat last line is the single biggest source of firewalld confusion. There are two rule sets: runtime and permanent. A command without --permanent changes the running firewall and is lost on reboot. A command with --permanent changes the saved configuration and does nothing until you reload. Adding a rule permanently and then wondering why the port is still closed is the standard rite of passage.
The runtime-first workflow is actually the safer one for remote changes, because a mistake is undone by a reboot. Test in runtime, then promote:
# Runtime only, disappears on reboot
sudo firewall-cmd --add-port=8080/tcp
# Happy with it? Save the current runtime set permanently
sudo firewall-cmd --runtime-to-permanentFor source-restricted rules, firewalld uses rich rules:
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" \
source address="203.0.113.20" port port="5432" protocol="tcp" accept'
sudo firewall-cmd --reloadService names come from XML definitions in /usr/lib/firewalld/services/. Run firewall-cmd --get-services to see the full list, which is long and covers most common software.
Check what is actually listening
A firewall is your second line of defence. The first is not listening on a public address in the first place. Before congratulating yourself on a tidy rule set, look at what the machine is offering:
sudo ss -tulpnRead the Local Address column. 127.0.0.1:5432 means the database only accepts connections from the machine itself and no firewall rule is needed at all. 0.0.0.0:5432 means it is listening on every interface, and only your firewall is standing between it and the internet. Where a service supports it, bind it to localhost and let the firewall be the backup rather than the only control. Networking basics covers reading that output in more detail.
Docker publishes ports past your firewall
This one catches experienced people, so it is worth stating plainly. When you publish a container port with -p, Docker writes its own rules directly into the kernel’s NAT table, and those rules are consulted before ufw’s. Your ufw policy is not consulted, not overridden, just bypassed.
The practical result is that you can have ufw status showing a neat deny-by-default policy with only ports 22, 80 and 443 open, run a container with -p 5432:5432, and have your database reachable from the internet. Both things are true at once and nothing warns you.
There are two fixes, and the first is better:
- Bind the published port to localhost.
-p 127.0.0.1:5432:5432instead of-p 5432:5432. The container is reachable from the host and from other containers on the same network, and from nowhere else. Do this for every port that does not need to be public. - Filter in the DOCKER-USER chain. Docker deliberately leaves this chain empty and consults it first, so rules you add there are honoured. It is raw iptables, not ufw syntax, and it needs to be reapplied at boot.
# Verify the problem: this shows Docker's rules, which ufw status will not
sudo iptables -L DOCKER -n -v
# Block outside traffic to a published port, allowing one source
sudo iptables -I DOCKER-USER -p tcp --dport 5432 -s 203.0.113.20 -j ACCEPT
sudo iptables -I DOCKER-USER -p tcp --dport 5432 -j DROPNote the order: the -I flag inserts at the top, so the second command ends up above the first. Whichever approach you take, verify from outside the machine rather than trusting the configuration. See getting started with Docker for more on port publishing.
Cloud security groups are a different firewall
If your server is on AWS, Azure, GCP, DigitalOcean or similar, there is a second firewall you do not administer from inside the machine. It goes by several names — security group, network security group, cloud firewall — and it filters traffic before it ever reaches your network interface.
| Host firewall (ufw / firewalld) | Cloud security group | |
|---|---|---|
| Where it runs | On the machine | On the provider’s network |
| Configured from | The shell | Provider console or API |
| A misconfiguration | Can lock you out of SSH | Can also lock you out of SSH |
| Sees container traffic | Bypassed by Docker’s rules | Yes, it is upstream of everything |
| Survives reinstalling the OS | No | Yes |
Two consequences follow. First, when a port refuses to open, check both layers before assuming your rule is wrong — traffic must be permitted by the security group and the host firewall. Second, because the security group sits upstream of the kernel, it is the reliable place to shut off a Docker-published port you cannot otherwise contain.
Run both. The security group protects you from mistakes inside the machine; the host firewall protects you from mistakes in the console, and travels with the server if you migrate it.
When a port will not open
| Symptom | Likely cause | Check |
|---|---|---|
| Connection times out, no response | Packet dropped by a firewall | sudo ufw status verbose, then the security group |
| Connection refused, immediately | Nothing is listening on that port | sudo ss -tulpn |
| Works locally, not remotely | Service bound to 127.0.0.1 | Local Address column of ss |
| firewalld rule added, no effect | Permanent rule not reloaded | sudo firewall-cmd --reload |
| Rules vanish after reboot | Runtime rule never made permanent | sudo firewall-cmd --runtime-to-permanent |
| Port open despite a deny policy | Docker published it | sudo iptables -L DOCKER -n |
Test from outside the machine, not from the machine itself. A quick curl from another host, or an online port checker, tells you what the internet sees. curl and wget covers the syntax.
A sensible default
For a typical single-purpose web server, this is the whole policy:
- Deny inbound by default, allow outbound.
- SSH open, rate limited, and restricted by source address if you have a fixed one.
- Ports 80 and 443 open to everyone.
- Everything else — databases, caches, admin interfaces, metrics endpoints — bound to localhost or reached over an SSH tunnel or private network.
- Container ports published to
127.0.0.1unless the public genuinely needs them. - The same policy mirrored in the cloud security group.
That is a very small attack surface for ten minutes of work, and it does not need revisiting until the server’s job changes.
Related reading
- Securing a new server — the wider hardening checklist this fits into
- Getting started with Docker — port publishing and the docker group
- Networking basics —
ss,ipand diagnosing connections - systemctl — starting, enabling and inspecting the firewall service
- ssh — keys, tunnels and hardening the one port you leave open
- sudo — every command on this page needs it
