← Back to DevBytes

Securing NixOS Servers: A Practical Checklist

Introduction to Securing NixOS Servers

NixOS is a declarative Linux distribution built on the Nix package manager. Unlike traditional distributions where system configuration is scattered across files in /etc, NixOS uses a single configuration file (typically /etc/nixos/configuration.nix) to describe the entire system state. This declarative approach offers a unique advantage for security: your security posture becomes auditable, reproducible, and version-controllable.

This tutorial walks through a practical checklist for hardening NixOS servers. Each section provides concrete configuration snippets you can drop into your own configuration.nix file. By the end, you will have a hardened baseline suitable for production workloads.

Why NixOS Security Matters

Traditional Linux distributions suffer from configuration drift — administrators make changes over time that are poorly documented, easily forgotten, and difficult to audit. NixOS eliminates this problem by making every system change explicit in code. When you secure a NixOS server, you are writing security policy as code, which means:

However, NixOS is not secure by default. Out of the box, it ships with conveniences that are inappropriate for internet-facing servers. The checklist below addresses the most impactful hardening steps.

1. User and Access Management

Disable Root Login and Enforce SSH Keys

The first step is to eliminate password-based authentication and direct root access. Create a dedicated administrative user with sudo privileges and require SSH key authentication.

# configuration.nix
{ config, pkgs, ... }:

{
  users.users.admin = {
    isNormalUser = true;
    extraGroups = [ "wheel" ];
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExamplePublicKeyHere user@workstation"
    ];
  };

  # Require a password for sudo (prevents privilege escalation if SSH key is stolen)
  security.sudo = {
    enable = true;
    wheelNeedsPassword = true;
  };

  services.openssh = {
    enable = true;
    settings = {
      PermitRootLogin = "no";
      PasswordAuthentication = false;
      KbdInteractiveAuthentication = false;
      AllowUsers = [ "admin" ];
    };
  };
}

Notice the AllowUsers directive. This whitelists which users may connect via SSH, reducing the attack surface even if other accounts exist on the system.

Enforce Strong Password Hashing

For any local accounts that do require passwords, ensure the hashing algorithm is modern. NixOS defaults to yescrypt, but you should verify and configure it explicitly.

security.loginDefs.settings = {
  ENCRYPT_METHOD = "YESCRYPT";
  SHA_CRYPT_MIN_ROUNDS = 5000;
};

users.mutableUsers = false;

Setting users.mutableUsers = false prevents manual changes to user accounts via passwd or useradd. All user management must go through the NixOS configuration, which prevents shadow accounts from persisting unnoticed.

2. Firewall and Network Hardening

Enable the NixOS Firewall

NixOS uses nftables under the hood for its firewall module. Enable it and explicitly list the ports you need open. Everything else is denied by default.

networking = {
  firewall = {
    enable = true;
    allowedTCPPorts = [ 22 80 443 ];
    allowedUDPPorts = [ ];
    # Reject instead of drop for cleaner failure modes
    rejectPackets = true;
    # Log dropped packets for monitoring
    logRefusedConnections = true;
    logRefusedPackets = true;
  };

  # Disable IPv6 if unused to reduce attack surface
  enableIPv6 = false;
};

Rate-Limit SSH Connections

Even with key-only authentication, SSH brute-force attempts generate noise and can be used in denial-of-service attacks. Use the firewall to rate-limit inbound SSH connections.

networking.firewall.extraCommands = ''
  iptables -A INPUT -p tcp --dport 22 -m state --state NEW -m recent --set --name ssh
  iptables -A INPUT -p tcp --dport 22 -m state --state NEW -m recent --update --seconds 60 --hitcount 4 --name ssh -j DROP
'';

networking.firewall.extraStopCommands = ''
  iptables -D INPUT -p tcp --dport 22 -m state --state NEW -m recent --set --name ssh 2>/dev/null || true
  iptables -D INPUT -p tcp --dport 22 -m state --state NEW -m recent --update --seconds 60 --hitcount 4 --name ssh -j DROP 2>/dev/null || true
'';

This allows at most three new SSH connections per minute from any single source IP. The fourth and subsequent attempts within that window are dropped.

3. Kernel and System Hardening

Apply Kernel Security Parameters

The Linux kernel exposes many tunables via sysctl. NixOS lets you set these declaratively. The following settings disable common attack vectors.

