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)
| Range | Purpose |
|---|---|
192.168.0.0/16 | Home/consumer LANs |
10.0.0.0/8 | Enterprise LANs, homelab containers/VMs |
172.16.0.0/12 | Docker default bridge, secondary LANs |
100.64.0.0/10 | Tailscale CGNAT |
All four are RFC 1918 or CGNAT — not routable on the public internet.
IPv6 Ranges (lanSourcesV6)
| Range | Purpose |
|---|---|
fc00::/7 | Unique Local Addresses (ULA) |
fe80::/10 | Link-Local Addresses |
fd7a:115c:a1e0::/48 | Tailscale IPv6 CGNAT-equivalent prefix |
Trusted interfaces
Traffic arriving on these interfaces bypasses the firewall entirely:
| Interface | Source | Purpose |
|---|---|---|
tun0 | services/net/sing-box.nix | Sing-box TUN (when enabled) |
virbr0 | services/virt/qemu.nix | Libvirt NAT bridge |
waydroid0 | services/virt/waydroid.nix | Android container |
[!IMPORTANT] Zero-Trust Hardening: Tailscale Interface Filtering Previously,
tailscale0was treated as a trusted interface. For Zero-Trust security, this has been removed. If a public-facing Tailscale peer (like the edge VPSvps-pacman) is compromised, an attacker would have unrestricted access to all local listening ports.By removing
tailscale0from 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/10andfd7a:115c:a1e0::/48) usingmkFirewallFor.
Nginx Firewall Modes
Nginx (modules/services/web/nginx.nix) supports three firewall configurations:
- Global Open (
openFirewall = true; restrictToLAN = false;): Opens ports 80/443 globally to all interfaces. Used on edge gateways (likevps-pacman) that serve public traffic. - LAN + Tailscale Restricted (
openFirewall = true; restrictToLAN = true;): UsesmkFirewallForto open ports 80/443 ONLY for physical LAN and Tailscale IPs. This is the hardened default for internal compute nodes (lab-matrixandid3-eniac), protecting Nginx from untrusted networks (e.g. public Wi-Fi on laptop nodes) while allowing remote proxying from the VPS. - Loopback/Single Subnet (
openFirewall = false; trustedNetwork = "...";): Manually whitelist a single subnet.
How to add a firewall rule
Decision order for any new service port:
- Binds only to loopback? → No firewall rule needed. Nginx proxies via
127.0.0.1or[::1]. - Needs global reach? →
mkFirewallGlobal { tcp = [...]; udp = [...]; comment = "..."; } - LAN or Tailscale only? →
mkFirewallFor { tcp = [...]; udp = [...]; comment = "..."; } - 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.
| Service | Protocol | Port(s) | Why global |
|---|---|---|---|
| Tailscale | TCP+UDP | 41641 | DERP relay + coordination |
| WireGuard | UDP | 51820 | VPN peer connections |
| Sing-box proxy | TCP+UDP | 2080+ | SOCKS/HTTP proxy |
| FRP server | TCP+UDP | (configurable) | Tunnel endpoints |
| Nginx HTTP/HTTPS | TCP | 80, 443 | Web server (when openFirewall = true) |
| Nginx QUIC | UDP | 443 | HTTP/3 |
| Nginx mail proxy | TCP | 25, 465, 587, 143, 993 | SMTP/IMAP (when stalwartStreamProxy enabled) |
| Syncthing relay | TCP | 22067 | Global P2P relay |
| Transmission | TCP+UDP | 51413 | BitTorrent 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.
| Service | Protocol | Port(s) | Module file |
|---|---|---|---|
| Syncthing | TCP | 22000 | services/sharing/syncthing.nix |
| Syncthing | UDP | 22000, 21027 | services/sharing/syncthing.nix |
| Ollama | TCP | 11434 | services/ai/ollama.nix |
| Jellyfin | TCP | 8096 | services/media/jellyfin.nix |
| Sunshine | TCP | 47984, 47989, 48010 | services/media/sunshine.nix |
| Sunshine | UDP | 47998-48000, 48002, 48010 | services/media/sunshine.nix |
| Samba | TCP | 445, 139, 5357 | services/sharing/samba.nix |
| Samba | UDP | 137-138, 3702 | services/sharing/samba.nix |
| SSH | TCP | 22 | services/net/ssh.nix |
| KDE Connect | TCP+UDP | 1714-1764 | desktop/apps/kdeconnect.nix |
| LocalSend | TCP+UDP | 53317 | desktop/apps/localsend.nix |
| Spotify Connect | TCP | 57621 | desktop/apps/spotify.nix |
| Spotify Connect | UDP | 5353 | desktop/apps/spotify.nix |
| Steam Remote Play | TCP | 27036 | desktop/gaming/steam.nix |
| Steam Remote Play | UDP | 27031-27036 | desktop/gaming/steam.nix |
| CUPS printer | TCP+UDP | 631 | profiles/hardware/printer.nix |
| CUPS printer | UDP | 5353 | profiles/hardware/printer.nix |
| SANE scanner | TCP+UDP | 6566 | profiles/hardware/scaner.nix |
| Deskflow | TCP+UDP | 24800 | profiles/hardware/kvm.nix |
| Lan Mouse | TCP+UDP | 4242 | profiles/hardware/kvm.nix |
| WayVNC | TCP+UDP | 5901 | services/desktop/wayvnc.nix |
| Jitsi Meet | TCP | 5222, 5280, 5281, 5347 | services/media/jitsi-meet.nix |
| Jitsi Meet | UDP | 10000-20000 | services/media/jitsi-meet.nix |
| AdGuard DNS | TCP+UDP | 53 | services/net/adguardhome.nix |
| Home Assistant | TCP | 8123, 21063 | services/home/home-assistant.nix |
| Home Assistant | UDP | 5353 | services/home/home-assistant.nix |
| Stalwart mail | TCP | 25, 465, 587, 143, 993, 4190 | services/net/stalwart.nix |
Interface-scoped ports
Traffic accepted only on a specific network interface.
| Service | Protocol | Port | Interface | Module |
|---|---|---|---|---|
| MicroVM DHCP | UDP | 67 | microvm bridge | services/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.
| Service | Listen address | Nginx proxy |
|---|---|---|
| Affine | 0.0.0.0:3010 (container, loopback proxy) | yes |
| Calibre | localhost | yes |
| Gotify | localhost | yes |
| JupyterHub | 127.0.0.1:8888 | yes |
| Linkwarden | 127.0.0.1:3001 | yes |
| Forgejo | *:3000 (upstream default) | yes |
| Hedgedoc | 127.0.0.1:3003 | yes |
| Paperless | 127.0.0.1:28981 | yes |
| Stirling PDF | 127.0.0.1:8083 | yes |
| Vaultwarden | 127.0.0.1:8222 | yes |
| Mealie | 127.0.0.1:9000 | yes |
| FreshRSS | fastcgi (no standalone port) | yes |
| Discourse | [::1]:8007 | yes |
| OnlyOffice | 127.0.0.1:8000 | yes |
| Kanidm | 127.0.0.1:8443 (HTTPS) | yes |
| Atuin | 127.0.0.1:8887 | yes |
| Anki Sync | 127.0.0.1:27701 (configurable via listenAddress) | yes |
| Stalwart (HTTP) | 127.0.0.1:8085 | yes |
| LiteLLM | 127.0.0.1:4000 | yes |
| Gatus | 127.0.0.1:9091 | yes |
| sing-box (Clash API) | 127.0.0.1:9090 | no |
| jitsi-videobridge | 127.0.0.1:8080 | no (internal) |
| jicofo | 127.0.0.1:8858 | no (internal) |
| jitsi-excalidraw | 127.0.0.1:3002 + :9097 | via Jitsi vhost |
Note: Bind addresses vary (
127.0.0.1,[::1],*) because they come from different upstream nixpkgs defaults. What matters is that theproxyPassin each nginx vhost matches the actual bind address. Services using[::1]needproxyPass = "http://[::1]:...", nothttp://127.0.0.1:.... Where possible, prefer overriding the upstreamhostdefault to"127.0.0.1"(explicit IPv4) — avoids IPv6 resolution ambiguity (Node.js resolveslocalhost→::1). Seehomelab-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:
- Relay — Syncthing connects outbound to the private relay on
vps-pacman. Outbound isn’t filtered. - Tailscale — Syncthing ports (TCP 22000, UDP 22000, 21027) are explicitly allowed from Tailscale IPs via
mkFirewallFor. - SSH tunnel —
sshTunnel.enablewraps 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"(was0.0.0.0) - Prometheus exporters:
listenAddress = "127.0.0.1"(was0.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 bymkFirewallFor - Samba: all interfaces —
hosts allow+mkFirewallFordefense-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.