NixOS Anti-Patterns and Best Practices

Important: These patterns were identified through community feedback and code review in GitHub issues #10, #11, and #12. Following these guidelines prevents common mistakes and ensures idiomatic NixOS code.

πŸ“š Background

This document captures critical lessons learned from real community feedback about anti-patterns in NixOS configurations. These patterns were found in this repository and fixed based on expert review, helping establish guidelines for future development.

❌ Critical Anti-Patterns to Avoid

1. The mkIf true Anti-Pattern

# ❌ WRONG - Unnecessary abstraction
services.myservice.enable = mkIf cfg.enable true;
light.enable = mkIf (cfg.profile == "laptop") true;
qemuGuest.enable = mkIf (cfg.type == "qemu" || cfg.type == "auto") true;

# βœ… CORRECT - Direct assignment
services.myservice.enable = cfg.enable;
light.enable = cfg.profile == "laptop";
qemuGuest.enable = cfg.type == "qemu" || cfg.type == "auto";

Why this is wrong:

2. Trivial Function Wrappers

# ❌ WRONG - Pointless re-exports that add no value
mkMerge = lib.mkMerge;
mkIf = condition: config: lib.mkIf condition config;

# Functions that just call other functions with the same parameters
mkService = { name, enable ? true, config ? { } }:
  lib.mkIf enable {  # Also combines with anti-pattern #1
    services.${name} = lib.mkMerge [
      { enable = true; }
      config
    ];
  };

# βœ… CORRECT - Use library functions directly
lib.mkMerge [...]
lib.mkIf condition config

# For services, trust the module system
services.${name} = lib.mkMerge [
  { inherit enable; }
  config
];

Why this is wrong:

3. Magic Auto-Discovery

# ❌ WRONG - Complex auto-discovery that hides behavior
discoverModules = dir:
  let
    entries = builtins.readDir dir;
    moduleEntries = lib.filterAttrs
      (name: type:
        name != "installer" &&
        (type == "directory" ||
         (type == "regular" && lib.hasSuffix ".nix" name && name != "default.nix"))
      )
      entries;
    modulePaths = lib.mapAttrsToList
      (name: type:
        if type == "directory" then
          dir + "/${name}"
        else
          dir + "/${name}"
      )
      moduleEntries;
  in
  modulePaths;

# βœ… CORRECT - Explicit imports are clear and obvious
imports = [
  ./core
  ./desktop
  ./development
  ./gaming
  ./hardware
  ./presets
  ./profiles
  ./security
  ./services
  ./virtualization
  ./wsl
  ./template.nix
];

Why this is wrong:

4. Unnecessary Template Functions

# ❌ WRONG - Redundant wrappers for every possible variant
mkWorkstation = { hostname, system ? "x86_64-linux", extraModules ? [ ] }:
  mkSystem { inherit hostname system extraModules; profile = "workstation"; };

mkServer = { hostname, system ? "x86_64-linux", extraModules ? [ ] }:
  mkSystem { inherit hostname system extraModules; profile = "server"; };

mkDevelopment = { hostname, system ? "x86_64-linux", extraModules ? [ ] }:
  mkSystem { inherit hostname system extraModules; profile = "development"; };

mkGaming = { hostname, system ? "x86_64-linux", extraModules ? [ ] }:
  mkSystem { inherit hostname system extraModules; profile = "gaming"; };

# ... 5 more similar functions

# βœ… CORRECT - Direct usage with explicit parameters
nixosConfigurations = {
  my-workstation = mkSystem {
    hostname = "my-workstation";
    profile = "workstation";
  };

  my-server = mkSystem {
    hostname = "my-server";
    profile = "server";
    system = "aarch64-linux";
  };
};

Why this is wrong:

5. Code Duplication Without Extraction

# ❌ WRONG - Repeated definitions across configurations
programs.bash.shellAliases = {
  ll = "ls -alF";
  la = "ls -A";
  l = "ls -CF";
  ".." = "cd ..";
  "..." = "cd ../..";
  gs = "git status";
  ga = "git add";
  gc = "git commit";
  gp = "git push";
  gl = "git log --oneline";
  gd = "git diff";
  # ... more aliases
};

