Contents

NixOS for Non-Believers: A Practical Guide for Developers

You have sent the message “it works on my machine” at least once in your career. Maybe you’ve been on the receiving end of it. Either way, the problem is always the same: two machines that are supposed to be identical are not, and no one can explain why. One has Python 3.11, the other has 3.12. One has a system-level OpenSSL that some C extension links against, and the other doesn’t. One engineer installed a package six months ago and forgot about it.

NixOS is a Linux distribution built around one idea: the entire system - installed packages, kernel version, system services, users, file permissions, shell configuration - is declared in a single file that you version-control like any other piece of code. Not reduced into a manifest, not approximated with containers, but fully declared. If two machines have the same configuration.nix and the same flake.lock, they are functionally identical.

The skepticism is understandable. NixOS requires learning a new purely functional language (Nix), rethinking assumptions about how Linux works, and tolerating a learning curve that is genuinely steep. But for developers who configure multiple machines, who have been burned by environment drift, or who want a development setup they can reproduce from scratch in under an hour, NixOS repays the investment quickly.

What Makes NixOS Different from Every Other Linux Distro

The distinction between NixOS and conventional distributions like Ubuntu, Arch, or Fedora is not superficial. It goes all the way down to how packages are stored and how the system state is tracked.

The Nix Store

On a conventional Linux system, installing a package means writing files into shared directories: binaries go to /usr/bin, libraries go to /lib, headers go to /usr/include. When you upgrade a package, the old files are overwritten. If something goes wrong mid-upgrade, the system can be left in a broken intermediate state.

NixOS uses a different model. Every package lives in the Nix store at /nix/store/, in an immutable, content-addressed directory. A real path looks like this:

/nix/store/r8vvq3lh5n3m7wk5x9f7-glibc-2.39/

The hash prefix (r8vvq3lh5n3m7wk5x9f7) is derived from the package’s build inputs and build recipe. If two packages share the same hash, they are bitwise identical. If anything about the build changes - a different source URL, a different dependency version, a different compiler flag - the hash changes, and a new directory is created alongside the old one.

This means multiple versions of the same package can coexist without conflict. Your Python 3.11 project and your Python 3.12 project each get their own isolated store paths.

Derivations

The core unit of computation in Nix is a derivation: an expression that describes how to build a package. A derivation specifies the source URL, the build dependencies, the build commands, and the outputs. It is conceptually similar to a Dockerfile, except it applies to individual packages rather than to containers, and it is composable all the way up to the operating system.

When you write pkgs.ripgrep in your configuration, you are referencing a derivation. When NixOS evaluates your configuration, it resolves the full dependency graph of derivations and builds or downloads everything needed.

Generations and Atomic Upgrades

Every time you run nixos-rebuild switch, NixOS creates a new generation - a complete snapshot of the system configuration. Your bootloader (GRUB or systemd-boot) lists all previous generations. If a new generation has a problem, you boot the previous one from the menu and run nixos-rebuild switch --rollback. The system is back to exactly where it was before the upgrade.

NixOS boot menu showing multiple system generations listed as selectable boot options, with Generation 47, 46, and 45 visible
The NixOS bootloader lists every generation — selecting an older one instantly reverts the entire system to that exact state
Image: NixOS.org

This is atomic in a meaningful sense: the switch from one generation to the next involves updating symlinks, not rewriting shared directories. Either the switch succeeds and the new generation is active, or it fails and the old generation remains untouched.

Reproducibility in Practice

Given the same configuration.nix and the same flake.lock (which pins exact git revisions of the package repository), two NixOS installations produce bit-for-bit identical package builds. This is a stronger guarantee than Docker layer caching, which depends on the order of instructions and can produce different results depending on what is cached.

The trade-offs are real: the Nix store grows large over time (clean it with nix-collect-garbage -d), the Nix language takes time to internalize, and software that assumes a standard FHS layout requires workarounds. More on that later.

Installing NixOS: The Minimal Path for Developers

