Networking, Firewall, and Security

Updated

September 12, 2026

Networking, Firewall, and Security

Ad-hoc iptables -A and a NetworkManager GUI click are gone after a rebuild — or worse, they survive in unmanaged /etc and fight the next activation. The boring default is: firewall on, ports listed in Nix, NetworkManager on laptops, systemd-networkd on servers, secrets as files.

Mental model

NixOS enables a firewall by default (networking.firewall.enable is true). Incoming is denied unless you or a module open it. services.openssh.enable = true opens 22 unless you tell it not to (openFirewall = false on modules that offer that flag).

Machine Network backend
Desk laptop, Wi-Fi networking.networkmanager.enable = true;
VM, cloud, rack server networking.useNetworkd = true; (systemd-networkd)

Do not enable both and hope. Pick one.

26.05 uses nftables for networking.firewall unless you set networking.nftables.enable = false (legacy iptables). nft list ruleset is the boring inspect command. networking.firewall.extraInputRules is an nft snippet when a typed option is missing — still better than a page of extraCommands.

internet
   │
   ▼
nftables/iptables  ←  networking.firewall.allowedTCPPorts
   │
   ▼
sshd / nginx / desk-api

WireGuard, Tailscale, and similar are modules (networking.wireguard, services.tailscale). They are not extra packages you systemctl start by hand.

Worked examples

Case 1: Hostname, DNS, firewall allow-list

Save as networking_config.nix:

# networking_config.nix
{
  networking = {
    hostName = "desk-gateway";
    domain = "internal.corp";

    firewall = {
      enable = true;
      allowPing = true; # ICMP; set false on a bastion if you mean it
      allowedTCPPorts = [ 22 8080 ];
      allowedTCPPortRanges = [
        { from = 9000; to = 9010; }
      ];
    };

    nameservers = [ "1.1.1.1" "9.9.9.9" ];
  };
}
sudo nixos-rebuild switch
sudo nft list ruleset | head
# or, if still on iptables backend:
sudo iptables -L INPUT -n | head

Port 8080 is open because you listed it. Port 3000 is not. A node app that binds :3000 is reachable on localhost only until you add 3000.

Case 2: systemd-networkd static address (server)

Save as networkd.nix:

# networkd.nix
{
  networking.useDHCP = false;
  networking.useNetworkd = true;
  networking.networkmanager.enable = false;

  systemd.network.networks."10-lan" = {
    matchConfig.Name = "en*";
    networkConfig = {
      Address = "10.20.0.10/24";
      Gateway = "10.20.0.1";
      DNS = "10.20.0.1";
    };
  };
}

Match on en* (or a by-id name) in the lab. On metal, prefer matchConfig.MACAddress or Name = "enp1s0" after you have seen networkctl status.

networkctl status
ip -br addr

Case 3: NetworkManager on the workstation

Save as nm.nix:

# nm.nix
{
  networking.networkmanager.enable = true;
  networking.useNetworkd = false;
  networking.firewall.enable = true;
}

Users who should click Wi-Fi:

users.users.deskadmin.extraGroups = [ "networkmanager" ];

Do not also set networking.interfaces.eth0.ipv4.addresses unless you know you want NixOS’s scripted networking instead. One backend.

Case 4: WireGuard with a key file

Save as wireguard.nix:

# wireguard.nix
{
  networking.firewall.allowedUDPPorts = [ 51820 ];

  networking.wireguard.interfaces.wg0 = {
    ips = [ "10.100.0.2/24" ];
    listenPort = 51820;
    privateKeyFile = "/run/secrets/wg-desk.key";

    peers = [
      {
        publicKey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
        allowedIPs = [ "10.100.0.0/24" ];
        endpoint = "vpn.desk.internal:51820";
        persistentKeepalive = 25;
      }
    ];
  };
}

privateKeyFile is a path. The key bytes are not in the flake. Generate with wg genkey on the machine (or decrypt with sops) and persist that file (impermanence: /persist).

sudo systemctl status wg-quick-wg0
# or wg0 depending on the module's unit name
ip -br addr show wg0

Case 5: Trust a VPN interface without opening the world

# trusted.nix
{
  networking.firewall = {
    enable = true;
    trustedInterfaces = [ "lo" "wg0" ];
    allowedTCPPorts = [ 22 ];
  };
}

Traffic from wg0 is trusted. The public NIC still only has 22. That is how the desk API can listen on 10.100.0.2:8080 without listing 8080 on the internet.

Per-interface holes (no trust of the whole overlay):

{
  networking.firewall.interfaces.wg0.allowedTCPPorts = [ 8080 5432 ];
}

Postgres on wg0 only. checkReversePath = "loose"; is for asymmetric routing (some VPNs); "strict" (default on many setups) drops martian sources. Do not flip it to "loose" to “make WireGuard work” until nft / journalctl -k says rpfilter is the drop.

The trap

The trap is networking.firewall.enable = false; “to debug the app,” then committing the host. The next deploy is an open machine.

Open allowedTCPPorts = [ 3000 ]; instead. Leave the firewall on.

The other trap is putting a WireGuard private key in the attrset (privateKey = "…"). World-readable store. Same as a password in configuration.nix.

A third trap: networking.firewall.extraCommands with a page of iptables. Those rules are not typed, not easily reviewed, and break when the backend is nftables. Prefer allowedTCPPorts, interfaces.<name>.allowedTCPPorts, and trustedInterfaces.

A fourth trap: deploying firewall rules or SSH port changes over SSH using nixos-rebuild switch directly. If you typo a port or misconfigure an interface, your connection drops and the unbootable network state is saved as default. Use nixos-rebuild test --flake .#host first; if you lose access, a hypervisor or out-of-band reboot automatically reverts the machine.

The boring rule

  • Firewall stays on. List ports. Do not disable it to debug.
  • NetworkManager or networkd, not both.
  • VPN keys as privateKeyFile (sops / persist), never as Nix strings.
  • Test firewall and SSH updates with nixos-rebuild test before committing with switch.
  • Let service modules open their own ports, or set openFirewall deliberately.
  • trustedInterfaces for overlay NICs; or interfaces.<name>.allowedTCPPorts if you do not trust the whole mesh.
  • nftables backend. nft list ruleset. checkReversePath stays strict until proven.

Try this

  1. Add 8080 to allowedTCPPorts, switch, nc -l 8080 in one shell and nc $HOST 8080 from another VM. Remove 8080 and confirm the second connect hangs/refuses.
  2. sudo nft list ruleset | grep -i 22 (or iptables) after services.openssh.enable = true with default firewall — 22 should be there.
  3. Set allowPing = false on a lab VM, ping it from the host, then set it true again.
  4. Generate a WireGuard keypair with wg genkey | tee /tmp/k | wg pubkey and do not paste the private half into a .nix file. Put it in a file and reference the path.