programs.zsh.shellAliases = {
  ll = "ls -alF";        # Exact duplication
  la = "ls -A";          # Exact duplication
  l = "ls -CF";          # Exact duplication
  ".." = "cd ..";        # Exact duplication
  "..." = "cd ../..";    # Exact duplication
  gs = "git status";     # Exact duplication
  ga = "git add";        # Exact duplication
  # ... same aliases repeated
};

# βœ… CORRECT - Shared definition with proper extraction
let
  commonAliases = {
    # System shortcuts
    ll = "ls -alF";
    la = "ls -A";
    l = "ls -CF";
    ".." = "cd ..";
    "..." = "cd ../..";

    # Git shortcuts
    gs = "git status";
    ga = "git add";
    gc = "git commit";
    gp = "git push";
    gl = "git log --oneline";
    gd = "git diff";

    # System monitoring
    psg = "ps aux | grep";
    h = "history";
    j = "jobs -l";

    # Safety aliases
    rm = "rm -i";
    cp = "cp -i";
    mv = "mv -i";

    # Directory shortcuts
    mkdir = "mkdir -pv";
  };
in {
  programs.bash.shellAliases = commonAliases;
  programs.zsh.shellAliases = commonAliases;
}

Why this is wrong:

βœ… Required Patterns for NixOS

1. Always Use Explicit Imports

2. Trust the NixOS Module System

3. Extract Common Functionality Properly

4. Follow Community Standards

5. Be Transparent About AI Assistance

Performance and Maintainability Impact

The anti-pattern fixes in this repository resulted in:

Code Review Checklist

Before submitting any NixOS configuration changes, verify:

When in Doubt - Decision Framework

  1. Check nixpkgs: How do official modules handle similar functionality?
  2. Ask the community: NixOS Discourse or Matrix channels for guidance
  3. Prefer explicit: Make behavior obvious and discoverable, not magical
  4. Trust the system: NixOS modules handle most cases correctly without extra wrapping
  5. Less is more: Remove code and abstractions rather than adding unnecessary ones

Real-World Example: Before and After

Before (Anti-patterns)

# lib/default.nix (32 lines - DELETED ENTIRELY)
{ lib }:
rec {
  mkHost = import ./mkHost.nix { inherit lib; };
  mkIf = condition: config: lib.mkIf condition config;  # Pointless wrapper
  mkMerge = lib.mkMerge;                                # Pointless re-export
  mkService = { name, enable ? true, config ? { } }:   # Unnecessary abstraction
    lib.mkIf enable {                                  # Anti-pattern #1
      services.${name} = lib.mkMerge [
        { enable = true; }
        config
      ];
    };
}

# modules/default.nix (49 lines of auto-discovery logic)
discoverModules = dir: let
  # ... 30+ lines of complex auto-discovery
in modulePaths;

# Multiple files with mkIf true patterns
services.qemuGuest.enable = mkIf (cfg.type == "qemu" || cfg.type == "auto") true;
programs.dconf.enable = mkIf cfg.applications.gnome-boxes true;
# ... 8 more instances

# Duplicate shell aliases in home/profiles/base.nix
programs.bash.shellAliases = { ll = "ls -alF"; la = "ls -A"; /* ... */ };
programs.zsh.shellAliases = { ll = "ls -alF"; la = "ls -A"; /* ... */ };

After (Best practices)

# lib/default.nix - DELETED (unnecessary abstractions removed)

# modules/default.nix (17 lines - explicit and clear)
{
  imports = [
    ./core
    ./desktop
    ./development
    ./gaming
    ./hardware
    ./presets
    ./profiles
    ./security
    ./services
    ./virtualization
    ./wsl
    ./template.nix
  ];
}

# Direct assignments throughout codebase
services.qemuGuest.enable = cfg.type == "qemu" || cfg.type == "auto";
programs.dconf.enable = cfg.applications.gnome-boxes;

# Shared aliases in home/profiles/base.nix
let
  commonAliases = {
    ll = "ls -alF";
    la = "ls -A";
    # ... defined once
  };
in {
  programs.bash.shellAliases = commonAliases;
  programs.zsh.shellAliases = commonAliases;
}

Community Feedback Integration

These patterns were identified through:

This demonstrates the importance of:

Conclusion