Download the NixOS minimal ISO from nixos.org (24.11 is the current stable release) and write it to a USB drive. The graphical installer works, but starting with manual partitioning gives you more control and a better understanding of what you’re setting up.

Partitioning

A sensible layout for a developer workstation uses BTRFS with subvolumes for easy snapshot management:

  • EFI partition: 512MB, FAT32, mounted at /boot
  • Root partition: remaining space, BTRFS with @ (root) and @home subvolumes

BTRFS subvolumes let you use Snapper or btrfs-snapper to take automatic snapshots before every system change, giving you filesystem-level rollback on top of NixOS’s generation-based rollback.

A Minimal Developer configuration.nix

After partitioning and mounting, nixos-generate-config creates an initial configuration.nix. Here is a starting point for a developer workstation with KDE Plasma 6:

{ config, pkgs, ... }:

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

  boot.loader.systemd-boot.enable = true;
  boot.loader.efi.canTouchEfiVariables = true;

  networking.hostName = "dev-machine";
  networking.networkmanager.enable = true;

  time.timeZone = "America/New_York";

  # Desktop environment - swap plasma6 for gnome, or use hyprland below
  services.xserver.enable = true;
  services.displayManager.sddm.enable = true;
  services.desktopManager.plasma6.enable = true;

  # For Hyprland instead:
  # programs.hyprland.enable = true;

  environment.systemPackages = with pkgs; [
    git
    neovim
    zsh
    tmux
    docker
    ripgrep
    fd
    bat
    eza
    fzf
    direnv
    nix-direnv
    wget
    curl
  ];

  users.users.developer = {
    isNormalUser = true;
    extraGroups = [ "wheel" "docker" "video" "audio" ];
    shell = pkgs.zsh;
  };

  services.docker.enable = true;

  system.stateVersion = "24.11";
}

For NVIDIA GPUs, add:

hardware.nvidia.package = config.boot.kernelPackages.nvidiaPackages.stable;
hardware.opengl.enable = true;
hardware.nvidia.modesetting.enable = true;

Switching between NVIDIA driver versions is a one-line change and a rebuild - no manual driver installation, no DKMS module compilation that silently fails after a kernel update.

Run nixos-install to apply the configuration, reboot, and you have a fully configured developer workstation. Any setting you didn’t write into configuration.nix is not active. The configuration is the system.

Nix Flakes: The Modern Way to Manage NixOS

The traditional configuration.nix approach works, but it has a problem: nixpkgs (the package repository) is pinned to a channel that updates separately from your config. Two machines both running nixos-24.11 may have slightly different package versions depending on when they last fetched channel updates.

Nix flakes solve this by introducing a flake.lock file that pins every input - including nixpkgs - to an exact git commit hash. Flakes were stabilized in Nix 2.18 and are now the recommended approach for new NixOS configurations.

A Minimal flake.nix

{
  description = "My NixOS configuration";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
    home-manager = {
      url = "github:nix-community/home-manager/release-24.11";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { self, nixpkgs, home-manager, ... }: {
    nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        ./configuration.nix
        home-manager.nixosModules.home-manager
        {
          home-manager.useGlobalPkgs = true;
          home-manager.useUserPackages = true;
          home-manager.users.developer = import ./home.nix;
        }
      ];
    };
  };
}

Build and switch to this configuration with:

nixos-rebuild switch --flake .#myhost

The flake.lock file records the exact git commit of nixpkgs that was used. Commit both flake.nix and flake.lock to git. When a teammate or a new machine runs nixos-rebuild switch --flake .#myhost, they get the exact same packages regardless of when they run it.

Update all inputs to their latest revisions with nix flake update. The lock file is updated and you can review the diff before committing.

Multi-Machine Configurations

One flake.nix can declare multiple NixOS configurations:

