What UFW Is

UFW — Uncomplicated Firewall — is not a firewall. It is a policy manager for the Linux kernel's packet filter. The actual filtering happens in Netfilter — a kernel subsystem that inspects every packet traversing the network stack. UFW sits on top of iptables, translating human-readable rules into the kernel's native filtering language. On Ubuntu 22.04 and later the underlying framework has shifted from iptables to nftables, but UFW abstracts that transition away. You write rules. UFW translates them.

The distinction matters. UFW does not inspect packet payloads. It does not perform deep packet inspection or application-layer analysis. It manages accept, deny, reject, and limit policies and passes them to the kernel. Every rule you create through UFW becomes a Netfilter rule. UFW spares you the syntax.

Under the hood, UFW populates several iptables chains — ufw-user-input, ufw-user-output, and ufw-user-forward among them. These chains are hooked into the kernel's INPUT, OUTPUT, and FORWARD chains via jump rules. Understanding this structure is unnecessary for routine use but becomes essential when debugging conflicts with Docker, LXC, or any other tool that manipulates iptables directly. When two tools write to the same chain hierarchy, the one whose rules are evaluated first wins — and UFW does not always win.

Installing UFW

UFW ships with Ubuntu but is not enabled by default. Confirm it is present and install it if missing.

apt update
apt install ufw

Do not enable it yet. Enabling UFW on a remote server without first allowing SSH will lock you out immediately. This is not a theoretical risk — it is the single most common UFW mistake, and it happens to experienced administrators who are working too quickly.

Default Policies

Every firewall configuration begins with defaults. The correct starting position is default-deny for incoming traffic and default-allow for outgoing.

ufw default deny incoming
ufw default allow outgoing

This is least privilege applied to the network layer. Deny everything inbound. Allow everything outbound. Then punch holes for the services you actually run.

Some hardening guides recommend denying outbound traffic as well. This is appropriate for high-security environments — bastion hosts, database servers, machines where any outbound connection is inherently suspicious. For general-purpose servers, default-allow outgoing is pragmatic. Package updates, DNS resolution, NTP synchronisation, and log forwarding all require outbound access.

UFW supports three actions for inbound traffic — allow, deny, and reject. deny silently drops packets. The sender receives no response and eventually times out. reject sends an ICMP port-unreachable reply, telling the sender immediately that the port is closed. For public-facing services, deny is preferred — it gives scanners no information. For internal networks where fast failure matters, reject is often more practical. UFW defaults to deny for its default-deny policy, which is the correct choice for internet-facing servers.

Allowing Services

Allow SSH first — before anything else.

ufw allow ssh

This is equivalent to ufw allow 22/tcp. The named form references application profiles stored in /etc/ufw/applications.d/. Both produce identical iptables rules.

For a web server, allow HTTP and HTTPS.

ufw allow 80/tcp
ufw allow 443/tcp

For services on non-standard ports, specify the port directly.

ufw allow 8443/tcp

To allow a port range — common for passive FTP or ephemeral service ports — use a colon separator.

ufw allow 6000:6100/tcp

To restrict a service to specific source networks — essential for database ports, monitoring endpoints, and administrative interfaces — use the full syntax.

ufw allow from 192.168.1.0/24 to any port 22 proto tcp

This permits SSH only from the 192.168.1.0/24 subnet. All other sources hit the default-deny policy. Apply this pattern to every service that does not require public access.

To deny traffic from a specific IP address — useful when you spot abuse in your logs — insert a deny rule with a low rule number so it is evaluated first.

ufw insert 1 deny from 203.0.113.50

Rule ordering matters. UFW evaluates rules top to bottom and stops at the first match. A deny rule inserted after a broader allow rule has no effect. Always place specific deny rules before the corresponding allow rules.

Rate Limiting SSH

UFW has a built-in rate limiter. Apply it to SSH.

ufw limit ssh

