Nix
Guidance for writing Nix expressions, NixOS modules, flakes, packages, overlays, and system/user configuration.
References
- •Nix Reference Manual
- •Nixpkgs Manual
- •NixOS Manual
- •Nix Pills
- •nix.dev Tutorials
- •flake-parts Documentation
- •Dendritic Pattern
- •hjem Documentation
- •Nix RFCs
Code Quality Checks
Run in order:
# 1. Format code (check project for formatter first; default to alejandra) alejandra . # 2. Run all checks (statix, deadnix, and custom checks should be wired into flake checks) nix flake check # 3. Build outputs nix build # 4. Security scan (scan build output, not the development system) vulnix result/ # Scan nix-build output + transitive closure vulnix /nix/store/<drv>.drv # Scan a specific derivation
statix check . and deadnix . can be run directly for faster local iteration, but nix flake check is the authoritative lint runner — wire all lints into checks in your flake.nix.
Project Structure
Canonical flake-based layout:
project-root/ ├── flake.nix ├── flake.lock ├── modules/ │ ├── nixos/ │ │ ├── default.nix │ │ └── services/ │ └── common/ ├── packages/ │ └── my-package/ │ └── default.nix ├── overlays/ │ └── default.nix ├── homes/ # hjem user configs │ └── user/ │ └── default.nix ├── hosts/ │ └── hostname/ │ ├── default.nix │ └── hardware.nix ├── lib/ │ └── default.nix └── README.md
Testing
Evaluation Tests
nix eval .#myConfig.value # Evaluate a specific attribute nix eval --json .#myConfig # JSON output for inspection
Build Tests
passthru.tests.version = runCommand "version-test" { } ''
${myPackage}/bin/my-app --version | grep "${version}"
touch $out
'';
NixOS VM Tests
nixos-lib.runTest {
name = "my-service-test";
nodes.machine = { pkgs, ... }: {
services.myService.enable = true;
};
testScript = ''
machine.wait_for_unit("my-service")
machine.succeed("curl -f http://localhost:8080")
'';
}
Run with: nix build .#checks.x86_64-linux.myTest
Naming Conventions
| Item | Convention | Example |
|---|---|---|
| Attribute names | camelCase | buildInputs, enableService |
| Package names | lowercase with hyphens | my-package, hello-world |
| Module options | camelCase, dot-separated path | services.myApp.enable |
| Files | lowercase with hyphens | my-module.nix, default.nix |
| Flake outputs | camelCase | nixosConfigurations, devShells |
| Variables | camelCase | pkgs, lib, config |
| Functions | camelCase | mkDerivation, mkOption |
| Boolean options | enable prefix | services.nginx.enable |
Idioms
- •Use
let/inblocks for local bindings — neverrec { }. - •Avoid
with pkgs;at top level. Use narrowwithonly when it clearly improves readability (e.g., inside a list of packages). - •Use
libhelpers:mkIf,mkMerge,mkDefault,mkOption,mkEnableOption. - •Use
callPackagepattern for package definitions — it handles dependency injection. - •Use flake-parts for new flakes — eliminates per-system boilerplate and provides modular flake structure.
- •Use the dendritic pattern for multi-configuration flakes — every file is a flake-parts module, use
deferredModuletype for lower-level configs. Seereference/dendritic.md. - •Pin all flake inputs via
flake.lock. Runnix flake lock --update-input <input>for targeted updates. - •Prefer
pkgs.writeShellApplicationoverpkgs.writeShellScriptBin— it runs shellcheck automatically. - •Use
overrideAttrsfor modifying existing packages. - •Use
passthrufor package metadata and tests. - •Prefer
lib.optional/lib.optionalsoverif/then/elsein lists. - •Use
lib.attrsetsfunctions (mapAttrs,filterAttrs,genAttrs) for attribute set manipulation. - •Use
builtins.readFilefor including external files. - •Use
pkgs.formats.*(pkgs.formats.toml { },pkgs.formats.yaml { }, etc.) for generating config files.
Module Patterns
Always separate options and config:
{ config, lib, pkgs, ... }:
let
cfg = config.services.myApp;
in
{
options.services.myApp = {
enable = lib.mkEnableOption "myApp service";
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "Port to listen on.";
};
settings = lib.mkOption {
type = lib.types.submodule {
options.logLevel = lib.mkOption {
type = lib.types.enum [ "debug" "info" "warn" "error" ];
default = "info";
};
};
};
};
config = lib.mkIf cfg.enable {
systemd.services.myApp = {
wantedBy = [ "multi-user.target" ];
serviceConfig.ExecStart = "${pkgs.myApp}/bin/myApp --port ${toString cfg.port}";
};
};
}
Key patterns:
- •
mkEnableOptionfor service toggles. - •
mkOptionwith properlib.types.*(str,int,port,bool,listOf,attrsOf,submodule,enum,nullOr,oneOf). - •
mkIf cfg.enableto gate all configuration. - •
mkMergefor combining multiple conditional configs. - •
mkDefaultfor overridable defaults. - •
importsfor composing modules.
Dendritic Pattern
For multi-configuration flakes (NixOS + darwin + hjem), use the dendritic pattern:
- •Every Nix file is a flake-parts module of the top-level configuration.
- •Each file implements a single feature across all configurations it applies to.
- •Lower-level configs (NixOS, darwin, hjem) are stored as
deferredModuleoption values. - •Share values between files via top-level
config— nospecialArgspass-through. - •Use import-tree to auto-import all modules.
See reference/dendritic.md for full examples.
Packaging
Standard Derivation
{ lib, stdenv, fetchFromGitHub }:
stdenv.mkDerivation {
pname = "my-package";
version = "1.0.0";
src = fetchFromGitHub {
owner = "example";
repo = "my-package";
rev = "v${version}";
hash = "sha256-AAAA...";
};
meta = {
description = "A short description";
homepage = "https://example.com";
license = lib.licenses.mit;
maintainers = [ lib.maintainers.username ];
platforms = lib.platforms.linux;
};
}
- •Always use
pname+version, never barename. - •Use language-specific builders:
buildGoModule,buildPythonPackage,buildRustPackage,buildNpmPackage. - •Always set
metawithdescription,license,maintainers,platforms. - •Use
passthru.testsfor package tests. - •Use
nix-updatefor version bumps.
callPackage Pattern
# In flake.nix or overlay
my-package = pkgs.callPackage ./packages/my-package { };
callPackage auto-injects dependencies from pkgs matching function parameter names.
Overlays
Define in overlays/default.nix:
final: prev: {
my-package = final.callPackage ../packages/my-package { };
existing-package = prev.existing-package.overrideAttrs (old: {
patches = old.patches or [ ] ++ [ ./fix.patch ];
});
}
- •Use
final: prev:convention (notself: super:). - •
final= the fixed point (use for dependencies).prev= the previous layer (use for overriding). - •Keep overlays minimal — prefer upstream contributions.
- •Composition order matters: later overlays override earlier ones.
- •Use overlays for: version pinning, patching, adding local packages.
Wire into flake:
{
overlays.default = import ./overlays;
nixosConfigurations.myHost = nixpkgs.lib.nixosSystem {
modules = [{
nixpkgs.overlays = [ self.overlays.default ];
}];
};
}
Development Shells
Use devShells for reproducible project development environments. Enter with nix develop.
Basic devShell (flake-parts)
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
};
outputs = inputs:
inputs.flake-parts.lib.mkFlake {inherit inputs;} {
systems = ["x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin"];
perSystem = {pkgs, ...}: {
devShells.default = pkgs.mkShellNoCC {
packages = with pkgs; [
go
gopls
golangci-lint
];
env.GOPATH = "${builtins.getEnv "HOME"}/go";
};
};
};
}
- •Use
mkShellNoCCunless a C compiler is needed — lighter and faster. - •Use
mkShellwhenbuildInputs/nativeBuildInputsare required (e.g., C libraries, pkg-config). - •Use
envattribute for environment variables (cleaner thanshellHookexports). - •Use
shellHooksparingly — only for side effects like printing or generating files. - •flake-parts
perSystemhandles the per-system attribute set — no manualsystemthreading.
Multiple shells
perSystem = {pkgs, ...}: {
devShells = {
default = pkgs.mkShellNoCC { packages = with pkgs; [ go gopls ]; };
ci = pkgs.mkShellNoCC { packages = with pkgs; [ go golangci-lint ]; };
docs = pkgs.mkShellNoCC { packages = [ pkgs.mdbook ]; };
};
};
Enter non-default shells with nix develop .#ci.
Composing with project packages
perSystem = {pkgs, self', ...}: {
devShells.default = pkgs.mkShell {
inputsFrom = [ self'.packages.my-package ];
packages = with pkgs; [ gopls delve ];
};
};
inputsFrom pulls in all build inputs from an existing derivation — keeps the devShell in sync with the package's dependencies. Use self' (from flake-parts) to reference the current system's outputs.
Anti-Patterns
| Avoid | Do Instead |
|---|---|
with pkgs; at module top level | Qualify names: pkgs.git, or narrow with in lists |
rec { } attribute sets | let/in bindings |
| Impure fetches without hashes | Flake inputs or fixed-output derivations |
nix-env -i | Declarative config |
builtins.fetchTarball without hash | Add sha256 or use flake inputs |
Mutable state in /etc outside Nix | Manage via NixOS modules |
| Home Manager when hjem suffices | Use hjem (see below) |
nixpkgs.config.allowUnfree = true globally | Scope to specific packages |
Ignoring --show-trace | Always use when debugging errors |
Legacy nix-shell, nix-build | nix develop, nix build (flakes) |
Dotfiles & Config Management
hjem (Preferred)
Always use hjem for user-level dotfiles and config. It is lightweight, fast, and maps directly to file placement.
{ pkgs, lib, ... }: {
hjem.users.taylor = {
files = {
".config/git/config".source = ./git/config;
".config/starship.toml".text = ''
[character]
success_symbol = "[›](bold green)"
'';
".config/app/config.toml".generator = let
format = pkgs.formats.toml { };
in format.generate "config.toml" {
database.host = "localhost";
database.port = 5432;
};
};
xdg.config.files = {
"alacritty/alacritty.toml".source = ./alacritty.toml;
};
xdg.data.files = {
"applications/my-app.desktop".source = ./my-app.desktop;
};
environment.sessionVariables = {
EDITOR = "nvim";
VISUAL = "nvim";
};
};
}
File types: source (symlink, default), text (inline), generator (via pkgs.formats.*).
Home Manager (Last Resort)
Home Manager is bloated, significantly slows builds, and often lags behind native application config changes. Use it only when you need its module abstractions (e.g., complex program modules with deep NixOS integration that hjem cannot replicate).
Documentation
- •Every module option must have a
description. - •Every package must have
meta.description. - •Complex modules should have a README in their directory.
- •Flake
descriptionshould be set inflake.nix.
Dependencies & Inputs
- •All external dependencies come through flake inputs.
- •Pin nixpkgs to a specific revision via
flake.lock. - •Use
followsto deduplicate shared inputs:nixinputs.hjem.inputs.nixpkgs.follows = "nixpkgs";
- •Minimize the number of flake inputs — each adds evaluation overhead.
- •Use flake-parts for new flakes —
perSystem,self', andinputs'eliminate manual system threading.
Performance Considerations
- •Minimize import chains — deep
importtrees slow evaluation. - •Use
lib.mkMergesparingly in hot paths. - •Avoid
builtins.fetchGitin frequently evaluated expressions. - •Use
nix evalto profile evaluation time. - •Binary caches: configure
nix.settings.substitutersandtrusted-public-keys. - •Use
nix build --dry-runto check what needs building before committing to a full build.
Troubleshooting
| Problem | Solution |
|---|---|
| Cryptic error | Add --show-trace to the command |
| Infinite recursion | Check for circular imports or rec usage; use let/in |
| Attribute not found | Verify input wiring, check with scope, use nix repl to inspect |
| Type mismatch | Check lib.types.* in module options |
| Build failure | nix log /nix/store/<drv> for build logs |
| Store corruption | nix store verify --all |
| Flake lock conflicts | nix flake lock --update-input <input> |
| Printf debugging | builtins.trace, lib.traceVal, lib.traceValSeq |
| Interactive debugging | nix repl then :lf . to load current flake |
Security
- •Scan build output and its transitive closure:
vulnix result/ - •Scan a specific derivation:
vulnix /nix/store/<drv>.drv - •Scan all passed derivations without following requisites:
vulnix -R /nix/store/*.drv - •Scan the full NixOS system (if needed):
vulnix --system - •JSON output for CI/post-processing:
vulnix --json result/ - •Use whitelists to suppress known false positives:
vulnix -w whitelist.toml result/ - •Review
meta.licenseon all dependencies. - •Use
nixpkgs.config.permittedInsecurePackagesexplicitly rather than blanketallowInsecure.
Design Principles
- •Declarative over imperative — describe what, not how.
- •Reproducibility above all — same inputs must yield same outputs.
- •Composition over inheritance — combine modules, don't subclass.
- •Minimal abstraction — don't over-abstract; Nix is already a DSL.
- •Pin everything —
flake.lockis your friend. - •KISS — simple Nix is maintainable Nix.
- •DRY — extract shared logic into
lib/. - •YAGNI — don't add options or modules until needed.
Checklist Before Completion
- • Code is formatted:
alejandra . - • No lint issues:
statix check . - • No dead code:
deadnix . - • Flake checks pass:
nix flake check - • All outputs build:
nix build - • Security scan:
vulnix result/ - • No
with pkgs;at module top level - • No
rec { }attribute sets - • All modules separate
optionsandconfig - • All packages have
metaattributes - • Overlays use
final: prev:convention - • hjem used for dotfiles (not Home Manager)
- • All flake inputs are pinned