Install and Enable UFW

On Ubuntu 24.04, UFW is already installed but inactive. On a minimal Debian 12 install you may need to add it.

After installation, check the backend. On Ubuntu 24.04 with kernel 6.8, iptables-nft is the default, so UFW rules translate to nft chains internally. You can verify with `update-alternatives --query iptables`.

Before enabling UFW, set your default policies. Enabling UFW before setting defaults can lock you out of an SSH session if the defaults happen to drop all incoming traffic and you have no allow rule for port 22.

apt install ufw

# Set defaults before enabling
ufw default deny incoming
ufw default allow outgoing

# Allow SSH before enabling, or you will lose your session
ufw allow 22/tcp

# Enable
ufw enable

# Verify
ufw status verbose

How UFW Processes Rules: Order Matters

UFW evaluates rules top-to-bottom and stops at the first match. This is the same behavior as raw iptables. If you allow a port and then later add a deny for the same port, the allow wins because it was inserted first. This trips up many operators who add rules interactively over time.

List rules with line numbers so you can insert and delete by position:

`ufw status numbered` gives you the indexed list. To insert a deny rule at position 3 - before an existing allow - use `ufw insert 3 deny from 198.51.100.0/24`. To delete rule number 5, run `ufw delete 5` and confirm the prompt.

UFW stores rules in two files: `/etc/ufw/user.rules` for IPv4 and `/etc/ufw/user6.rules` for IPv6. These are raw iptables-save format. You can edit them directly for bulk changes, then run `ufw reload` to apply. This is faster than issuing 50 individual `ufw` commands when migrating a ruleset.

# List with numbers
ufw status numbered

# Insert deny at position 2
ufw insert 2 deny from 203.0.113.0/24 to any port 443

# Delete by number
ufw delete 5

# Reload after manual edits to user.rules
ufw reload

Application Profiles: Using /etc/ufw/applications.d/

UFW ships with profiles for common services in `/etc/ufw/applications.d/`. Nginx splits into three profiles: `Nginx HTTP`, `Nginx HTTPS`, and `Nginx Full`. OpenSSH is a single profile covering port 22/tcp.

List all available profiles with `ufw app list`. Show what ports a profile covers with `ufw app info 'Nginx Full'`.

You can write your own profiles. This is useful when you run internal services on non-standard ports. Create a file in `/etc/ufw/applications.d/` with an INI-style block. After creating the file, run `ufw app update myapp` to register it, then allow it by name.

Application profiles make rulesets readable in version-controlled infrastructure. If you are managing firewall rules as code and pushing them with a tool like those at taskbotshub.ai, named profiles give you human-readable audit trails instead of bare port numbers.

# List all profiles
ufw app list

# Inspect a profile
ufw app info 'Nginx Full'

# Create a custom profile
cat > /etc/ufw/applications.d/myapp << 'EOF'
[MyApp]
title=My Internal App
description=Internal service on 8443
ports=8443/tcp
EOF

ufw app update MyApp
ufw allow MyApp
// advertisement

Specific Allow and Deny Rules

The `ufw allow` and `ufw deny` commands accept ports, protocols, source IPs, destination IPs, and combinations. For production servers, always be as specific as possible. A rule that allows 5432/tcp from any source defeats the purpose of running PostgreSQL on a private network.

Allow a port range for passive FTP:

``` ufw allow 49152:65535/tcp ```

Allow a specific source CIDR to a specific port:

``` ufw allow from 10.0.0.0/8 to any port 5432 proto tcp ```

Allow traffic on a specific interface only. This is critical on multi-homed servers where eth0 faces the internet and eth1 faces a private backend network:

``` ufw allow in on eth1 to any port 5432 proto tcp ```

Deny with logging. The `deny` action silently drops. If you want to log the drop, use `ufw deny log` or edit the rule in user.rules directly with the LOG target before the DROP.

Delete a rule by specifying it in full rather than by number - this is safer in scripts:

``` ufw delete allow from 10.0.0.0/8 to any port 5432 proto tcp ```

# Allow SSH from a management CIDR only
ufw allow from 192.0.2.0/24 to any port 22 proto tcp

# Allow HTTPS from anywhere
ufw allow 443/tcp

# Allow PostgreSQL from private network only, on internal interface
ufw allow in on eth1 from 10.10.0.0/16 to any port 5432 proto tcp

# Deny with logging
ufw deny log from 198.51.100.5

# Allow a port range
ufw allow 49152:65535/tcp

Rate Limiting with UFW

UFW has a built-in rate limit command that uses the hashlimit iptables module. The default threshold is 6 connections within 30 seconds from a single IP. When the threshold is exceeded, new connections from that IP are dropped until the rate falls below the limit.

`ufw limit ssh` applies rate limiting to port 22. This is a single command and takes effect immediately. It is not as configurable as fail2ban, but it requires zero dependencies and works at the kernel level without reading log files.

