Networking Firewall

Firewall Architecture

Stateful host firewall (networking.firewall) enabled globally on every host. All service rules use mkFirewallFor from lib/firewall.nix to restrict inbound traffic to trusted source ranges, unless the service requires global reachability.

Trusted source ranges (lanSources & lanSourcesV6)

IPv4 Ranges (lanSources)

RangePurpose
192.168.0.0/16Home/consumer LANs
10.0.0.0/8Enterprise LANs, homelab containers/VMs
172.16.0.0/12Docker default bridge, secondary LANs
100.64.0.0/10Tailscale CGNAT

All four are RFC 1918 or CGNAT — not routable on the public internet.

IPv6 Ranges (lanSourcesV6)

RangePurpose
fc00::/7Unique Local Addresses (ULA)
fe80::/10Link-Local Addresses
fd7a:115c:a1e0::/48Tailscale IPv6 CGNAT-equivalent prefix

Trusted interfaces

Traffic arriving on these interfaces bypasses the firewall entirely:

InterfaceSourcePurpose
tun0services/net/sing-box.nixSing-box TUN (when enabled)
virbr0services/virt/qemu.nixLibvirt NAT bridge
waydroid0services/virt/waydroid.nixAndroid container

[!IMPORTANT] Zero-Trust Hardening: Tailscale Interface Filtering Previously, tailscale0 was treated as a trusted interface. For Zero-Trust security, this has been removed. If a public-facing Tailscale peer (like the edge VPS vps-pacman) is compromised, an attacker would have unrestricted access to all local listening ports.

By removing tailscale0 from trusted interfaces, the stateful host firewall filters Tailscale traffic. Services that need to be accessible over Tailscale must explicitly whitelist the Tailscale IP range (100.64.0.0/10 and fd7a:115c:a1e0::/48) using mkFirewallFor.

Nginx Firewall Modes

Nginx (modules/services/web/nginx.nix) supports three firewall configurations:

  1. Global Open (openFirewall = true; restrictToLAN = false;): Opens ports 80/443 globally to all interfaces. Used on edge gateways (like vps-pacman) that serve public traffic.
  2. LAN + Tailscale Restricted (openFirewall = true; restrictToLAN = true;): Uses mkFirewallFor to open ports 80/443 ONLY for physical LAN and Tailscale IPs. This is the hardened default for internal compute nodes (lab-matrix and id3-eniac), protecting Nginx from untrusted networks (e.g. public Wi-Fi on laptop nodes) while allowing remote proxying from the VPS.
  3. Loopback/Single Subnet (openFirewall = false; trustedNetwork = "...";): Manually whitelist a single subnet.

How to add a firewall rule

Decision order for any new service port:

  1. Binds only to loopback? → No firewall rule needed. Nginx proxies via 127.0.0.1 or [::1].
  2. Needs global reach?mkFirewallGlobal { tcp = [...]; udp = [...]; comment = "..."; }
  3. LAN or Tailscale only?mkFirewallFor { tcp = [...]; udp = [...]; comment = "..."; }
  4. Already open globally, need to lock down?mkRestrictedPort "11434"

Rule of thumb: start with mkFirewallFor. Only open globally if the service literally can’t function without it (mail delivery, P2P, VPN mesh).

Helpers (lib/firewall.nix)

mkFirewallFor

Generates dual-stack (iptables -A INPUT and ip6tables -A INPUT) ACCEPT rules from lanSources/lanSourcesV6 only. No terminal DROP is added — unmatched traffic falls through to the default chain policy.

networking.firewall = mkFirewallFor {
  tcp = ["8080"];
  udp = ["5353"];
  comment = "my-service";
};

mkRestrictedPort

Inserts dual-stack ACCEPT rules at position 1 in both iptables and ip6tables nixos-fw chains for lanSources/lanSourcesV6, then appends a terminal DROP. Use when a port was previously opened globally and needs to be locked down.

networking.firewall = mkRestrictedPort "11434";

mkFirewallGlobal

Opens ports globally via allowedTCPPorts/allowedUDPPorts. Use only for services that must accept connections from the public internet (VPN, P2P, proxy, relay). For everything else, use mkFirewallFor.

networking.firewall = mkFirewallGlobal {
  tcp = ["41641"];
  udp = ["41641"];
  comment = "tailscale";
};

Complete port table

Globally open ports

Traffic accepted from any source IP. Required for internet-facing services.

ServiceProtocolPort(s)Why global
TailscaleTCP+UDP41641DERP relay + coordination
WireGuardUDP51820VPN peer connections
Sing-box proxyTCP+UDP2080+SOCKS/HTTP proxy
FRP serverTCP+UDP(configurable)Tunnel endpoints
Nginx HTTP/HTTPSTCP80, 443Web server (when openFirewall = true)
Nginx QUICUDP443HTTP/3
Nginx mail proxyTCP25, 465, 587, 143, 993SMTP/IMAP (when stalwartStreamProxy enabled)
Syncthing relayTCP22067Global P2P relay
TransmissionTCP+UDP51413BitTorrent P2P swarm

LAN-restricted ports (mkFirewallFor)

Traffic accepted from lanSources only. These services have no legitimate WAN need, or WAN access is handled through nginx reverse proxy.