This drops connections from any IP address that attempts more than six connections within thirty seconds. The mechanism operates entirely in the kernel — no log parsing, no userspace daemon, no additional software. It handles casual brute-force attempts efficiently.

Rate limiting is not a replacement for proper authentication controls. Key-based SSH authentication with password login disabled is the baseline. Fail2Ban, covered in a separate tutorial, adds dynamic banning on top of these static rules — it parses authentication logs, detects repeated failures, and creates temporary firewall rules that block offending addresses for configurable durations. UFW's rate limiter and Fail2Ban serve different purposes. Use both.

Application Profiles

UFW ships with profiles for common services. List available profiles.

ufw app list

Inspect a specific profile to see what ports it opens.

ufw app info OpenSSH

Profiles are plain text files in /etc/ufw/applications.d/. Each contains a title, a description, and a ports directive in an INI-style block. You can write custom profiles for your own services. The format is minimal — three directives in a bracketed section.

A custom profile for an internal service might look like this, saved to /etc/ufw/applications.d/myapp.

[MyApp]
title=My Application
description=Internal service on port 9090
ports=9090/tcp

After creating the file, verify it loads correctly with ufw app info MyApp, then allow it with ufw allow MyApp. Profiles provide no security advantage over raw port rules. They exist for clarity and documentation.

Logging

Enable UFW logging.

ufw logging on

The default level is low. Available levels — low, medium, high, full — control verbosity. low logs blocked packets that match the default deny policy. medium adds invalid packets, allowed connections, and new connections. high and full log rate-limited packets and all traffic matching any rule, which generates substantial volume on busy servers.

ufw logging medium

For most servers, medium provides adequate visibility without overwhelming disk storage. Logs write to /var/log/ufw.log. Monitor them in real time during rule changes.

tail -f /var/log/ufw.log

Each log line includes the timestamp, network interface, source and destination IP, source and destination port, protocol, and action — BLOCK, ALLOW, AUDIT, or LIMIT. These logs are the foundation for host-level network monitoring and feed directly into intrusion detection tooling. psad, covered elsewhere on this site, adds port-scan detection by analysing these UFW log entries for sequential port probes and other reconnaissance signatures.

IPv6

UFW handles IPv6 rules alongside IPv4, but this must be explicitly enabled. Open /etc/default/ufw and confirm the following line exists.

IPV6=yes

Reload UFW if you changed this value.

ufw reload

Without IPv6 enabled in UFW, your rules apply only to IPv4 traffic. Most services on modern Ubuntu bind to both protocol families by default — a process listening on :: accepts IPv6 connections that UFW never evaluates. This is a common and dangerous oversight on dual-stack servers. Run ss -tlnp to see which addresses your services bind to. Any service showing ::: in the local address column is listening on IPv6 and needs matching UFW rules.

When IPv6 is enabled, every ufw allow command creates rules for both IPv4 and IPv6 automatically. You do not need to write separate rules. If you later add ufw deny from rules for specific IPv4 addresses, remember that the same attacker may use an IPv6 address — add deny rules for both protocols.

Enabling UFW

With your rules in place — SSH allowed at minimum — enable the firewall.

ufw enable

UFW warns that enabling may disrupt existing SSH connections. In practice, established connections usually survive. But always have out-of-band console access available before enabling. On cloud providers, this means the web console or serial console. On physical hardware, this means IPMI, iDRAC, iLO, or a physical KVM.

UFW persists across reboots automatically. The systemd unit ufw.service is enabled when you run ufw enable. No additional systemd configuration is required.

Before closing your current SSH session, open a second terminal and verify you can still connect. This is the most important habit in remote firewall management. Every rule change should be tested from a separate connection before the working session is closed. If the new rules lock you out, the existing session remains open for correction. If you close first and test second, you may not get a second chance.

Checking Status

View the active ruleset with the verbose flag.

ufw status verbose

This shows the default policies, the logging level, and every active rule with its source, destination, port, and action.

