Security Hardening Patterns
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 -30The 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_forward0 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 -5sshd 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+RestrictAddressFamilieson your network daemons.MemoryDenyWriteExecuteonly after the app starts under it.ip_forwardonly on routers. Hardened kernel is optional, not default.systemd-analyze securityas a diff tool, not a scoreboard. Do notmkForcemodule sandboxes.
Try this
systemd-analyze security desk-apibefore and after Case 1. Note which checks flipped.- Set
MemoryDenyWriteExecute = trueon a Node unit in a VM, start it, read the journal, set it false. sysctl net.ipv4.ip_forwardon a worker vs the gateway.systemctl cat nginxon 26.05 and list sandbox flags the module already set.