Following these guidelines ensures NixOS configurations that are:

These patterns help both human developers and AI systems create better NixOS code that the community can rely on and build upon.


Extended Comprehensive Anti-Patterns Reference

Source: Research compilation from @docs/researched-antipatterns.md and community best practices

πŸ” Nix Language Anti-Patterns

Unquoted URLs (Deprecated)

# ❌ BAD - RFC 45 deprecated this due to parsing ambiguities
fetchurl {
  url = https://example.com/file.tar.gz;  # Causes static analysis issues
  sha256 = "...";
}

# βœ… GOOD - Always quote URLs
fetchurl {
  url = "https://example.com/file.tar.gz";
  sha256 = "...";
}

Path Division Confusion

# ❌ BAD - Nix interprets 6/3 as path "./6/3"
result = 6/3;

# βœ… GOOD - Use spacing for arithmetic
result = 6 / 3;  # Returns 2
# OR explicit function
result = builtins.div 6 3;

Type Coercion in String Interpolation

# ❌ BAD - Cannot coerce these types
let
  number = 42;
  boolean = true;
in {
  badNumber = "${number}";    # Error: cannot coerce integer
  badBoolean = "${boolean}";  # Error: cannot coerce boolean
}

# βœ… GOOD - Explicit conversion
{
  goodNumber = "${toString number}";    # "42"
  goodBoolean = "${toString boolean}";  # "1" or ""
}

Excessive with Usage

# ❌ BAD - Unclear variable origins, breaks static analysis
with (import <nixpkgs> {});
with lib;
with stdenv;

mkDerivation {
  name = "example";
  buildInputs = [ curl jq ];  # Where do these come from?
}

# βœ… GOOD - Explicit imports with limited scope
let
  pkgs = import <nixpkgs> {};
  inherit (pkgs) lib stdenv;
in
stdenv.mkDerivation {
  name = "example";
  buildInputs = with pkgs; [ curl jq ];  # Clear, limited scope
}

Manual Assignment Instead of inherit

# ❌ BAD - Verbose and error-prone
let pkgs = import <nixpkgs> {};
in {
  curl = pkgs.curl;
  jq = pkgs.jq;
  git = pkgs.git;
}

# βœ… GOOD - Use inherit for cleaner syntax
let pkgs = import <nixpkgs> {};
in {
  inherit (pkgs) curl jq git;
}

🚨 Dangerous Builtins Usage

Import From Derivation (IFD) - Critical

# ❌ BAD - Forces sequential evaluation, blocks parallelism
let
  generatedConfig = pkgs.runCommand "config" {} ''
    echo "some_value = 42" > $out
  '';
  configValue = builtins.readFile generatedConfig;  # Forces build during eval!
in
pkgs.writeText "app-config" configValue

# βœ… GOOD - Keep evaluation and building separate
let
  generatedConfig = pkgs.runCommand "config" {} ''
    echo "some_value = 42" > $out
  '';
in
pkgs.runCommand "app-config" { inherit generatedConfig; } ''
  cp $generatedConfig $out
''

Performance Impact: Can increase evaluation time from seconds to hours for complex projects.

Reading Secrets During Evaluation - Security Critical

# ❌ BAD - Exposes password in world-readable Nix store
services.myservice = {
  password = builtins.readFile "/secrets/password";  # MAJOR SECURITY ISSUE!
}

# βœ… GOOD - Reference paths for runtime loading
services.myservice = {
  passwordFile = "/secrets/password";  # Read at runtime only
}

# βœ… BETTER - Use proper secret management
age.secrets.myservice-password.file = ../secrets/password.age;
services.myservice.passwordFile = config.age.secrets.myservice-password.path;

πŸ—οΈ System Configuration Anti-Patterns

Using nix-env for System Packages

# ❌ BAD - Breaks declarative configuration and reproducibility
nix-env -i firefox vim git
# Packages persist across rebuilds, aren't tracked in config
# βœ… GOOD - Declarative in configuration.nix
environment.systemPackages = with pkgs; [
  firefox vim git
];

Why Problematic: nix-env packages aren’t tracked in configuration, persist across rebuilds unexpectedly, and make rollbacks incomplete.

Misusing environment.systemPackages

