N150 NixOS router

May 23, 2025

The why

For years, I’ve relied on two Google Nest Wi-Fi units to handle my home networking needs. To be fair, they’ve served me well—stable, low-maintenance, and good enough for day-to-day use. That’s probably why I kept postponing any kind of upgrade. But eventually, I reached a point where “good enough” wasn’t cutting it anymore.

There were a few things pushing me to make a change:

  • The max speed of the Google unit was 800mbit.
  • I wanted more control over my network, and features such as VLAN tagging.
  • And I was tired of managing everything through the Google Home app instead of using SSH.

Hardware

A while back, I bought an N100-based motherboard and CPU combo from AliExpress. It was compact, power-efficient, and came with multiple NICs, M.2 slots, and SATA ports—plenty for most DIY network projects. It worked flawlessly and honestly exceeded expectations.

For the router I picked up an N150-based board, added an M.2 SSD and 16GB of DDR5 RAM (which is probably overkill, but useful for future flexibility), and got to work. Besides routing, I also plan to run a few lightweight networking services on it. I already have a separate server for heavier workloads, but it’s nice to spread things out and keep the network-focused features close to the router itself.

Unboxing and installation

The N150 device was packaged quite neatly and contained all the specified parts. The device feels quite sturdy. I forgot to order a wall mount, which topton sells via Aliexpress, so I decided to print one instead.

Installing the M.2 SSD and RAM was quite simple. The device opened easily by removing four screws in the bottom.

It booted directly, recognizing the RAM and disk without issue. Inspecting BIOS settings showed appropriate settings to my knowledge. VT-d enabled. i226 LAN PXE was disabled, but I don’t plan to use it.

Installing NixOS went without flaw. Its always a bit of a chore setting up secrets management on a new device but it’s fairly straight forward. I use sops as well as git-agecrypt. The installer correctly set the hardware.cpu.intel.updateMicrocode to the same value as nixos-hardware.

System Configuration

The N150 hardware has some known issues with power management that are common among budget mini PCs. BIOS options for power limiting and advanced C-state control are often limited or non-functional.

I applied some power management configuration. Please get in touch if you’ve got better ideas on how to do it.

{
environment.systemPackages = [
  pkgs.lm_sensors
  pkgs.powertop
];

services.tlp.enable = true;
powerManagement = {
  enable = true;
  powertop.enable = true;
  cpuFreqGovernor = "powersave";
};

boot.kernelParams = [
  "intel_pstate=passive"
  "processor.max_cstate=6"
];

boot.blacklistedKernelModules = [ "i915" ];
}

The results seems decent for now. At idle, the system maintains:

  • CPU temperatures: 42-46°C across all cores
  • NVMe SSD: 59-65°C
  • C-state behavior: Individual cores spend 99% of time in deep sleep states (C6/C7), while the package maintains lighter sleep (C2).

The device is clearly warm/hot to the touch. But the provided temperatures shows it’s acceptable for now. I plan to wall mount it, which according to someone at STH forums improved cooling somewhat.

When configuring the network settings and testing it became clear there is an issue when inserting in to NIC ports which are down, it will not go up again. It turned out that PCIe port power management and EEE is responsible. Setting this resulted in everything working as it should.

boot.kernelParams = [
  "pcie_port_pm=off" # Disable PCIe port power management
  "igc.eee_enable=0" # Disable Energy Efficient Ethernet
];

Network configuration

A benefit of doing this on NixOS is having the configuration evaluated before runtime. This makes it easy to catch errors, like the one below I encountered when mistyping a firewall rule.

ruleset.conf:13:60-86: Error: Statement after terminal statement has no effect
iifname "enp1s0" tcp dport 22 limit rate 5/minute accept commen "Allow SSH from WAN"

Another aspect I enjoyed was evaluating and building the router system remotely from my workstation, deploying it over SSH. This both saves time and makes for a great workflow experience.

nixos-rebuild switch --flake .#io --target-host [email protected] --use-remote-sudo

For the networking stack I settled for:

  • systemd-networkd
  • nftables
  • Unbound
  • Kea DHCP

Flake

