Firewall basics for servers: nftables, ufw and firewalld

Updated November 2026

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.

Existingconnection? From loopback(lo)? Allowed port?22, 80, 443 Dropdefault policy nonono yesyesyes Accept
Every rule is an exception to "drop". Anything you did not allow never reaches the service.

Before you start

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.

Packet tohost:8080 Docker NATto container IP FORWARD chainDOCKER-USER first Container INPUT chain (ufw)never consulted
Rules in DOCKER-USER apply to published ports; ufw's INPUT rules do not.

What to do:

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

Default ports to block from the internet
ServiceDefault portWhy
Docker daemon API2375 / 23762375 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.
Redis6379Designed for trusted clients only; one FLUSHALL deletes everything.
Elasticsearch9200, 9300Elastic: never expose an unprotected node to the public internet.
MySQL, MariaDB3306Upstream MySQL listens on all interfaces by default.
PostgreSQL5432Defaults to localhost; don't widen it without a firewall rule.
MongoDB27017MongoDB says to allow only trusted clients.
Admin panels, dashboardsvariesHosting 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