Day 37 — Firewall discipline
Day 37 — Firewall discipline
Stage IV · ~4h
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 day exists
Day 15 introduced networking/firewall basics. Stage IV services (proxy, DB, metrics) tempt:
networking.firewall.enable = false; # "lab"or:
networking.firewall.allowedTCPPorts = [ 22 80 443 5432 3000 9090 9100 /* … */ ];Without a story. Today 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
# also inspect effective rules:
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 |
# example idea — postgres listen addresses
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 (out of scope unless you already use them)
Theory 6 — Interaction with containers (preview)
Containers and port publishes can bypass your mental model if you open host ports carelessly. Days 40–41: treat published ports as first-class firewall entries in docs/ports.md.
Worked example — Tight SSH + later HTTPS only
# modules/common/firewall.nix
{ lib, ... }:
{
networking.firewall = {
enable = true;
allowPing = true;
# Explicit list kept tiny; prefer service openFirewall flags
allowedTCPPorts = [
# 80 443 added on Day 38 with the proxy module
];
};
}When proxy lands:
# modules/services/proxy.nix
{
networking.firewall.allowedTCPPorts = [ 80 443 ];
# or services.caddy/nginx openFirewall-style options if available
}Lab 1 — Audit current opens
ss -tulpn
sudo nft list ruleset 2>/dev/null | head -150
# document everything listeningFill the ports table for current state.
Lab 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.
Lab 3 — Remove unjustified ports
Delete any “temporary” ports. Rebuild. Re-audit ss and ruleset.
Lab 4 — Service-tied open (dry for tomorrow)
Add a comment module or stub:
# modules/services/proxy-firewall.nix
# Day 38 will open 80/443 here when proxy is enabledWire the idea that proxy owns those ports.
Lab 5 — Localhost service experiment
Run a quick local listener (non-Nix, temporary) or use an existing local-only service; confirm it is not reachable from another machine without a firewall hole.
# on lab: nc -l 127.0.0.1 9999
# from another host: should fail unless you opened the port AND bound 0.0.0.0Lab 6 — ports.md committed
Commit docs/ports.md with the matrix. Update it on Days 38–42 as services arrive.
Theory 7 — networking.firewall interfaces and trusted nets
networking.firewall = {
enable = true;
# trustedInterfaces = [ "tailscale0" ]; # example VPN iface — only if real
interfaces."eth0".allowedTCPPorts = [ 22 ]; # shape varies; verify options
};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.
Worked example — Service openFirewall pattern
# Prefer when the module offers it:
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 ];Co-locate port opens with the service module so deleting the service deletes the hole.
Lab 7 — Diff firewall across generations (optional)
# After a known-good generation, change ports, rebuild, then:
sudo nft list ruleset > /tmp/fw-after.txt
# compare mentally to docs/ports.md — docs must match realityLab 8 — Ping policy decision
networking.firewall.allowPing = true; # or falseWrite one sentence: who needs ICMP on this host? Internet-facing hardened hosts often disable; home LAN labs often keep ping for debugging.
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 (Day 41) |
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 |
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 Day 38 ports owned by proxy module
Commit
git add .
git commit -m "day37: firewall discipline and ports inventory"Write three personal gotchas before continuing.
Tomorrow
Day 38 — TLS front door: ACME + Caddy or Nginx—one reverse-proxy pattern with HTTPS to a backend.