A firewall decides which network traffic may reach a server. Both Ubuntu and CentOS ship a simple front end over the kernel’s packet filter (netfilter): ufw on Ubuntu and firewalld on CentOS. The goal is the same everywhere: deny everything by default and open only the ports the server must serve.
Versions in use on this date. ufw 0.36 on Ubuntu 18.04.3 LTS (iptables backend; nftables is not the default yet) and firewalld 0.6 on CentOS 7.7. Cloud providers add their own network-level rules (security groups) in front of the host.
Where filtering happens
flowchart TB
NET[Internet] --> SG[Cloud security group<br/>or network firewall]
SG --> NIC[Server network interface]
NIC --> NF[Netfilter in the kernel<br/>managed by ufw or firewalld]
NF -->|allowed port| SVC[Service such as nginx on 443]
NF -->|not allowed| DROP[Dropped or rejected]
SVC --> APP[Application]
ADM[Admin SSH from office IP] --> SG
Use both layers: the network firewall for coarse control and the host firewall as a second line, protecting the server even if a security group is mis-set.
ufw on Ubuntu
sudo ufw status verbose
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from 203.0.113.0/24 to any port 22 proto tcp # SSH from the office only
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw limit 22/tcp # rate-limit new SSH connections
sudo ufw enable
sudo ufw status numbered
sudo ufw delete 3
Allow SSH before enabling, or you will lock yourself out of a remote server. ufw is disabled by default on Ubuntu; check sudo ufw status. Application profiles help: sudo ufw app list and sudo ufw allow 'Nginx Full'. Logs go to /var/log/ufw.log with sudo ufw logging low.
firewalld on CentOS
firewalld groups rules into zones (public, internal, trusted, drop) and assigns interfaces to them. It has a runtime configuration (immediate, lost on reload) and a permanent one.
sudo systemctl enable --now firewalld
sudo firewall-cmd --state
sudo firewall-cmd --get-active-zones
sudo firewall-cmd --zone=public --list-all
sudo firewall-cmd --permanent --zone=public --add-service=http
sudo firewall-cmd --permanent --zone=public --add-service=https
sudo firewall-cmd --permanent --zone=public --add-port=8080/tcp
sudo firewall-cmd --permanent --remove-service=dhcpv6-client
sudo firewall-cmd --permanent --zone=public --add-rich-rule='rule family="ipv4" source address="203.0.113.0/24" service name="ssh" accept'
sudo firewall-cmd --reload
Forgetting --permanent means the rule disappears at the next reload or reboot; forgetting --reload means a permanent rule is not active yet. Test a rule first without --permanent, and it self-clears on reload: firewall-cmd --add-port=9000/tcp --timeout=300.
Side by side
| Task | ufw | firewalld |
|---|---|---|
| Default deny incoming | ufw default deny incoming |
zone public already drops non-listed traffic |
| Open web ports | ufw allow 80,443/tcp |
--add-service=http --add-service=https |
| Allow from one subnet | ufw allow from 10.0.0.0/8 to any port 5432 |
rich rule or a dedicated zone with --add-source |
| Show rules | ufw status numbered |
firewall-cmd --list-all |
| Persistence | automatic | --permanent + --reload |
Verify from outside, and from inside
sudo ss -tulpn # what is listening (inside)
nmap -Pn -p 22,80,443,3306 server1 # what is reachable (from another host)
nc -zv server1 3306
A port open in the firewall with no process listening is harmless but confusing; a listening process with no firewall rule is unreachable. Both checks together show the real exposure. Databases (3306, 5432, 6379, 27017) should never be open to the world; bind them to private addresses and allow only the application subnet.
Docker note
Docker writes its own iptables rules, so -p 8080:80 can publish a port even when ufw denies it. Bind published ports to localhost (-p 127.0.0.1:8080:80) or manage the DOCKER-USER chain when you add containers later in this series.
Safe change routine
- Open a second SSH session as a safety net.
- Add the SSH allow rule first, then the others, then enable or reload.
- Test new connections from a separate terminal before closing the old one.
- Keep an out-of-band console (cloud serial console or hypervisor access) for recovery.
- Write the intended open ports in your documentation and review the list quarterly.