# ❌ BAD - Installing user-specific packages system-wide
environment.systemPackages = with pkgs; [
  firefox      # Should be user-specific
  vscode       # Development tool for individual users
  spotify      # Personal application
];

# βœ… GOOD - Proper separation of concerns
environment.systemPackages = with pkgs; [
  wget curl git vim  # System essentials only
];

users.users.alice.packages = with pkgs; [
  firefox vscode spotify  # User-specific applications
];

Running Services as Root Unnecessarily

# ❌ BAD - Violates principle of least privilege
systemd.services.myservice = {
  serviceConfig = {
    ExecStart = "${pkgs.myapp}/bin/myapp";
    # No User specified - runs as root with full privileges!
  };
};

# βœ… GOOD - Dedicated user with comprehensive hardening
users.users.myservice = {
  isSystemUser = true;
  group = "myservice";
};
users.groups.myservice = {};

systemd.services.myservice = {
  serviceConfig = {
    ExecStart = "${pkgs.myapp}/bin/myapp";
    User = "myservice";
    Group = "myservice";

    # Process isolation
    DynamicUser = true;
    PrivateTmp = true;
    ProtectSystem = "strict";
    ProtectHome = true;

    # Capabilities restrictions
    NoNewPrivileges = true;
    ProtectKernelTunables = true;
    ProtectKernelModules = true;

    # Memory protections
    MemoryDenyWriteExecute = true;
    RestrictRealtime = true;
    LockPersonality = true;
  };
};

Poor Firewall Configuration

# ❌ BAD - Security nightmare
networking.firewall.enable = false;  # Completely exposed!
# OR
networking.firewall.allowedTCPPorts = [ 1-65535 ];  # Everything open!

# βœ… GOOD - Minimal, targeted port opening
networking.firewall = {
  enable = true;
  allowedTCPPorts = [ 80 443 ];  # Only what's actually needed

  # Interface-specific rules for internal services
  interfaces."enp3s0" = {
    allowedTCPPorts = [ 5432 ];  # PostgreSQL on internal network only
  };
};

Monolithic Configuration File

# ❌ BAD - Everything in one massive configuration.nix (500+ lines)
{ config, pkgs, ... }: {
  boot.loader.grub.enable = true;
  networking.hostName = "myhost";
  services.nginx.enable = true;
  services.postgresql.enable = true;
  # ... hundreds more lines making maintenance impossible
}
# βœ… GOOD - Modular structure for maintainability
/etc/nixos/
β”œβ”€β”€ configuration.nix        # Main entry point (imports only)
β”œβ”€β”€ hardware-configuration.nix
β”œβ”€β”€ modules/
β”‚   β”œβ”€β”€ networking.nix
β”‚   β”œβ”€β”€ security.nix
β”‚   └── users.nix
└── services/
    β”œβ”€β”€ nginx.nix
    └── postgresql.nix

πŸ“¦ Package Management Anti-Patterns

Incorrect final vs prev Usage in Overlays

# ❌ BAD - Causes infinite recursion
final: prev: {
  hello = final.hello.overrideAttrs (oldAttrs: {
    postPatch = "...";
  });  # Refers to itself - infinite loop!
}

# βœ… GOOD - Use prev for the base package
final: prev: {
  hello = prev.hello.overrideAttrs (oldAttrs: {
    postPatch = "...";
  });
}

Using rec in Overlays

# ❌ BAD - Breaks composability and prevents later overrides
final: prev: rec {
  pkg-a = prev.callPackage ./a { };
  pkg-b = prev.callPackage ./b { dependency-a = pkg-a; }  # Fixed reference
}

# βœ… GOOD - Reference through final for composability
final: prev: {
  pkg-a = prev.callPackage ./a { };
  pkg-b = prev.callPackage ./b { dependency-a = final.pkg-a; };  # Overrideable
}

Impure Derivations

# ❌ BAD - Network access during build breaks reproducibility
stdenv.mkDerivation {
  name = "impure-build";
  buildPhase = ''
    curl -O https://example.com/dependency.tar.gz  # Non-deterministic!
  '';
}

