Firewall discipline
Firewall discipline
Goal: Treat the NixOS firewall as a deny-by-default policy: open only ports you can name, tie openings to services, and audit with tools—not hope.
Why this chapter exists
Host networking introduced firewall basics. Services (proxy, DB, metrics) tempt:
networking.firewall.enable = false; # "lab"or:
networking.firewall.allowedTCPPorts = [ 22 80 443 5432 3000 9090 9100 /* … */ ];Without a story. This chapter is discipline: every open port has an owner service and a reason.
Theory 1 — NixOS firewall model
With networking.firewall.enable = true (default on many installs):
| Mechanism | Role |
|---|---|
| nftables/iptables backend | Implementation (prefer not hand-editing) |
allowedTCPPorts / UDP |
Host-wide opens |
allowedTCPPortRanges |
Ranges when unavoidable |
| Interface-specific options | Limit exposure to LAN interfaces |
| Service modules | Often open ports via openFirewall options |
Prefer service options
services.openssh.enable = true;
# many services offer:
# services.foo.openFirewall = true;That keeps port + service definition adjacent. Global lists become a last resort.
Theory 2 — Deny by default
{
networking.firewall = {
enable = true;
allowPing = true; # or false on hardened internet hosts
allowedTCPPorts = [ ];
allowedUDPPorts = [ ];
};
}Then add ports only with services. SSH often opened by services.openssh + related settings depending on config—verify rather than assume.
Check:
sudo nixos-option networking.firewall.allowedTCPPorts
sudo nft list ruleset 2>/dev/null | head -100
# or iptables-save depending on backendTheory 3 — Per-service matrix (living document)
Maintain docs/ports.md:
| Port | Proto | Service | Source scope | Module |
|---|---|---|---|---|
| 22 | TCP | sshd | admin IPs / world (lab) | hardening/ssh |
| 443 | TCP | caddy/nginx | world | reverse-proxy |
| 5432 | TCP | postgres | localhost only | data service |
If Postgres is localhost-only, do not open 5432 on the firewall.
Theory 4 — Localhost vs LAN vs world
| Scope | Pattern |
|---|---|
| Localhost only | Bind service to 127.0.0.1; no firewall open |
| LAN only | Firewall interface restrictions / not exposing WAN NIC |
| World | 80/443 typically; auth/TLS required |
# postgres listen addresses — keep lab default local
services.postgresql.enable = true;
# ensure listen_addresses = 'localhost' for lab defaultExact options depend on module—read search.nixos.org for your pin.
Theory 5 — Temporary debug holes
Bad habit: leave allowedTCPPorts = [ 3000 ] forever for a dev server.
Better:
- SSH tunnel:
ssh -L 3000:127.0.0.1:3000 lab - Or short-lived config on a branch
- Or VPN/Tailscale patterns (document if you use them)
Theory 6 — Interaction with containers (preview)
Containers and port publishes can bypass your mental model if you open host ports carelessly. Treat published ports as first-class firewall entries in docs/ports.md.
Theory 7 — Interfaces and trusted nets
networking.firewall = {
enable = true;
# trustedInterfaces = [ "tailscale0" ]; # only if real
# interfaces."eth0".allowedTCPPorts = [ 22 ]; # verify option shape on 26.05
};Prefer least exposure: admin SSH on a management interface if you have one; never open DB ports “because the app is in the same VPC narrative” without proof.
Cloud security groups
NixOS firewall is not a substitute for cloud SG/NSG rules—and vice versa. Document both layers in docs/ports.md when the lab is a VPS.
Theory 8 — Connection tracking
Allowing TCP 22 does not require opening random high ports for SSH. Related/established traffic is handled by the firewall state machine. Do not “open 1024–65535 for good measure.”
Theory 9 — Co-locate opens with services
# modules/services/proxy.nix
{
networking.firewall.allowedTCPPorts = [ 80 443 ];
services.caddy.enable = true;
# ...
}Deleting the service module should remove the hole—review PRs for orphaned ports.
Worked example — Tight SSH + HTTPS owned by proxy
# modules/common/firewall.nix
{ lib, ... }:
{
networking.firewall = {
enable = true;
allowPing = true;
allowedTCPPorts = [
# 80 443 added with the proxy module, not here forever empty-handed
];
};
}When proxy lands, open 80/443 in the proxy module file.
Worked example — Service openFirewall pattern
services.nginx.enable = true;
# services.nginx... openFirewall-style options — check 26.05 module
# Fallback explicit ownership in the same module file:
# networking.firewall.allowedTCPPorts = [ 80 443 ];Exercises
Exercise 1 — Audit current opens
ss -tulpn
sudo nft list ruleset 2>/dev/null | head -150Fill the ports table for current state.
Exercise 2 — Enable firewall explicitly
Ensure networking.firewall.enable = true. Rebuild carefully (console ready).
sudo nixos-rebuild test --flake .#labConfirm SSH still works from a new session.
Exercise 3 — Remove unjustified ports
Delete any “temporary” ports. Rebuild. Re-audit ss and ruleset.
Exercise 4 — Service-tied open (stub)
# modules/services/proxy-firewall.nix
# TLS chapter will open 80/443 here when proxy is enabledWire the idea that proxy owns those ports.
Exercise 5 — Localhost service experiment
Run a quick local listener or use an existing local-only service; confirm it is not reachable from another machine without a firewall hole and public bind.
# on lab: nc -l 127.0.0.1 9999
# from another host: should fail unless you opened the port AND bound 0.0.0.0Exercise 6 — ports.md committed
Commit docs/ports.md with the matrix. Update as services arrive.
Exercise 7 — Diff firewall after change
sudo nft list ruleset > /tmp/fw-after.txt
# compare mentally to docs/ports.mdExercise 8 — Ping policy decision
networking.firewall.allowPing = true; # or falseWrite one sentence: who needs ICMP on this host?
Exercise 9 — SSH tunnel habit
Practice one tunnel instead of opening a port:
ssh -L 9090:127.0.0.1:9090 labExercise 10 — Cloud SG note
If on a VPS, paste (redacted) SG rules into ports.md as a second table.
Exercise 11 — Orphan port hunt
Find any allowedTCPPorts entry without a matching enabled service. Remove or justify.
Exercise 12 — UDP inventory
ss -ulpnDocument any unexpected UDP listeners.
Exercise 13 — test generation safety
Use nixos-rebuild test when tightening rules so reboot returns to previous if you disconnect.
Exercise 14 — Pre-proxy checklist
List ports you expect after TLS + postgres chapters; mark which are localhost-only.
Common gotchas
| Symptom / mistake | What to do |
|---|---|
| Locked out of SSH | Console; previous generation; re-open 22 |
| Firewall on but service bound wrong | Fix listen address |
| Docker-ish publishes confuse audit | Inventory publish flags (Podman chapter) |
enable = false “for lab” |
Re-enable; use tunnels for debug |
| Open 5432 to world | Bind localhost; proxy/app only |
| Ruleset empty surprise | Backend differ; verify with ss too |
| Orphan ports after service delete | Review ports.md + module |
| Opened high port ranges | Close; use tunnels |
| Trusted interface too broad | Narrow trust |
Checkpoint
- Firewall enabled
- Every open port documented in
docs/ports.md - No unjustified temporary holes
- SSH verified after firewall changes
- Localhost vs world distinction understood
- Plan for proxy ports owned by proxy module
- Ping policy decided
- Tunnel practiced once
Journal (optional)
Write three personal gotchas before continuing.