Im fairly certain there are some improvements to be done, and possibly some redundancies. I suspect it will be a work in progress for quite some while. But having this declarative nature (and rollbacks) is as always a huge benefit when iterating. Should you the reader have any questions or suggestions feel free to reach out by mail.

{pkgs, ...}: let
  lanSubnet = "10.0.0";
  lanCidr = "${lanSubnet}.0/24";
  routerIp = "${lanSubnet}.1";
  dhcpStart = "${lanSubnet}.100";
  dhcpEnd = "${lanSubnet}.200";

  # Machines
  charon = "${lanSubnet}.15";
  makemake = "${lanSubnet}.10";
  encke = "${lanSubnet}.156";

  ulaPrefix = "fd99:d22a:eddd:7e75";
in {
  boot.kernel.sysctl = {
    "net.ipv4.conf.all.forwarding" = true;
    "net.ipv4.conf.default.rp_filter" = 1;
    "net.ipv4.conf.enp1s0.rp_filter" = 1;
    "net.ipv4.conf.br-lan.rp_filter" = 1;

    "net.ipv6.conf.all.forwarding" = true;
    "net.ipv6.conf.all.accept_ra" = 0;
    "net.ipv6.conf.all.autoconf" = 0;
    "net.ipv6.conf.all.use_tempaddr" = 0;
  };

  boot.kernelParams = [
    "pcie_port_pm=off" # Disable PCIe port power management
    "igc.eee_enable=0" # Disable Energy Efficient Ethernet
  ];

  networking = {
    hostName = "io";
    useNetworkd = true;
    useDHCP = false;
    networkmanager.enable = false;
    firewall.enable = false;

    nftables = {
      enable = true;
      tables = {
        filterV4 = {
          family = "ip";
          content = ''
            chain input {
              type filter hook input priority 0; policy drop;
              iifname "lo" accept
              iifname "br-lan" accept
              iifname "enp1s0" ct state established,related accept
              iifname "enp1s0" ip protocol icmp accept
            }
            chain forward {
              type filter hook forward priority 0; policy drop;
              iifname "br-lan" oifname "enp1s0" accept
              iifname "enp1s0" oifname "br-lan" ct state established,related accept
              iifname "enp1s0" oifname "br-lan" ip daddr ${makemake} tcp dport { 80, 443, 25, 465, 993 } accept
            }
          '';
        };
        natV4 = {
          family = "ip";
          content = ''
            chain prerouting {
              type nat hook prerouting priority -100;
              iifname "enp1s0" tcp dport { 80, 443, 25, 465, 993 } dnat to ${makemake}
            }
            chain postrouting {
              type nat hook postrouting priority 100;
              oifname "enp1s0" masquerade
            }
          '';
        };
        filterV6 = {
          family = "ip6";
          content = ''
            chain input {
              type filter hook input priority 0; policy drop;
              iifname "lo" accept
              iifname "br-lan" accept
              iifname "enp1s0" ct state established,related accept
              iifname "enp1s0" icmpv6 type {
                destination-unreachable, packet-too-big, time-exceeded,
                parameter-problem, nd-router-advert, nd-neighbor-solicit,
                nd-neighbor-advert
              } accept
              iifname "enp1s0" udp dport dhcpv6-client udp sport dhcpv6-server accept
            }
            chain forward {
              type filter hook forward priority 0; policy drop;
              iifname "br-lan" oifname "enp1s0" accept
              iifname "enp1s0" oifname "br-lan" ct state established,related accept
            }
          '';
        };
      };
    };
  };

  systemd.network = {
    enable = true; 

    # Define the bridge device itself
    netdevs."20-br-lan" = {
      netdevConfig = {
        Kind = "bridge";
        Name = "br-lan";
      };
    };

    networks = {
      # WAN interface configuration (enp1s0)
      "20-wan" = {
        matchConfig.Name = "enp1s0";
        networkConfig = {
          DHCP = "yes"; 
          IPv4Forwarding = true;
          IPv6Forwarding = true;
          IPv6AcceptRA = true; 
        };
        dhcpV6Config.WithoutRA = "solicit"; 
        linkConfig.RequiredForOnline = "routable";
      };

      # LAN bridge (br-lan) configuration
      "10-br-lan" = {
        matchConfig.Name = "br-lan";
        # Addresses for the router on the LAN bridge
        address = [
          "${routerIp}/24"
          "${ulaPrefix}::1/64" 
        ];
        networkConfig = {
          ConfigureWithoutCarrier = true; 
          DHCPPrefixDelegation = true; 
          IPv6SendRA = true; 
          IPv6AcceptRA = false; 
        };
        bridgeConfig = {}; 
        ipv6Prefixes = [
          {
            AddressAutoconfiguration = true;
            OnLink = true;
            Prefix = "${ulaPrefix}::/64";
          }
        ];
        linkConfig.RequiredForOnline = "no";
      };

      # Configure enp2s0 to be part of br-lan
      "30-enp2s0-lan" = {
        matchConfig.Name = "enp2s0";
        networkConfig = {
          Bridge = "br-lan";
          ConfigureWithoutCarrier = true;
        };
      };
      # Configure enp3s0 to be part of br-lan
      "30-enp3s0-lan" = {
        matchConfig.Name = "enp3s0";
        networkConfig = {
          Bridge = "br-lan";
          ConfigureWithoutCarrier = true;
        };
      };
      # Configure enp4s0 to be part of br-lan
      "30-enp4s0-lan" = {
        matchConfig.Name = "enp4s0";
        networkConfig = {
          Bridge = "br-lan";
          ConfigureWithoutCarrier = true;
        };
      };
    };
  };

  services.unbound = {
    enable = true;
    settings = {
      server = {
        interface = [
          "127.0.0.1"
          "::1"
          routerIp
          "${ulaPrefix}::1"
        ];
        access-control = [
          "127.0.0.0/8 allow"
          "::1 allow"
          "${lanCidr} allow"
          "${ulaPrefix}::/64 allow"
          "0.0.0.0/0 refuse"
          "::0/0 refuse"
        ];
        cache-min-ttl = 0;
        cache-max-ttl = 86400;
        do-tcp = true;
        do-udp = true;
        prefetch = true;
        num-threads = 1;
        so-reuseport = true;
        local-zone = ''
          "lan." static
        '';
        local-data = [
          # Router
          ''"io.lan. IN A ${routerIp}"''
          ''"io.lan. IN AAAA ${ulaPrefix}::1"''

          # Machines
          ''"charon.lan. IN A ${charon}"''
          ''"makemake.lan. IN A ${makemake}"''
          ''"encke.lan. IN A ${encke}"''

          # Services
          ''"mail.stark.pub. IN A ${makemake}"''
        ];
      };
      forward-zone = [
        {
          name = ".";
          forward-addr = [
            "1.1.1.1@853#cloudflare-dns.com"
            "1.0.0.1@853#cloudflare-dns.com"
            "2606:4700:4700::1111@853#cloudflare-dns.com"
            "2606:4700:4700::1001@853#cloudflare-dns.com"
          ];
          forward-tls-upstream = true;
        }
      ];
    };
  };

  services.kea.dhcp4 = {
    enable = true;
    settings = {
      interfaces-config.interfaces = ["br-lan"];
      lease-database = {
        name = "/var/lib/kea/dhcp4-leases.csv";
        type = "memfile";
        persist = true;
        lfc-interval = 3600;
      };
      valid-lifetime = 86400;
      renew-timer = 43200;
      rebind-timer = 75600;
      subnet4 = [
        {
          id = 1;
          subnet = lanCidr;
          pools = [
            {
              pool = "${dhcpStart} - ${dhcpEnd}";
            }
          ];
          reservations = [
            {
              hw-address = "f0:2f:74:de:91:0a";
              ip-address = "${charon}";
              hostname = "charon";
            }
            {
              hw-address = "00:d0:b4:02:bb:3c";
              ip-address = "${makemake}";
              hostname = "makemake";
            }
            {
              hw-address = "52:54:00:a7:db:fe";
              ip-address = "${encke}";
              hostname = "encke";
            }
          ];
          option-data = [
            {
              name = "routers";
              data = routerIp;
            }
            {
              name = "domain-name-servers";
              data = routerIp;
            }
            {
              name = "domain-name";
              data = "lan";
            }
          ];
        }
      ];
    };
  };
}

RSS
https://blog.stark.pub/posts/feed.xml