Day 37 — Firewall discipline

Updated

July 30, 2026

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 backend

Theory 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 default

Exact 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 listening

Fill 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 .#lab

Confirm 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 enabled

Wire 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.0

Lab 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 reality

Lab 8 — Ping policy decision

networking.firewall.allowPing = true; # or false

Write 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.