For custom thresholds, you need to write directly to `/etc/ufw/user.rules`. The relevant iptables target is `-m hashlimit --hashlimit-upto 3/min --hashlimit-burst 5 --hashlimit-mode srcip --hashlimit-name ssh`. Edit the file between the UFW rule markers and reload.

Rate limiting works well alongside fail2ban, not instead of it. UFW rate limiting stops brute force at the connection level instantly. Fail2ban adds IP banning based on authentication failure patterns in logs. Run both on any internet-facing SSH port.

# Built-in rate limit for SSH (6 connections per 30s per source IP)
ufw limit ssh

# Verify it appears in rules
ufw status verbose | grep LIMIT

# Custom threshold requires direct edit
# In /etc/ufw/user.rules, between ### rules/before and ### END DOKU-UFW markers:
# -A ufw-user-input -p tcp --dport 22 -m hashlimit \
#   --hashlimit-upto 3/min --hashlimit-burst 5 \
#   --hashlimit-mode srcip --hashlimit-name ssh -j ACCEPT
ufw reload

Logging: Levels and Log Parsing

UFW logging has five levels: off, low, medium, high, and full. The default after `ufw logging on` is low. At low, UFW logs blocked packets that do not match any rule. At medium, it also logs allowed packets. High adds rate-limited packets. Full disables rate limiting on log entries.

On Ubuntu 24.04 with systemd-journald and rsyslog, UFW log entries appear in `/var/log/ufw.log` and also in the journal. The journal route is faster to query with structured filters:

``` journalctl -k --grep='UFW BLOCK' --since='1 hour ago' ```

A typical UFW BLOCK line looks like:

``` Aug 17 03:14:22 prod-web01 kernel: [UFW BLOCK] IN=eth0 OUT= MAC=... SRC=198.51.100.77 DST=203.0.113.10 LEN=44 TOS=0x00 PREC=0x00 TTL=241 ID=54321 PROTO=TCP SPT=54789 DPT=23 WINDOW=1024 RES=0x00 SYN URGP=0 ```

Parse the SRC, DPT, and PROTO fields. A spike in DPT=23 (Telnet) or DPT=3389 (RDP) is a reliable indicator of automated scanning. Feed these fields into a log aggregator. If you are using centralized logging in a DevOps pipeline, tools like those at taskbotshub.ai can automate alert routing based on UFW log patterns.

To change the logging level:

``` ufw logging medium ```

Do not run `high` or `full` on a high-traffic server permanently. At full logging, every accepted packet generates a log entry. On a server handling 10,000 requests per minute, this will saturate your disk I/O and fill `/var/log` within hours.

# Enable logging at medium level
ufw logging medium

# Query journal for blocked packets in last hour
journalctl -k --grep='UFW BLOCK' --since='1 hour ago'

# Count top source IPs being blocked (from ufw.log)
grep 'UFW BLOCK' /var/log/ufw.log | grep -oP 'SRC=\K[^ ]+' | sort | uniq -c | sort -rn | head -20

# Count top blocked destination ports
grep 'UFW BLOCK' /var/log/ufw.log | grep -oP 'DPT=\K[^ ]+' | sort | uniq -c | sort -rn | head -10
// advertisement

IPv6 Configuration

UFW handles IPv6 automatically when `IPV6=yes` is set in `/etc/default/ufw`. This is the default on Ubuntu 24.04. Every rule you add gets a corresponding IPv6 rule in `user6.rules`.

Verify that your rules applied to both stacks:

``` ufw status verbose ```

Look for both `v6` entries next to each rule. If they are missing, check `/etc/default/ufw` and reload.

For servers with a public IPv6 address, the same hardening logic applies. Do not assume IPv6 traffic is obscure. Scanners actively probe IPv6 ranges on major cloud providers. Rate limit SSH on IPv6, restrict management ports to your IPv6 management prefix, and set the same default deny incoming policy.

One common trap: if you disable UFW and re-enable it after changing `IPV6=yes`, the existing rules in `user6.rules` are preserved but may not match `user.rules` if you made changes while IPv6 was disabled. Always run `ufw status verbose` and cross-check both files after any IPV6 setting change.

# Check /etc/default/ufw
grep IPV6 /etc/default/ufw
# Should return: IPV6=yes

# After confirming, reload
ufw reload

# Verify IPv6 rules are present
ufw status verbose | grep 'v6'

# If you need to allow SSH on IPv6 from a specific prefix
ufw allow from 2001:db8::/32 to any port 22 proto tcp

Editing /etc/ufw/before.rules for Advanced Scenarios

UFW applies rules in this order: before.rules, user.rules, after.rules. Rules in before.rules run before any user-defined rules and are not managed by the `ufw` CLI - you edit the file directly.

The most common reason to touch `before.rules` is NAT or port forwarding. If this server is acting as a router or you need DNAT for an internal service:

``` # At the top of /etc/ufw/before.rules, before the *filter section: *nat :PREROUTING ACCEPT [0:0] -A PREROUTING -i eth0 -p tcp --dport 8080 -j DNAT --to-destination 10.10.0.5:80 COMMIT ```

