
Firewall and Fail2ban: locking down NAS network access
Why bother with a firewall on a home NAS
A NAS is a server running 24/7 that holds sensitive data: file shares, media server, sometimes home automation. Without a firewall, every port is open by default and any device on the network can reach it. The moment a port is exposed outward for remote access, the attack surface explodes.
Two complementary layers on this TerraMaster F4-424: UFW to filter ports, Fail2ban to detect and ban intrusion attempts.
UFW: a readable firewall
UFW is a frontend for iptables/nftables whose main advantage is readable rules, against iptables' cryptic syntax.
Default policy — lock everything down first:
ufw default deny incoming
ufw default allow outgoing
All incoming traffic is blocked unless explicitly allowed, while the NAS can still reach outward (updates, DNS, NTP).
SSH — rather than a plain allow, limit restricts to 3 connections per minute from the same IP, a first line of defence against brute-force before Fail2ban even runs:
ufw limit ssh
LAN-only services — file shares don't need to be reachable from the Internet:
# Samba (Windows/macOS)
ufw allow from 192.168.1.0/24 to any port 445
ufw allow from 192.168.1.0/24 to any port 139
# NFS (Linux)
ufw allow from 192.168.1.0/24 to any port 2049
Exposed Docker services — some containers must stay reachable, including over VPN:
ufw allow 8096/tcp # Jellyfin
ufw allow 6881/tcp # qBittorrent
ufw allow 22000/tcp # Syncthing
ufw allow 8123/tcp # Home Assistant
ufw status verbose gives the full picture:
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
To Action From
-- ------ ----
22/tcp LIMIT Anywhere
445 ALLOW 192.168.1.0/24
139 ALLOW 192.168.1.0/24
2049 ALLOW 192.168.1.0/24
8096/tcp ALLOW Anywhere
6881/tcp ALLOW Anywhere
22000/tcp ALLOW Anywhere
8123/tcp ALLOW Anywhere
The Docker + UFW trap
Docker bypasses UFW. When Docker publishes a port (-p 8080:80), it injects its rules straight into iptables before UFW. The result: containers are reachable from anywhere even though UFW blocks the port.
Option 1 — disable iptables in Docker, via /etc/docker/daemon.json:
{
"iptables": false
}
Careful: Docker then no longer handles container NAT, which you must configure manually.
Option 2, preferable — add rules to DOCKER-USER, evaluated before Docker's own rules:
# /etc/ufw/after.rules (at the end of the file)
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -s 192.168.1.0/24 -j ACCEPT
-A DOCKER-USER -j DROP
COMMIT
Docker stays functional, but containers are confined to the LAN.
Fail2ban: banning intruders
UFW filters ports but doesn't detect brute-force attempts. Fail2ban watches the logs and temporarily bans suspicious IPs.
The SSH jail, in /etc/fail2ban/jail.local:
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
backend = systemd
# 3 attempts max before ban
maxretry = 3
# Detection window: 10 minutes
findtime = 600
Progressive bans
Instead of a fixed ban, a progressive strategy: one mistyped password costs ten minutes, while a persistent attacker gets banned for longer and longer.
# Progressive ban
bantime.increment = true
bantime.multipliers = 1 5 30 60 180 360 720
bantime = 600
| Repeat offence | Multiplier | Ban duration |
|---|---|---|
| 1st ban | x1 | 10 minutes |
| 2nd ban | x5 | 50 minutes |
| 3rd ban | x30 | 5 hours |
| 4th ban | x60 | ~10 hours |
| 5th ban | x180 | ~30 hours |
| 6th ban | x360 | ~2.5 days |
| 7th ban | x720 | ~5 weeks |
A persistent bot ends up banned for five weeks; in practice nearly all of them give up before that.
The default action blocks the IP through iptables. action = %(action_mwl)s adds an email with the logs and a whois of the attacker, useful for monitoring.
Checking jail status:
fail2ban-client status sshd
Status for the jail: sshd
|- Filter
| |- Currently failed: 0
| |- Total failed: 47
| `- File list: /var/log/auth.log
`- Actions
|- Currently banned: 2
|- Total banned: 12
`- Banned IP list: 203.0.113.42 198.51.100.7
Ansible automation
The firewall and Fail2ban are deployed through an idempotent Ansible role:
# roles/firewall/tasks/main.yml
- name: Install UFW and Fail2ban
ansible.builtin.apt:
name:
- ufw
- fail2ban
state: present
tags: [firewall]
- name: Set UFW default policies
community.general.ufw:
direction: '{{ item.direction }}'
policy: '{{ item.policy }}'
loop:
- { direction: incoming, policy: deny }
- { direction: outgoing, policy: allow }
tags: [firewall, ufw]
- name: Configure UFW rules
community.general.ufw:
rule: '{{ item.rule }}'
port: '{{ item.port }}'
proto: "{{ item.proto | default('tcp') }}"
from_ip: "{{ item.from_ip | default('any') }}"
loop: '{{ firewall_rules }}'
tags: [firewall, ufw]
- name: Deploy Fail2ban jail configuration
ansible.builtin.template:
src: jail.local.j2
dest: /etc/fail2ban/jail.local
mode: '0644'
notify: Restart fail2ban
tags: [firewall, fail2ban]
The rules live in the role's variables: declarative and version-controlled in Git.
Conclusion
UFW makes port filtering approachable, Fail2ban adds active detection, and Ansible ensures it all survives reinstalls. The Docker/UFW trap remains the most critical point: without addressing it, your containers are probably exposed without you knowing.
Related articles