Firewall discipline

Updated

July 30, 2026

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 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
# postgres listen addresses — keep lab default local
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 (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 -150

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

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

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

Exercise 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.md

Exercise 8 — Ping policy decision

networking.firewall.allowPing = true; # or false

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

Exercise 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 -ulpn

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