Firewall basics for servers: nftables, ufw and firewalld
A server firewall should drop every incoming connection except the few services the server exists to provide. Allow SSH before you switch it on, apply the same rules to IPv6, and remember that Docker's published ports go around ufw unless you take extra steps.
How default deny works
The inbound policy is "drop". Then you add a short list of exceptions: replies to connections the server itself opened, loopback traffic, and the ports you serve. Outbound traffic is usually allowed.
Before you start
- Pick one tool: ufw (common on Ubuntu), firewalld (RHEL family, Fedora, SUSE) or plain nftables (Debian's default framework since Debian 10). Running two managers at once gives confusing, conflicting rules.
- Have console access from your hosting provider in case you block yourself.
- List what is listening with
sudo ss -tulpnso you know which ports are really needed (Server hardening). - Allow SSH before enabling the firewall. If you moved SSH to another port, allow that port.
ufw
ufw is installed but disabled by default on Ubuntu. Set the policies, add rules, then enable:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # SSH first
sudo ufw allow proto tcp from any to any port 80,443
sudo ufw enable # asks for confirmation over SSH
sudo ufw status verbose
Useful variations:
# SSH only from one admin address
sudo ufw allow from 203.0.113.10 to any port 22 proto tcp
# rate-limit SSH: denies an address that opens 6 or more connections in 30 seconds
sudo ufw limit 22/tcp
# remove a rule by number
sudo ufw status numbered
sudo ufw delete 3
If you add the address-restricted rule, delete the general 22/tcp rule afterwards, or it still lets everyone in. ufw applies rules to IPv6 by default; check that /etc/default/ufw contains IPV6=yes. With IPV6=no, ufw accepts IPv6 only on loopback.
nftables
On Debian, rules live in /etc/nftables.conf and are loaded at boot by nftables.service. An inet table covers IPv4 and IPv6 together. This example is based on the server ruleset in the nftables wiki:
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
ct state vmap { established : accept, related : accept, invalid : drop }
iifname "lo" accept
# IPv6 neighbour discovery, or IPv6 stops working
icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept
# optional: allow ping, rate-limited
# icmp type echo-request limit rate 5/second accept
# icmpv6 type echo-request limit rate 5/second accept
tcp dport 22 accept
tcp dport { 80, 443 } accept
}
chain forward {
type filter hook forward priority 0; policy drop;
}
}
sudo nft -c -f /etc/nftables.conf # check only, applies nothing
sudo nft -f /etc/nftables.conf # apply
sudo systemctl enable nftables.service
sudo nft list ruleset
Docker, VM and fail2ban hosts. flush ruleset deletes every table, including those made by Docker, libvirt and fail2ban; restart those services after loading. Leave out the forward chain on a host that routes traffic for containers or VMs, because a drop policy there blocks their traffic.
firewalld
firewalld groups rules into zones; interfaces not assigned elsewhere use the default zone, normally public. Upstream's public zone allows only ssh and dhcpv6-client; RHEL installs often add cockpit (the web console). Its default backend is nftables.
sudo firewall-cmd --get-active-zones
sudo firewall-cmd --list-all
sudo firewall-cmd --permanent --add-service=http --add-service=https
sudo firewall-cmd --permanent --remove-service=cockpit # if you don't use it
sudo firewall-cmd --reload
Changes without --permanent apply to the running firewall only and are lost on --reload. That is a handy safety net: make a runtime change, test it from a new session, then save it with sudo firewall-cmd --runtime-to-permanent. To limit SSH to one address:
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="203.0.113.10" service name="ssh" accept'
sudo firewall-cmd --permanent --remove-service=ssh
sudo firewall-cmd --reload
A rich rule without family applies to both IPv4 and IPv6; one with a source address must name a family, so add an ipv6 rule too if you manage over IPv6.
Don't forget IPv6
Many servers get a public IPv6 address automatically. A firewall that only covers IPv4 leaves every service open on IPv6. All three tools above handle both when set up as shown, but check: services that listen on [::] in ss -tulpn are reachable over IPv6, and an external scan should be run against both addresses.
Docker goes around ufw
Docker's documentation says plainly that Docker and ufw are incompatible: traffic to published container ports is diverted in the nat table before it reaches the INPUT chain that ufw uses, so ufw's rules are ignored. With firewalld, Docker puts its bridges into a docker zone with target ACCEPT and adds a policy allowing forwarding from any zone into it. By default, -p 8080:80 publishes on all host addresses, IPv4 and IPv6.
What to do:
- Publish to loopback when only a local reverse proxy needs the port:
-p 127.0.0.1:8080:80, or"127.0.0.1:8080:80"in Compose. Remote hosts then cannot reach it. Databases and caches in containers usually need no published port at all; put them on a shared Docker network instead. - Filter in DOCKER-USER (iptables backend). Docker evaluates it before its own rules. See the example below.
- Don't edit the rules Docker creates. Docker's nftables backend is still marked experimental; with it Docker does not set a default drop policy or enable IP forwarding for you.
To let only one subnet reach published ports (replace eth0 with your external interface; -I inserts at the top, so the ACCEPT added second ends up first):
sudo iptables -I DOCKER-USER -i eth0 ! -s 192.0.2.0/24 -j DROP
sudo iptables -I DOCKER-USER -m state --state RELATED,ESTABLISHED -j ACCEPT
Repeat with ip6tables for IPv6, and save the rules so they survive a reboot.
More in Containers.
Ports that should never be public
| Service | Default port | Why |
|---|---|---|
| Docker daemon API | 2375 / 2376 | 2375 is plain text with no authentication. Anyone who can reach the API can get root on the host. Keep the local socket, or use SSH contexts. |
| Redis | 6379 | Designed for trusted clients only; one FLUSHALL deletes everything. |
| Elasticsearch | 9200, 9300 | Elastic: never expose an unprotected node to the public internet. |
| MySQL, MariaDB | 3306 | Upstream MySQL listens on all interfaces by default. |
| PostgreSQL | 5432 | Defaults to localhost; don't widen it without a firewall rule. |
| MongoDB | 27017 | MongoDB says to allow only trusted clients. |
| Admin panels, dashboards | varies | Hosting panels, web consoles, phpMyAdmin: VPN, SSH tunnel or IP allowlist only. |
Application-specific advice is in Redis and Valkey, MySQL and MariaDB, PostgreSQL and MongoDB and Elasticsearch.
Check from outside
Rules on the server only show what you meant to do, and every open port adds to your attack surface. Scan from another machine you control, against both addresses:
nmap -Pn -p1-65535 --open 203.0.113.20
nmap -6 -Pn -p1-65535 --open 2001:db8::20
Only the ports you chose should appear. Scan only systems you are responsible for. For regular scanning, see Vulnerability scanning.
Sources
- ufw(8) manual page, Ubuntu
- Firewalls, Ubuntu Server documentation
- Simple ruleset for a server, nftables wiki
- nft(8) manual page, netfilter project
- nftables, Debian Wiki
- firewall-cmd(1), firewalld documentation
- firewalld.richlanguage(5), firewalld documentation
- firewalld.conf(5), firewalld documentation
- public zone definition, firewalld project
- Using and configuring firewalld, RHEL 9 documentation
- Packet filtering and firewalls, Docker documentation
- Docker with iptables, Docker documentation
- Port publishing and mapping, Docker documentation
- dockerd reference, Docker documentation
- Protect the Docker daemon socket, Docker documentation
- Redis security, Redis documentation
- Networking settings, Elasticsearch documentation
- Server system variables (bind_address), MySQL 8.4 Reference Manual
- Connections and authentication, PostgreSQL documentation
- Default MongoDB port, MongoDB documentation
- Security checklist, MongoDB documentation
- Options summary, Nmap reference guide