You also need to enable IP forwarding in `/etc/ufw/sysctl.conf`:

``` net/ipv4/ip_forward=1 ```

Another use case: ICMP control. By default, UFW allows all ICMP. If you want to block ICMP echo requests from the internet while keeping them allowed on internal interfaces, edit the ICMP rules in `before.rules` to add interface conditions.

After any change to before.rules:

``` ufw reload ```

Never run `ufw disable && ufw enable` on a remote server unless you have console access. Use `ufw reload` which reloads rules without the firewall going offline.

# /etc/ufw/before.rules - NAT example
# Add BEFORE the *filter section

*nat
:PREROUTING ACCEPT [0:0]
-A PREROUTING -i eth0 -p tcp --dport 8080 -j DNAT --to-destination 10.10.0.5:80
COMMIT

# Enable forwarding in /etc/ufw/sysctl.conf
echo 'net/ipv4/ip_forward=1' >> /etc/ufw/sysctl.conf

# Reload - do not disable/enable
ufw reload

Exporting, Auditing, and Version-Controlling Your Ruleset

The full active ruleset at any time is readable with:

``` iptables-save ip6tables-save ```

This output includes UFW's internal chains (`ufw-user-input`, `ufw-before-input`, etc.) alongside any system chains. For auditing purposes, what matters is the content of `/etc/ufw/user.rules` and `/etc/ufw/user6.rules`, since those are the user-managed rules that survive reboots.

Version-control these files. A minimal approach:

``` cp /etc/ufw/user.rules /etc/ufw/user6.rules /etc/ufw/before.rules /etc/ufw/after.rules ~/firewall-backup/ git -C ~/firewall-backup add -A && git -C ~/firewall-backup commit -m "$(hostname): $(date -u +%Y-%m-%dT%H:%M:%SZ)" ```

For a full audit of what is currently active versus what is on disk, diff the output of `iptables-save | grep ufw-user` against the parsed content of `user.rules`. If they diverge, someone ran an iptables command directly without going through UFW. This is a common problem in teams where junior engineers have root access.

If you are managing firewall state across a fleet of servers, export the user.rules format and apply it via configuration management (Ansible, Puppet, Salt). The UFW CLI is fine for single-server work. At scale, treat `user.rules` as a config file, not the `ufw` command as the source of truth.

# Snapshot current UFW config files
mkdir -p ~/firewall-backup
cp /etc/ufw/{user.rules,user6.rules,before.rules,after.rules,sysctl.conf} ~/firewall-backup/
git -C ~/firewall-backup init
git -C ~/firewall-backup add -A
git -C ~/firewall-backup commit -m "$(hostname): $(date -u +%Y-%m-%dT%H:%M:%SZ)"

# Compare active rules vs on-disk rules
iptables-save | grep 'ufw-user' > /tmp/active-ufw.txt
diff /tmp/active-ufw.txt <(grep -v '^#' /etc/ufw/user.rules | grep -v '^$')
// advertisement

Hardening Checklist: What a Production UFW Config Must Include

A production UFW configuration on an internet-facing server should meet these minimums:

1. Default deny incoming, default allow outgoing. If you need to restrict outgoing (for data exfiltration prevention), set `ufw default deny outgoing` and explicitly allow DNS (53/udp), HTTP (80/tcp), HTTPS (443/tcp), and SMTP (587/tcp) as needed.

2. SSH locked to known management CIDRs. Do not allow `ufw allow ssh` from any. If you manage many servers and need a consistent management IP range, document it. If you are registering a management domain for your infrastructure, a clean memorable name via a service like nicename.me ensures your team can reference it without ambiguity in runbooks and firewall comments.

3. Rate limiting on SSH regardless of source restriction. Belt and suspenders.

4. Logging at `low` minimum. Parse and alert on blocked packets to management ports.

5. No rules that allow broad port ranges from any source unless the service explicitly requires it (passive FTP is the common exception).

6. Explicit deny rules for known bad networks above generic allow rules if you track threat intelligence CIDRs.

7. ufw status verbose reviewed and committed to version control after every change.

Verify your configuration survives reboot by running `ufw status verbose` from cron after startup and alerting if UFW is not active:

``` @reboot sleep 30 && ufw status | grep -q 'Status: active' || mail -s 'UFW DOWN on $(hostname)' ops@example.com ```

# Complete hardened baseline for a web server
ufw default deny incoming
ufw default allow outgoing
ufw limit in on eth0 from 192.0.2.0/24 to any port 22 proto tcp comment 'SSH management CIDR'
ufw allow in on eth0 to any port 80 proto tcp comment 'HTTP'
ufw allow in on eth0 to any port 443 proto tcp comment 'HTTPS'
ufw deny log from 198.51.100.0/24 comment 'Known bad CIDR'
ufw logging low
ufw reload
ufw status verbose