nixosConfigurations = {
  laptop = nixpkgs.lib.nixosSystem {
    modules = [ ./hosts/laptop/configuration.nix ./modules/common.nix ];
  };
  server = nixpkgs.lib.nixosSystem {
    modules = [ ./hosts/server/configuration.nix ./modules/common.nix ];
  };
  workstation = nixpkgs.lib.nixosSystem {
    modules = [ ./hosts/workstation/configuration.nix ./modules/common.nix ];
  };
};

Shared settings (time zone, base packages, security hardening) live in ./modules/common.nix. Machine-specific settings live in each host’s directory. A change to the common module applies consistently across all machines when you rebuild.

For a production-ready starting point, nix-starter-configs provides beginner, standard, and full variants of NixOS flake setups. Starting from one of these is faster than building the structure from scratch.

Home Manager

Home Manager extends the declarative model to user-level configuration: shell aliases, git config, fonts, VSCode extensions, GTK themes, and dotfiles. A home.nix for a developer might look like:

{ pkgs, ... }:

{
  home.stateVersion = "24.11";

  programs.zsh = {
    enable = true;
    autosuggestion.enable = true;
    syntaxHighlighting.enable = true;
    oh-my-zsh = {
      enable = true;
      theme = "robbyrussell";
    };
  };

  programs.git = {
    enable = true;
    userName = "Your Name";
    userEmail = "you@example.com";
    extraConfig.init.defaultBranch = "main";
  };

  programs.neovim = {
    enable = true;
    defaultEditor = true;
  };
}

Every setting in home.nix is applied on nixos-rebuild switch. Dotfiles are no longer a separate git repository you remember to sync manually.

Nix Develop Shells: The Dependency Management Game-Changer

Even if you are not ready to commit to NixOS as your primary operating system, Nix’s development shell feature solves the dependency isolation problem on any Linux or macOS machine.

nix develop enters a shell with exactly the packages declared in a flake’s devShells output. When you exit the shell, those packages disappear from PATH. Nothing is installed system-wide. Any machine with Nix installed gets an identical environment.

A Python Django Development Shell

{
  description = "Django project development environment";

  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";

  outputs = { self, nixpkgs }: {
    devShells.x86_64-linux.default =
      let pkgs = nixpkgs.legacyPackages.x86_64-linux;
      in pkgs.mkShell {
        buildInputs = with pkgs; [
          python312
          uv
          postgresql_15
          redis
          nodejs_22
          pre-commit
        ];

        shellHook = ''
          echo "Django dev environment ready"
          export DATABASE_URL="postgresql://localhost/myapp_dev"
        '';
      };
  };
}

Run nix develop in the project root to enter this environment. PostgreSQL 15, Redis, Python 3.12, and Node.js 22 are available exactly as declared. A colleague on a different machine runs the same command and gets the same environment, because flake.lock pins every version.

Automatic Activation with direnv

Manually running nix develop gets old quickly. direnv combined with nix-direnv automates this:

# .envrc in the project root
use flake

Run direnv allow once. From that point, cd-ing into the project directory activates the Nix shell automatically. Moving out deactivates it. VSCode with the direnv extension picks up the environment for LSP servers, so your editor’s Python or Rust tooling uses the project’s pinned versions without any manual configuration.

Replacing Docker for Development

Many teams use Docker Compose to provide consistent development environments. Nix development shells achieve the same goal with some practical advantages: no container spin-up time, native filesystem performance, and better IDE integration since the tools run directly on the host.

The main advantage Docker retains for development is process isolation - running PostgreSQL in a container keeps it cleanly separated from anything else on the host. For teams that need that isolation, the two approaches can coexist: use nix develop for the language toolchain and compilers, and Docker Compose for databases and external services.

Cross-language projects work naturally in a single shell. A project that requires Python, Rust (via the fenix overlay for nightly toolchains), and Node.js can declare all three in one buildInputs list without virtualenvs, nvm, or rbenv.

Common NixOS Pitfalls and How to Avoid Them

The NixOS learning curve has predictable rough spots, and most developers run into the same set of problems within the first month.

Packages That Need Service Enablement

