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:
- Configurations are reproducible across machines.
- Rolling back a bad change is trivial with generations.
- Security reviews become pull requests instead of post-incident forensics.
- Compliance evidence is built into the repository.
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
- Version control your configuration. Keep
/etc/nixosin a Git repository. Every change should be a commit, reviewed before deployment. - Test configurations in a VM first. Use
nixos-rebuild build-vmto test changes without touching the production system. - Review the NixOS security advisories. Subscribe to the NixOS security mailing list and apply critical patches promptly.
- Minimize installed packages. Every package is potential attack surface. Only install what your workload actually needs.
- Use flakes for reproducibility. Flakes lock your inputs to exact revisions, ensuring the same configuration produces the same system across machines and time.
- Monitor for configuration drift. Since NixOS is declarative, any file outside
/nix/storethat has been manually modified is a red flag. Use AIDE or similar tools to detect this. - Separate secrets from configuration. Never store passwords or API keys in
configuration.nix. Use tools likesops-nixoragenixto manage secrets encrypted at rest. - Disable unused services. Run
systemctl list-unit-files --state=enabledregularly and disable anything you do not need.
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.