# βœ… GOOD - Pure build with fixed-output derivation
stdenv.mkDerivation {
  name = "pure-build";
  src = fetchurl {
    url = "https://example.com/dependency.tar.gz";
    sha256 = "...";  # Fixed output hash ensures reproducibility
  };
}

Missing Phase Hooks

# ❌ BAD - Breaks extensibility by not calling hooks
installPhase = ''
  mkdir -p $out/bin
  cp myprogram $out/bin/
'';

# βœ… GOOD - Include hooks for extensibility
installPhase = ''
  runHook preInstall
  mkdir -p $out/bin
  cp myprogram $out/bin/
  runHook postInstall
'';

Wrong Dependency Types

# ❌ BAD - Confusing build-time and runtime dependencies
stdenv.mkDerivation {
  buildInputs = [ gcc cmake ];  # Build tools should be nativeBuildInputs!
}

# βœ… GOOD - Correct categorization for cross-compilation
stdenv.mkDerivation {
  nativeBuildInputs = [ gcc cmake ];           # Build tools (host→target)
  buildInputs = [ openssl zlib ];              # Runtime libraries
  propagatedBuildInputs = [ essential-lib ];   # Propagated to consumers
}

πŸš€ Performance Anti-Patterns

Never Running Garbage Collection

# ❌ BAD - Store grows unbounded (can reach 100GB+)
# No garbage collection configuration

# βœ… GOOD - Automated store management
nix.gc = {
  automatic = true;
  dates = "weekly";
  options = "--delete-older-than 30d";
};

nix.optimise = {
  automatic = true;
  dates = [ "03:45" ];  # Run during low-usage hours
};

Poor Binary Cache Configuration

# ❌ BAD - Wrong public keys break substitution entirely
nix.settings = {
  substituters = [ "https://cache.example.org" ];
  trusted-public-keys = [ "wrong-key" ];  # Everything rebuilds from source!
};

# βœ… GOOD - Proper cache setup with verified keys
nix.settings = {
  substituters = [
    "https://cache.nixos.org/"
    "https://nix-community.cachix.org"
  ];
  trusted-public-keys = [
    "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
    "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
  ];
};

Unsafe System Updates

# ❌ BAD - Direct production updates without testing
nixos-rebuild switch --upgrade  # Risky on production systems!

# βœ… GOOD - Safe testing workflow
nixos-rebuild build       # Build without applying
nixos-rebuild test        # Test without permanent changes
nixos-rebuild build-vm    # Test in isolated VM
nixos-rebuild switch      # Apply only when confident

🏠 Home Manager Anti-Patterns

Missing stateVersion - Most Common Error

# ❌ BAD - Causes "option 'home.stateVersion' is used but not defined"
{
  programs.git.enable = true;
  # Error: The option 'home.stateVersion' is used but not defined
}

# βœ… GOOD - Always set stateVersion (set once, never change)
{
  home.stateVersion = "24.05";  # Use version when you started
  programs.git.enable = true;
}

Duplicate Package Management

# ❌ BAD - Same packages in both system and Home Manager
# /etc/nixos/configuration.nix
environment.systemPackages = with pkgs; [ neovim git ];

# ~/.config/home-manager/home.nix
home.packages = with pkgs; [ neovim git ];  # Conflict and waste!

# βœ… GOOD - Clear separation of responsibilities
# System: system-wide essentials only
# Home Manager: user-specific packages and configurations

Choosing how to ship a dotfile

There are four ways to get a config file into place with Home Manager, and the right choice depends on whether a native module exists and whether you want the file to stay editable as a real file.

1. Best β€” a native module option, when one exists

# Typed, mergeable, and a host can override one setting without
# restating the whole file.
programs.git.settings = {
  init.defaultBranch = "main";
  pull.rebase = true;
};

wayland.windowManager.hyprland.settings = {
  "$mod" = "SUPER";
  bind = [ "$mod, Q, exec, alacritty" ];
};

2. Also good β€” .source, pointing at a real file in the repository

# Pure: the file is copied into the Nix store, so the flake fully
# describes the result.
home.file.".vimrc".source = ./dotfiles/vimrc;
xdg.configFile."niri/config.kdl".source = ./dotfiles/niri.kdl;

The file stays a real file on disk, so your editor’s LSP, syntax highlighting and formatter all work on it, and upstream documentation maps to it 1:1. Use this whenever no native module exists, or when the upstream format is large or awkward to express as Nix.

