Fransys

Tech blog — Architecture, Cloud & DevOps

BlogServicesContactAbout

Follow me

githubGitHublinkedinLinkedinmailMail

© 2026 Fransys • Fransys

Fransys

Categories

  • All posts
  • Tags
  • productivity10
  • nas10
  • ai8
  • security7
  • self-hosting7
  • linux6
  • claude-code6
  • neovim5
  • docker5
  • editor4
  • networking4
  • mcp3
  • vpn3
  • lua2
  • terminal2
naslinuxsecuritynetworking

Firewall and Fail2ban: locking down NAS network access

Published on
January 20, 2026·6 min read
Avatar François GUERLEZFrançois GUERLEZ

Why bother with a firewall on a home NAS?

A NAS is a server that's always on. No shame in securing it properly. It hosts sensitive stuff: file shares, media server, maybe home automation. Without a firewall, all ports are wide open and anything on your network can try to connect. Add port forwarding for remote access and suddenly you've got a problem.

I set up two complementary layers on my TerraMaster F4-424: UFW for port filtering, and Fail2ban for detecting and banning intrusion attempts.

UFW: a human-readable firewall

UFW (Uncomplicated Firewall) is a frontend for iptables/nftables. Main advantage: you can actually read the rules. Unlike iptables syntax which makes you want to throw your laptop out the window.

Default policy

First step: lock everything down:

ufw default deny incoming
ufw default allow outgoing

Deny incoming means all inbound traffic is blocked unless explicitly allowed. Allow outgoing lets the NAS talk to the outside world (updates, DNS, NTP...).

SSH: rate limiting

Instead of plain allow, I use limit which caps connections to 3 per minute from a single IP:

ufw limit ssh

First line of defense against brute-force, before Fail2ban even kicks in.

LAN-only services

File sharing doesn't need to be internet-accessible. Restrict them to the local subnet:

# Samba (Windows/macOS shares)
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 shares)
ufw allow from 192.168.1.0/24 to any port 2049

Simple. Takes 30 seconds. Saves a lot of headaches.

Open Docker services

Some containers need to be accessible remotely (VPN, remote access):

ufw allow 8096/tcp   # Jellyfin
ufw allow 6881/tcp   # qBittorrent
ufw allow 22000/tcp  # Syncthing
ufw allow 8123/tcp   # Home Assistant

Verification

Run ufw status verbose and see everything clearly:

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

Clean. Readable.

The Docker + UFW trap

Classic gotcha. Everyone falls for it eventually. Docker completely bypasses UFW. When you publish a port (-p 8080:80), Docker injects rules directly into iptables before UFW evaluates anything. Result? Your containers are accessible from anywhere even if UFW blocks the port.

Two solutions:

Option 1: Disable Docker's iptables management in /etc/docker/daemon.json:

{
  "iptables": false
}

Caveat: Docker no longer handles container NAT. You need to configure networking manually.

Option 2 (my preference): Add rules to the DOCKER-USER chain, which evaluates before Docker's rules:

# /etc/ufw/after.rules (append to the end)
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -s 192.168.1.0/24 -j ACCEPT
-A DOCKER-USER -j DROP
COMMIT

Docker keeps working, containers are limited to LAN access only. Took me 2 hours to figure out why Jellyfin was accessible from the internet. This is important.

Fail2ban: banning intruders

UFW filters ports. It doesn't detect brute-force attacks on permitted services. That's where Fail2ban comes in: monitor logs and temporarily ban suspicious IPs.

SSH jail configuration

Main config file: /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 duration, use progressive bans. Logic: someone who misses their password once gets banned 10 minutes. An attacker keeps trying, gets banned for longer and longer:

# Progressive ban
bantime.increment    = true
bantime.multipliers  = 1 5 30 60 180 360 720
bantime              = 600

With base bantime of 600 seconds (10 min) and these multipliers:

OffenseMultiplierBan duration
1st banx110 minutes
2nd banx550 minutes
3rd banx305 hours
4th banx60~10 hours
5th banx180~30 hours
6th banx360~2.5 days
7th banx720~5 weeks

A persistent bot gets banned for 5 weeks. In practice, most quit way earlier.

Ban action

Default action uses iptables to block the IP. Add email notifications too:

action = %(action_mwl)s

action_mwl means: ban + email with logs and attacker whois. Useful for monitoring.

Verification

Check SSH 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

47 failed attempts, 12 total bans, 2 IPs currently banned.

Ansible automation

Like everything else, firewall and Fail2ban are deployed via Ansible. The role is idempotent: run it as many times as you want, no side effects.

# 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]

Rules live in role variables, making the configuration declarative and git-versioned.

Bottom line

A firewall on a home NAS isn't paranoia - it's basic housekeeping. UFW makes port filtering approachable, Fail2ban adds active detection, and Ansible ensures this survives reinstalls. The Docker/UFW trap is the critical point: if you're running Docker without handling this, your containers are probably exposed and you don't even know it. Happens to almost everyone setting this up.


Debian NAS from scratch series — This article is part of a complete series on building a Debian NAS.

Previous: Docker on NAS: network architecture and best practices | Next: Hardening your NAS Linux kernel to CIS Level 2 standards

Previous post

← Native LSP in Neovim 0.11: zero plugins, zero compromises

Next post

AI completion in Neovim: Codeium, Gemini and nvim-cmp→
← Back to blog

Table of Contents

  • Why bother with a firewall on a home NAS?
  • UFW: a human-readable firewall
  • Default policy
  • SSH: rate limiting
  • LAN-only services
  • Open Docker services
  • Verification
  • The Docker + UFW trap
  • Fail2ban: banning intruders
  • SSH jail configuration
  • Progressive bans
  • Ban action
  • Verification
  • Ansible automation
  • Bottom line