ServiceProtocolPort(s)Module file
SyncthingTCP22000services/sharing/syncthing.nix
SyncthingUDP22000, 21027services/sharing/syncthing.nix
OllamaTCP11434services/ai/ollama.nix
JellyfinTCP8096services/media/jellyfin.nix
SunshineTCP47984, 47989, 48010services/media/sunshine.nix
SunshineUDP47998-48000, 48002, 48010services/media/sunshine.nix
SambaTCP445, 139, 5357services/sharing/samba.nix
SambaUDP137-138, 3702services/sharing/samba.nix
SSHTCP22services/net/ssh.nix
KDE ConnectTCP+UDP1714-1764desktop/apps/kdeconnect.nix
LocalSendTCP+UDP53317desktop/apps/localsend.nix
Spotify ConnectTCP57621desktop/apps/spotify.nix
Spotify ConnectUDP5353desktop/apps/spotify.nix
Steam Remote PlayTCP27036desktop/gaming/steam.nix
Steam Remote PlayUDP27031-27036desktop/gaming/steam.nix
CUPS printerTCP+UDP631profiles/hardware/printer.nix
CUPS printerUDP5353profiles/hardware/printer.nix
SANE scannerTCP+UDP6566profiles/hardware/scaner.nix
DeskflowTCP+UDP24800profiles/hardware/kvm.nix
Lan MouseTCP+UDP4242profiles/hardware/kvm.nix
WayVNCTCP+UDP5901services/desktop/wayvnc.nix
Jitsi MeetTCP5222, 5280, 5281, 5347services/media/jitsi-meet.nix
Jitsi MeetUDP10000-20000services/media/jitsi-meet.nix
AdGuard DNSTCP+UDP53services/net/adguardhome.nix
Home AssistantTCP8123, 21063services/home/home-assistant.nix
Home AssistantUDP5353services/home/home-assistant.nix
Stalwart mailTCP25, 465, 587, 143, 993, 4190services/net/stalwart.nix

Interface-scoped ports

Traffic accepted only on a specific network interface.

ServiceProtocolPortInterfaceModule
MicroVM DHCPUDP67microvm bridgeservices/virt/microvm.nix

No firewall rules (proxied through nginx)

These services bind to localhost and are accessed through nginx reverse proxy. Loopback traffic bypasses the firewall, so no rules are needed.

ServiceListen addressNginx proxy
Affine0.0.0.0:3010 (container, loopback proxy)yes
Calibrelocalhostyes
Gotifylocalhostyes
JupyterHub127.0.0.1:8888yes
Linkwarden127.0.0.1:3001yes
Forgejo*:3000 (upstream default)yes
Hedgedoc127.0.0.1:3003yes
Paperless127.0.0.1:28981yes
Stirling PDF127.0.0.1:8083yes
Vaultwarden127.0.0.1:8222yes
Mealie127.0.0.1:9000yes
FreshRSSfastcgi (no standalone port)yes
Discourse[::1]:8007yes
OnlyOffice127.0.0.1:8000yes
Kanidm127.0.0.1:8443 (HTTPS)yes
Atuin127.0.0.1:8887yes
Anki Sync127.0.0.1:27701 (configurable via listenAddress)yes
Stalwart (HTTP)127.0.0.1:8085yes
LiteLLM127.0.0.1:4000yes
Gatus127.0.0.1:9091yes
sing-box (Clash API)127.0.0.1:9090no
jitsi-videobridge127.0.0.1:8080no (internal)
jicofo127.0.0.1:8858no (internal)
jitsi-excalidraw127.0.0.1:3002 + :9097via Jitsi vhost

Note: Bind addresses vary (127.0.0.1, [::1], *) because they come from different upstream nixpkgs defaults. What matters is that the proxyPass in each nginx vhost matches the actual bind address. Services using [::1] need proxyPass = "http://[::1]:...", not http://127.0.0.1:.... Where possible, prefer overriding the upstream host default to "127.0.0.1" (explicit IPv4) — avoids IPv6 resolution ambiguity (Node.js resolves localhost::1). See homelab-bootstrap.md § Service binds IPv6-only.

Syncthing WAN sync

Syncthing’s firewall rules are LAN-only, but internet sync works through three paths unaffected by inbound firewall:

  1. Relay — Syncthing connects outbound to the private relay on vps-pacman. Outbound isn’t filtered.
  2. Tailscale — Syncthing ports (TCP 22000, UDP 22000, 21027) are explicitly allowed from Tailscale IPs via mkFirewallFor.
  3. SSH tunnelsshTunnel.enable wraps remote sync through an SSH forward, presenting as a localhost connection.

Direct inbound P2P from non-LAN/non-Tailscale IPs is blocked — intentional. All legitimate WAN sync paths are covered.

Bind address security

Services behind nginx should bind to 127.0.0.1 or ::1 (loopback) — not 0.0.0.0 or ::. If a service binds all interfaces, it can be reached directly on that port, bypassing nginx auth and potentially exposing the service to other hosts on the same network.

Fixed in this project:

  • Affine: AFFINE_SERVER_HOST = "127.0.0.1" (was 0.0.0.0)
  • Prometheus exporters: listenAddress = "127.0.0.1" (was 0.0.0.0)

Intentionally on all-interfaces (need LAN reachability):

  • AdGuard Home DNS: bind_hosts = ["0.0.0.0"] — DNS must serve LAN clients
  • Stalwart mail ports: [::] — accepts Tailscale + LAN connections
  • WayVNC: 0.0.0.0 — LAN-restricted by mkFirewallFor
  • Samba: all interfaces — hosts allow + mkFirewallFor defense-in-depth

Container firewall caveats

Docker

Docker manipulates iptables directly. Containers published with -p are accessible on all interfaces regardless of networking.firewall rules. Containers that should not be WAN-exposed must bind to 127.0.0.1 explicitly (e.g. -p 127.0.0.1:8080:8080), or use --network=host with the port already firewall-restricted at the host level.

Podman

Podman (rootless) uses slirp4netns or pasta for networking by default. Container ports are not exposed to the host unless explicitly published with -p AND the container runs rootful. Rootless Podman containers are subject to the host firewall — safer than Docker by default.