Adding a package to systemPackages installs the binary but does not start any associated service. PostgreSQL is the canonical example: pkgs.postgresql puts psql in PATH, but the database server is not running until you add services.postgresql.enable = true;. Check the NixOS options search at search.nixos.org/options when a package does not behave as expected.

FHS Violations

NixOS does not have /usr/bin, /lib/x86_64-linux-gnu/, or the other standard filesystem locations that most Linux software assumes. Software that hardcodes these paths fails. There are a few ways to handle this:

  • steam-run: wraps a command in an FHS-compatible environment. Good for gaming utilities and proprietary binaries.
  • buildFHSEnv: creates a persistent FHS chroot for applications that need it.
  • patchelf: rewrites ELF binaries to use Nix store library paths directly.
  • programs.nix-ld.enable = true: enables a compatibility shim that handles the most common case of unpatched binaries expecting a standard dynamic linker path. Add this to your configuration and most downloaded Linux binaries will work without further modification.

Nix Store Bloat

After months of nixos-rebuild switch, old generations accumulate in the store. Each generation holds references to all the packages it uses, preventing garbage collection. Clean up with:

nix-collect-garbage -d    # removes all but the current generation

Or automate it in configuration.nix:

nix.gc = {
  automatic = true;
  dates = "weekly";
  options = "--delete-older-than 30d";
};

A typical developer system stabilizes at 30-60GB of store size after automated cleanup is in place.

VSCode Remote SSH

Connecting to a remote NixOS server via VSCode Remote SSH requires the server to present a working shell environment. The VSCode server binary it installs expects standard FHS paths. Adding programs.nix-ld.enable = true; to the remote machine’s configuration resolves most issues.

Flake Experimental Features

Flakes require experimental features to be enabled in Nix’s configuration. On NixOS 24.11 and later, new installations enable this by default. Existing installations may need:

nix.settings.experimental-features = [ "nix-command" "flakes" ];

The First-Build Delay

The first nixos-rebuild switch after adding many new packages can be slow if binaries are not cached. The official binary cache at cache.nixos.org covers almost everything in nixpkgs. For team environments where multiple developers are rebuilding with the same configuration, setting up a local Nix binary cache with Attic or Cachix eliminates redundant downloads and dramatically reduces rebuild times.

NixOS vs. Arch Linux vs. Fedora

FeatureNixOSArch LinuxFedora
ReproducibilityBit-for-bit with flake.lockManual effort requiredModerate (DNF lockfiles)
RollbackBuilt-in, per-generationManual snapshots (Snapper)Limited (rpm-ostree on Silverblue)
Package freshnessnixpkgs-unstable is among the freshest; stable is slightly behindRolling, very freshLatest stable per release cycle
Learning curveSteep (Nix language)Moderate (manual setup)Low (familiar tooling)
Config as codeYes, first-classNoNo (except Silverblue)
Multi-machine managementExcellent (flakes)ManualManual

Arch Linux offers the freshest packages and a high degree of control, but reproducibility requires manual effort. Fedora (standard edition) is straightforward but offers no built-in rollback and no declarative configuration. Fedora Silverblue uses rpm-ostree for atomic updates and rollback, which is closer to NixOS’s model, but the declarative configuration story is weaker.

Where to Go From Here

The fastest way to start is to run Nix on your existing machine without committing to a full NixOS install. Install Nix on any Linux distribution or macOS using the Determinate Nix Installer , which handles multi-user installation and uninstallation cleanly. Then add a flake.nix to an existing project and try nix develop. The development shell workflow is independently useful and gives you hands-on experience with Nix before touching your system configuration.

When you are ready for a full install, use nix-starter-configs as a starting point, read through the NixOS manual , and expect the first two weeks to involve frequent consultation of the nixpkgs source. After that, most configuration tasks become straightforward pattern-matching against what you’ve already written.

The reproducibility guarantee is real, and it compounds over time. The second machine you configure takes minutes instead of hours. New team members get a working environment on day one. System changes are reversible with a single command. That is a reasonable trade for a steep learning curve.