Security Hardening Patterns

Updated

September 12, 2026

Security Hardening Patterns

Open ports, writable /, and JIT-friendly sysctls are the defaults of a general-purpose distro. The boring default on a desk server is: firewall on, DynamicUser + ProtectSystem=strict on custom units, a small sysctl set, and no hardening that you have not booted.

Mental model

Defense in this book is already mostly done if you followed earlier chapters: immutable store, listed firewall ports, no plaintext secrets, SSH keys only. This chapter is the extra kernel/unit knobs.

Layer Boring control
Network networking.firewall.enable = true;
Units ProtectSystem, ProtectHome, NoNewPrivileges, DynamicUser
Kernel boot.kernel.sysctl for kptr, bpf, syncookies
Firmware microcode (hardware chapter)
Audit systemd-analyze security <unit>

MemoryDenyWriteExecute = true breaks Node, JVM, and some Python. Test, then enable. A unit that will not start is not hardened; it is down.

NixOS 26.05’s firewall is nftables. networking.firewall.enable = true is enough; do not also enable iptables “for the gist.” ip_forward only on the WireGuard gateway.

Do not paste a 40-line gist onto systemd.services.nginx until systemctl cat nginx shows you still need those flags. Fighting the module produces restart loops. linuxPackages_hardened is not the desk default — firmware, ZFS, and vendor modules break. Measure, then maybe.

Worked examples

Case 1: Sandbox a desk unit

Save as secure-service.nix:

# secure-service.nix
{ pkgs, ... }:

{
  systemd.services.desk-api = {
    description = "Desk API";
    after = [ "network.target" ];
    wantedBy = [ "multi-user.target" ];
    serviceConfig = {
      ExecStart = "${pkgs.hello}/bin/hello";
      DynamicUser = true;
      ProtectSystem = "strict";
      ProtectHome = true;
      PrivateTmp = true;
      PrivateDevices = true;
      ProtectKernelTunables = true;
      ProtectKernelModules = true;
      ProtectControlGroups = true;
      RestrictRealtime = true;
      RestrictSUIDSGID = true;
      NoNewPrivileges = true;
      LockPersonality = true;
      SystemCallArchitectures = "native";
      RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
      CapabilityBoundingSet = "";
      AmbientCapabilities = "";
      UMask = "0077";
    };
  };
}
sudo nixos-rebuild switch
systemd-analyze security desk-api --no-pager | head -30

The score is a hint, not a compliance certificate. Read the exposed lines and decide.

Case 2: Kernel sysctl baseline

Save as hardening.nix:

# hardening.nix
{
  networking.firewall.enable = true; # nftables on 26.05

  boot.kernel.sysctl = {
    "kernel.unprivileged_bpf_disabled" = 1;
    "kernel.kptr_restrict" = 2;
    "kernel.yama.ptrace_scope" = 1;
    "net.ipv4.tcp_syncookies" = 1;
    "net.ipv4.conf.all.rp_filter" = 1;
    "net.ipv4.ip_forward" = 0;
  };
}

Turn ip_forward on only on the WireGuard gateway. A worker node that forwards is a mistake.

sysctl net.ipv4.ip_forward

0 on app-01. 1 on gw-01 only, and listed in the threat model.

Case 3: nixpkgs service modules already sandbox

# nginx-harden.nix
{
  services.nginx.enable = true;
  services.nginx.virtualHosts."desk.internal".locations."/".return = "204";
  networking.firewall.allowedTCPPorts = [ 80 ];
}
systemctl cat nginx | grep -E 'Protect|Private|NoNew'

Do not fight those with mkForce until you have a journal that says why.

Case 4: JIT exception

# node-api.nix
{
  systemd.services.desk-node.serviceConfig = {
    DynamicUser = true;
    ProtectSystem = "strict";
    # Node needs a writable executable mapping:
    MemoryDenyWriteExecute = false;
  };
}

Document the exception next to the option. A future reader will otherwise “fix” it and page themselves.

Case 5: Analyze before and after

systemd-analyze security sshd --no-pager | tail -5
systemd-analyze security desk-api --no-pager | tail -5

sshd will never look like a DynamicUser hello binary. Compare a unit to itself after a change, not to sshd.

The trap

The trap is copying a 40-line hardening gist onto every unit, including ones that need /home, BPF, or devices. The activation succeeds; the service dies; you disable the firewall to “debug.” Add flags one at a time. Keep the firewall on.

The other trap is MemoryDenyWriteExecute on Go with CGO off (usually fine) copied blindly onto Node (usually not).

A third: RestrictAddressFamilies without AF_UNIX so the unit cannot talk to the journal / D-Bus and looks “broken.” A fourth: linuxPackages_hardened on a box that needs NVIDIA or ZFS.

The boring rule

  • Firewall (nftables), keys, secrets, and an immutable store first. Sysctls second.
  • DynamicUser + ProtectSystem=strict + RestrictAddressFamilies on your network daemons.
  • MemoryDenyWriteExecute only after the app starts under it.
  • ip_forward only on routers. Hardened kernel is optional, not default.
  • systemd-analyze security as a diff tool, not a scoreboard. Do not mkForce module sandboxes.

Try this

  1. systemd-analyze security desk-api before and after Case 1. Note which checks flipped.
  2. Set MemoryDenyWriteExecute = true on a Node unit in a VM, start it, read the journal, set it false.
  3. sysctl net.ipv4.ip_forward on a worker vs the gateway.
  4. systemctl cat nginx on 26.05 and list sandbox flags the module already set.