boot.kernel.sysctl = {
  # Prevent kernel address leaks
  "kernel.kptr_restrict" = 2;
  "kernel.dmesg_restrict" = 1;
  "kernel.perf_event_paranoid" = 2;

  # Restrict eBPF to root
  "kernel.unprivileged_bpf_disabled" = 1;

  # Disable core dumps for setuid binaries
  "fs.suid_dumpable" = 0;

  # Harden symbolic link and hard link following
  "fs.protected_symlinks" = 1;
  "fs.protected_hardlinks" = 1;
  "fs.protected_fifos" = 2;
  "fs.protected_regular" = 2;

  # IP forwarding off unless this is a router
  "net.ipv4.ip_forward" = 0;
  "net.ipv6.conf.all.forwarding" = 0;

  # Reverse path filtering
  "net.ipv4.conf.all.rp_filter" = 1;
  "net.ipv4.conf.default.rp_filter" = 1;

  # Ignore ICMP broadcasts (smurf attacks)
  "net.ipv4.icmp_echo_ignore_broadcasts" = 1;

  # Log martian packets
  "net.ipv4.conf.all.log_martians" = 1;

  # Disable source routing
  "net.ipv4.conf.all.accept_source_route" = 0;
  "net.ipv6.conf.all.accept_source_route" = 0;

  # TCP SYN cookies
  "net.ipv4.tcp_syncookies" = 1;
};

Enable Kernel Module Restrictions

By default, any process with sufficient privileges can load kernel modules. Restricting module loading prevents an attacker from introducing malicious kernel code after exploitation.

boot.kernelModules = [ ];
boot.blacklistedKernelModules = [
  # Disable uncommon filesystems and protocols
  "cramfs"
  "freevxfs"
  "jffs2"
  "hfs"
  "hfsplus"
  "squashfs"
  "udf"
  "vfat"
  # Disable uncommon network protocols
  "dccp"
  "sctp"
  "rds"
  "tipc"
  # Disable Bluetooth if unused
  "bluetooth"
  "btusb"
];

# Prevent loading additional modules at runtime
security.lockKernelModules = false; # Set true only if you load all needed modules at boot

Be cautious with security.lockKernelModules. If your workload requires loading modules after boot (for example, container runtimes), leave it disabled and rely on blacklisting instead.

4. Service Isolation with systemd

Harden Individual Services

NixOS generates systemd unit files for every service. You can override the security settings of any service using systemd.services.<name>.serviceConfig. This is one of the most powerful hardening features in NixOS.

systemd.services.nginx.serviceConfig = {
  # Run as a non-root user
  User = "nginx";
  Group = "nginx";

  # Filesystem isolation
  ProtectSystem = "strict";
  ProtectHome = true;
  PrivateTmp = true;
  PrivateDevices = true;
  ProtectKernelTunables = true;
  ProtectKernelModules = true;
  ProtectControlGroups = true;
  ProtectClock = true;
  ProtectHostname = true;
  ProtectProc = "invisible";
  ProcSubset = "pid";

  # Network and capability restrictions
  RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
  RestrictNamespaces = true;
  RestrictRealtime = true;
  RestrictSUIDSGID = true;
  LockPersonality = true;
  MemoryDenyWriteExecute = true;
  RemoveIPC = true;
  NoNewPrivileges = true;

  # Capabilities
  CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" "CAP_NET_RAW" ];
  AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" "CAP_NET_RAW" ];

  # System call filtering
  SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
  SystemCallArchitectures = [ "native" ];
};

These directives sandbox the service so that even if an attacker achieves code execution within it, they cannot access the broader system. The ProtectSystem, ProtectHome, and PrivateTmp options create filesystem namespaces that hide most of the host. The SystemCallFilter restricts which kernel syscalls the service may invoke, blocking many exploit techniques.

Use systemd-nspawn or Containers for Untrusted Workloads

For workloads you do not fully trust, use NixOS container support to provide stronger isolation than systemd service hardening alone.

containers.untrusted-app = {
  autoStart = true;
  privateNetwork = true;
  hostAddress = "10.10.0.1";
  localAddress = "10.10.0.2";

  config = { config, pkgs, ... }: {
    services.nginx.enable = true;
    networking.firewall.allowedTCPPorts = [ 80 ];

    # Apply the same hardening inside the container
    boot.kernel.sysctl = {
      "kernel.kptr_restrict" = 2;
    };
  };
};

5. Automatic Updates and Patch Management

Enable Unattended Upgrades

Keeping packages current is critical. NixOS provides a built-in module for automatic upgrades that rebuilds and activates a new system generation on a schedule.