3. Escape hatch β€” mkOutOfStoreSymlink

home.file.".vimrc".source =
  config.lib.file.mkOutOfStoreSymlink "${config.home.homeDirectory}/dotfiles/.vimrc";

This symlinks to a path outside the Nix store, so edits take effect without a rebuild β€” genuinely useful while iterating on a config. The trade-off is real and worth stating plainly: the flake no longer describes the result, the target must already exist at that path on every machine, and a fresh clone will not reproduce your setup. Reach for it deliberately, not by default.

4. Avoid for anything sizeable β€” a long inline .text block

# ❌ Fine for three lines. Miserable at a hundred.
home.file.".vimrc".text = ''
  set number
  set expandtab
'';

Inside a Nix string you lose LSP, syntax highlighting and formatters for the embedded language; everything sits one indent level in; '' and ${ need escaping; and when something breaks you debug the generated file and then walk back to the Nix source. A handful of lines is fine. A hundred-line block is a worse outcome than either option 1 or option 2.

Note: an earlier version of this guide presented a long inline .text block as the β€œgood” fix for mkOutOfStoreSymlink. That was wrong, and community feedback rightly pushed back. The real distinction is in the store versus out of it β€” .source = ./file is pure and keeps a real file, so it fixes the purity problem without inheriting the ergonomics problem.

πŸ”§ Development Environment Anti-Patterns

Everything in flake.nix - Rightward Drift

# ❌ BAD - Creates unmaintainable complexity
{
  outputs = { self, nixpkgs }: {
    packages.x86_64-linux.default = nixpkgs.legacyPackages.x86_64-linux.stdenv.mkDerivation {
      # 100+ lines of derivation code making flake.nix huge
    };
  };
}

# βœ… GOOD - Modular structure with separation of concerns
{
  outputs = { self, nixpkgs }: {
    packages.x86_64-linux.default =
      nixpkgs.legacyPackages.x86_64-linux.callPackage ./package.nix { };
  };
}

Blocking direnv Operations

# ❌ BAD - Slow .envrc freezes shells and editors for 5+ seconds
nix-shell --run 'direnv dump > .envrc.cache'

# βœ… GOOD - Use nix-direnv for instant activation
use flake  # With nix-direnv installed - executes in <500ms

Rule: .envrc should execute in under 500ms for good developer experience.

πŸ› οΈ Detection and Prevention Tools

Automated Anti-Pattern Detection

# Language linting and formatting
statix check           # Detects 20+ anti-patterns automatically
statix fix             # Auto-fixes many problems
nixfmt .              # Consistent formatting
alejandra .           # Alternative formatter

# Package analysis
nixpkgs-hammering      # Detects packaging anti-patterns
nixpkgs-review pr 123  # Tests package changes safely

# System security analysis
systemd-analyze security service-name    # Service security audit
lynis                                   # Comprehensive system security scan

Performance Analysis Tools

# Evaluation performance
NIX_SHOW_STATS=1 nix build    # Shows evaluation statistics
nix path-info -S              # Check closure sizes
nix-tree                      # Visualize dependency graphs

# Store management
nix-du                        # Analyze store usage
nix store optimise            # Deduplicate store paths

βœ… Final Checklist for Quality Assurance

Before any configuration change, verify:

Language & Evaluation

System Configuration

Package Management

Performance & Maintenance

Home Manager Integration

🎯 Key Success Principles

  1. Evaluation vs Build Phase: Keep them completely separate to enable parallelism
  2. Declarative Philosophy: Everything in configuration files, no imperative changes
  3. Proper Scoping: Right tool for the right scope (system vs user vs build-time)
  4. Security by Default: Principle of least privilege everywhere
  5. Performance Awareness: Understand evaluation costs and caching strategies
  6. Gradual Adoption: Don’t try to migrate everything at once
  7. Community Standards: Follow established patterns from nixpkgs

Remember: Success with Nix/NixOS requires patience, understanding of the underlying model, and strict adherence to community best practices. Always test changes in safe environments before deploying to production systems.


References and Further Reading

This comprehensive guide ensures robust, maintainable, and community-standard NixOS configurations.