For numbered output — essential for safe rule deletion — use the numbered flag.

ufw status numbered

Deleting Rules

Delete by rule number.

ufw delete 3

UFW prompts for confirmation. Verify the rule number against the numbered status output before confirming — deleting the wrong rule on a remote machine can lock you out instantly.

Delete by rule specification.

ufw delete allow 80/tcp

The numbered approach is safer. It removes ambiguity when multiple rules share similar patterns.

The Docker Bypass

This section matters more than everything above it. Most administrators discover this problem after a security incident, not before.

Docker manipulates iptables directly. It does not use UFW. It does not respect UFW rules. When you expose a container port — docker run -p 3306:3306 — Docker inserts its own iptables rules in the DOCKER chain. These rules sit earlier in the iptables processing order than UFW's rules. The result is that your MySQL container is reachable from the public internet regardless of what your UFW configuration says.

This is not a bug. Docker's networking architecture — bridge networks, port mapping, inter-container routing — requires direct iptables manipulation. But the practical consequence is severe. Your carefully constructed UFW ruleset is silently irrelevant for every port Docker exposes.

Verify this yourself. Run iptables -L -n on any machine with exposed Docker containers. The DOCKER chain contains ACCEPT rules you did not write and that UFW cannot override. Every port mapped with -p is wide open.

This applies equally to docker run and docker compose. A ports: directive in a compose file with "3306:3306" is just as exposed as a command-line -p flag. The only difference is that compose files are version-controlled and reviewed — which makes the oversight more embarrassing, not less likely.

Three mitigations exist.

Bind containers to localhost only. Replace -p 3306:3306 with -p 127.0.0.1:3306:3306 in your run commands or compose files. The port is then reachable only from the host machine itself. A reverse proxy — Nginx, Caddy, Traefik — fronts the service and handles external access with its own access controls. This is the simplest mitigation and the one I use most often.

Use the DOCKER-USER chain. Docker provides a custom iptables chain — DOCKER-USER — that is evaluated before Docker's own routing rules. Insert your filtering rules there.

iptables -I DOCKER-USER -i eth0 -p tcp --dport 3306 -j DROP
iptables -I DOCKER-USER -i eth0 -s 192.168.1.0/24 -p tcp --dport 3306 -j ACCEPT

This drops all external access to port 3306 except from the 192.168.1.0/24 subnet. DOCKER-USER rules survive container restarts but not host reboots — persist them with iptables-save or a dedicated systemd unit.

Disable Docker's iptables manipulation entirely. Add the following to /etc/docker/daemon.json.

{
  "iptables": false
}

Restart the Docker daemon after this change. You now manage all container networking rules manually — port forwarding, NAT masquerading, inter-container communication, everything. This is the cleanest approach from a security perspective and the most operationally demanding. It suits production environments with dedicated network engineering capacity.

The Reset Escape Hatch

If your rules reach a state you cannot reason about clearly, reset to factory defaults.

ufw reset

This disables UFW and deletes every rule. On a remote machine, you regain access — but you also lose all protection. Re-apply your rules immediately after a reset.

A less destructive alternative to a full reset is disabling UFW temporarily while you rework your rules.

ufw disable

This stops enforcement but preserves your existing rules. Re-enable with ufw enable once you have corrected the configuration. On a remote machine, disabling is safer than resetting because your rules survive — you can fix them and re-enable without rebuilding from scratch.

Operational Summary

Install UFW, set default-deny incoming and default-allow outgoing, allow SSH before enabling the firewall, rate-limit SSH with ufw limit ssh, enable logging at medium, confirm IPv6 is active in /etc/default/ufw, and test every change from a separate session before closing the one you are working in. Understand that Docker bypasses all of it — bind containers to 127.0.0.1, use the DOCKER-USER iptables chain, or disable Docker's iptables manipulation in daemon.json. Verify your chosen mitigation with iptables -L -n and a port scan from an external host.