system.autoUpgrade = {
  enable = true;
  dates = "04:00";
  randomizedDelaySec = "30min";
  allowReboot = false; # Set true if kernel updates require it
  flags = [
    "--update-input" "nixpkgs"
    "--commit-lock-file"
  ];
};

The randomizedDelaySec option prevents many servers from upgrading simultaneously, which reduces the blast radius if a bad update ships. Pair this with a monitoring alert that fires if the system has not upgraded within a defined window.

Use the NixOS Unstable Channel with Caution

For production servers, pin your channel to a stable release and update deliberately rather than tracking nixos-unstable.

# Pin nixpkgs to a specific release
system.configurationRevision = "abc123def456"; # Optional: track git revision

# Use a specific nixpkgs version
nix.registry.nixpkgs.flake = github:NixOS/nixpkgs/nixos-24.05;

6. Auditing and Logging

Enable the Audit Daemon

Linux audit provides detailed logging of security-relevant events. Enable it to track file access, privilege changes, and system calls.

security.audit = {
  enable = true;
  rules = [
    # Watch for changes to the NixOS configuration
    "-w /etc/nixos/ -p wa -k nixos_config"
    # Log all sudo executions
    "-w /run/wrappers/bin/sudo -p x -k sudo"
    # Log failed login attempts
    "-w /var/log/lastlog -p wa -k logins"
    # Monitor user/group modifications
    "-w /etc/passwd -p wa -k identity"
    "-w /etc/group -p wa -k identity"
    "-w /etc/shadow -p wa -k identity"
  ];
};

Forward Logs to a Remote Server

Local logs are useless if an attacker can modify them. Forward logs to a central, hardened log collector.

services.journald = {
  extraConfig = ''
    SystemMaxUse=500M
    MaxRetentionSec=1month
    ForwardToSyslog=yes
  '';
};

services.rsyslogd = {
  enable = true;
  extraConfig = ''
    *.* @@log-collector.example.com:6514
  '';
};

For TLS-protected log forwarding, consider using services.fluentd or services.vector instead of rsyslog, as they offer richer transport options and native TLS support.

7. Fail2ban for Intrusion Prevention

Fail2ban monitors log files and temporarily bans IPs that show malicious behavior. It complements your firewall rate-limiting rules.

services.fail2ban = {
  enable = true;
  maxretry = 3;
  bantime = "1h";
  bantime-increment = {
    enable = true;
    multipliers = "1 2 4 8 16 32 64";
    maxtime = "168h";
  };
  jails = {
    sshd = {
      settings = {
        port = 22;
        filter = "sshd";
        logpath = "/var/log/auth.log";
      };
    };
    nginx-limit-req = {
      settings = {
        port = "http,https";
        filter = "nginx-limit-req";
        logpath = "/var/log/nginx/error.log";
        findtime = 600;
        maxretry = 10;
      };
    };
  };
};

8. File Integrity and Immutable Infrastructure

Make the Boot Partition Read-Only

NixOS already stores each generation in a separate path under /nix/store, which is read-only by design. You can further harden the system by making /boot read-only except during upgrades.

fileSystems."/boot" = {
  device = "/dev/disk/by-label/boot";
  fsType = "vfat";
  options = [ "ro" "defaults" ];
};

# Remount read-write during upgrades via a pre-switch hook
system.activationScripts.boot-rw = ''
  mount -o remount,rw /boot
'';

Enable AIDE for File Integrity Monitoring

services.aide = {
  enable = true;
  config = ''
    database_in=file:/var/lib/aide/aide.db
    database_out=file:/var/lib/aide/aide.db.new
    gzip_dbout=yes
    verbose=5

    report_url=file:/var/log/aide/aide.log
    report_url=stdout

    p = permissions
    i = inode
    n = number of links
    u = user
    g = group
    s = size
    b = block count
    m = mtime
    a = atime
    c = ctime
    S = check growing file
    md5 = md5 checksum
    sha1 = sha1 checksum
    sha256 = sha256 checksum
    rmd160 = rmd160 checksum
    tiger = tiger checksum

    FIPSR = p+i+n+u+g+s+m+c+acl+selinux+xattrs+sha256

    /boot   FIPSR
    /bin    FIPSR
    /sbin   FIPSR
    /lib    FIPSR
    /etc    FIPSR
    /usr    FIPSR
    !/var/log/.*
    !/var/spool/.*
  '';
};

9. Putting It All Together

The following is a consolidated baseline configuration combining the key hardening steps from this tutorial. Save it as /etc/nixos/hardening.nix and import it from your main configuration.nix.

# /etc/nixos/hardening.nix
{ config, pkgs, lib, ... }:

{
  # === User Management ===
  users.mutableUsers = false;
  security.sudo.wheelNeedsPassword = true;

  # === SSH Hardening ===
  services.openssh = {
    enable = true;
    settings = {
      PermitRootLogin = "no";
      PasswordAuthentication = false;
      KbdInteractiveAuthentication = false;
      X11Forwarding = false;
      AllowAgentForwarding = false;
      AllowTcpForwarding = false;
    };
  };

  # === Firewall ===
  networking.firewall = {
    enable = true;
    allowedTCPPorts = [ 22 443 ];
    rejectPackets = true;
    logRefusedConnections = true;
  };

  # === Kernel Hardening ===
  boot.kernel.sysctl = {
    "kernel.kptr_restrict" = 2;
    "kernel.dmesg_restrict" = 1;
    "kernel.perf_event_paranoid" = 2;
    "kernel.unprivileged_bpf_disabled" = 1;
    "fs.suid_dumpable" = 0;
    "fs.protected_symlinks" = 1;
    "fs.protected_hardlinks" = 1;
    "fs.protected_fifos" = 2;
    "fs.protected_regular" = 2;
    "net.ipv4.ip_forward" = 0;
    "net.ipv4.conf.all.rp_filter" = 1;
    "net.ipv4.conf.default.rp_filter" = 1;
    "net.ipv4.icmp_echo_ignore_broadcasts" = 1;
    "net.ipv4.conf.all.log_martians" = 1;
    "net.ipv4.conf.all.accept_source_route" = 0;
    "net.ipv4.tcp_syncookies" = 1;
  };

  boot.blacklistedKernelModules = [
    "cramfs" "freevxfs" "jffs2" "hfs" "hfsplus"
    "squashfs" "udf" "vfat" "dccp" "sctp" "rds"
    "tipc" "bluetooth" "btusb"
  ];

  # === Auditing ===
  security.audit = {
    enable = true;
    rules = [
      "-w /etc/nixos/ -p wa -k nixos_config"
      "-w /run/wrappers/bin/sudo -p x -k sudo"
      "-w /etc/passwd -p wa -k identity"
      "-w /etc/group -p wa -k identity"
      "-w /etc/shadow -p wa -k identity"
    ];
  };

  # === Fail2ban ===
  services.fail2ban = {
    enable = true;
    maxretry = 3;
    bantime = "1h";
  };

  # === Automatic Updates ===
  system.autoUpgrade = {
    enable = true;
    dates = "04:00";
    randomizedDelaySec = "30min";
  };

  # === Logging ===
  services.journald.extraConfig = ''
    SystemMaxUse=500M
    MaxRetentionSec=1month
  '';
}

Import it in your main configuration:

# /etc/nixos/configuration.nix
{ config, pkgs, ... }:

{
  imports = [
    ./hardware-configuration.nix
    ./hardening.nix
  ];

  # ... rest of your configuration
}

Apply the changes and create a new generation:

sudo nixos-rebuild switch

If anything breaks, roll back instantly:

sudo nixos-rebuild switch --rollback

Best Practices and Ongoing Maintenance

Managing Secrets with agenix

As a final best practice, here is a minimal example of integrating agenix so that secrets never appear in plaintext in your NixOS repository.

# Install agenix
{ inputs, ... }: {
  imports = [ inputs.agenix.nixosModules.default ];
  environment.systemPackages = [ inputs.agenix.packages.x86_64-linux.default ];
}

# Define a secret
age.secrets.databasePassword = {
  file = ./secrets/databasePassword.age;
  owner = "postgres";
  group = "postgres";
  mode = "0400";
};

# Reference it in a service
services.postgresql.initialScript = config.age.secrets.databasePassword.path;

Secrets are encrypted using age and decrypted at activation time using the host's SSH key. The plaintext never touches your repository.

Conclusion

Securing a NixOS server is fundamentally different from hardening a traditional Linux distribution because the entire security posture lives in a declarative configuration file. This tutorial covered a practical checklist spanning user management, SSH hardening, firewall configuration, kernel parameters, systemd service isolation, automatic updates, audit logging, intrusion prevention, and file integrity monitoring. The real power of NixOS is that every one of these controls is now a line of code in your repository — auditable, reviewable, and reproducible. Start with the consolidated baseline, adapt it to your workload, keep your configuration under version control, and treat every security change as a pull request. Over time, your NixOS configuration becomes both your server infrastructure and your compliance documentation, which is a position few other operating systems can match.

— Ad —

Google AdSense will appear here after approval

← Back to all articles