Building a Reproducible Multi-Machine Setup with NixOS Flakes

After years of manually configuring Linux systems and dealing with the inevitable drift that comes with imperative system management, I made the switch to NixOS. What started as curiosity about functional package management has evolved into a completely declarative approach to system configuration that I can’t imagine living without.

In this post, I’ll walk through how I structure my NixOS configuration using flakes to manage multiple machines, integrate home-manager for user environments, and handle secrets with agenix. You can find the complete configuration on GitHub.

The Philosophy: Everything Should Be Reproducible

My approach to system configuration is heavily influenced by the idea that everything should be reproducible. If I can’t rebuild my exact system state from a git repository, something is wrong. This means:

  • System packages and services are declared in Nix
  • User environments are managed through home-manager
  • Dotfiles and configurations are version controlled
  • Secrets are encrypted and managed declaratively
  • Hardware differences are isolated to specific modules

This isn’t just theoretical - I regularly rebuild systems from scratch, and the entire process takes maybe 30 minutes with minimal manual intervention. There are even some tools that can declaratively set up your disks for you, which I may look into at some point.

Repository Structure: Organization That Scales

Here’s how I organize my configuration:

nixos/
├── README.md
├── flake.nix             # Main flake definition
├── flake.lock            # Pinned dependency versions
├── assets/               # Static assets (wallpapers, etc.)
│   └── desktop.jpg
├── home/                 # User environment configs
│   ├── default.nix       # Home-manager entry point
│   └── anderson/         # User-specific configuration
│       ├── default.nix
│       ├── programs/     # Program configurations
│       ├── services/     # User services
│       └── themes/       # Theming and aesthetics
├── host/                 # Machine-specific configs
│   ├── default.nix       # Host configuration entry point
│   ├── desktop/          # Desktop machine config
│   ├── laptop/           # Laptop machine config
│   └── server/           # Server machine config
└── secrets/              # Encrypted secrets
    ├── secrets.nix       # agenix secret definitions
    └── *.age             # Encrypted secret files

This structure scales well because it separates concerns cleanly:

  • flake.nix ties everything together at the top level
  • host/ contains machine-specific configuration (hardware, services, etc.)
  • home/ contains user environment configuration that’s shared across machines
  • secrets/ keeps sensitive data encrypted but still version controlled

The Heart: Flake Configuration

The flake.nix is where the magic happens. Here’s the basic structure I use:

{
  description = "Quanchobi's NixOS configuration flake";

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

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

    agenix = {
      url = "github:ryantm/agenix";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    # Add other inputs as needed
  };

  outputs = { nixpkgs, home-manager, agenix, ... }: {
    nixosConfigurations = {
      # Desktop configuration
      desktop = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./host/desktop
          home-manager.nixosModules.home-manager
          agenix.nixosModules.default
          {
            home-manager.useGlobalPkgs = true;
            home-manager.useUserPackages = true;
            home-manager.users.anderson = import ./home/anderson;
          }
        ];
      };

      # Laptop configuration
      laptop = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./host/laptop
          home-manager.nixosModules.home-manager
          agenix.nixosModules.default
          {
            home-manager.useGlobalPkgs = true;
            home-manager.useUserPackages = true;
            home-manager.users.anderson = import ./home/anderson;
          }
        ];
      };
    };
  };
}

Multi-Machine Management: The Host System

Each machine gets its own directory under host/ with machine-specific configuration. Here’s what a typical host configuration looks like:

# host/desktop/default.nix
{ config, pkgs, ... }: {
  imports = [
    ./hardware-configuration.nix  # Generated by nixos-generate-config
    ./gaming.nix                  # Gaming-specific packages and config
    ./development.nix             # Development tools and services
  ];

  # Machine-specific settings
  networking.hostName = "desktop";

  # Desktop-specific services
  services.xserver.enable = true;
  services.xserver.videoDrivers = [ "nvidia" ];

  # Hardware-specific optimizations
  hardware.nvidia.open = false;  # Use proprietary drivers for now
  hardware.pulseaudio.enable = false;  # Use PipeWire instead

  # This machine runs some services
  services.openssh.enable = true;
  services.docker.enable = true;

  # Boot configuration
  boot.loader.systemd-boot.enable = true;
  boot.loader.efi.canTouchEfiVariables = true;

  system.stateVersion = "23.11";
}

The key insight here is that I can share most configuration between machines while keeping machine-specific details isolated. My laptop might disable gaming packages and enable power management, while my server disables the GUI entirely.

User Environment: Home-Manager Integration

Home-manager is where the real power shines for user environment management. Instead of scattering dotfiles across multiple repositories, everything lives in one place:

# home/anderson/default.nix
{ config, pkgs, ... }: {
  imports = [
    ./programs
    ./services
    ./themes
  ];

  # Basic user info
  home.username = "anderson";
  home.homeDirectory = "/home/anderson";
  home.stateVersion = "23.11";

  # Let home-manager manage itself
  programs.home-manager.enable = true;

  # Shell configuration
  programs.zsh = {
    enable = true;
    enableCompletion = true;
    autosuggestion.enable = true;
    syntaxHighlighting.enable = true;

    shellAliases = {
      ll = "ls -la";
      ".." = "cd ..";
      "..." = "cd ../..";
      rebuild = "sudo nixos-rebuild switch --flake ~/nixos#$(hostname)";
      update = "cd ~/nixos && nix flake update && sudo nixos-rebuild switch --flake .#$(hostname)";
    };
  };

  # Development packages I want everywhere
  home.packages = with pkgs; [
    git
    vim
    tmux
    htop
    tree
    jq
    curl
    wget
  ];
}

Program Configuration: Declarative Dotfiles

Instead of managing dotfiles manually, I declare program configurations directly in Nix. Here’s how I handle my development setup:

# home/anderson/programs/development.nix
{ config, pkgs, ... }: {
  # Git configuration
  programs.git = {
    enable = true;
    userName = "anderson";
    userEmail = "your-email@example.com";

    extraConfig = {
      core.editor = "nvim";
      pull.rebase = true;
      init.defaultBranch = "main";
    };

    aliases = {
      st = "status";
      co = "checkout";
      br = "branch";
      ci = "commit";
      unstage = "reset HEAD --";
    };
  };

  # Neovim configuration (references my separate nvim flake)
  programs.neovim = {
    enable = true;
    viAlias = true;
    vimAlias = true;
  };

  # Terminal multiplexer
  programs.tmux = {
    enable = true;
    terminal = "tmux-256color";
    historyLimit = 100000;

    extraConfig = ''
      # Better prefix key
      unbind C-b
      set -g prefix C-a
      bind C-a send-prefix

      # Vi mode
      setw -g mode-keys vi
    '';
  };
}

The beauty of this approach is that every program configuration is:

  • Version controlled with the rest of my system
  • Declarative and reproducible
  • Shared across all my machines automatically
  • Backed up whenever I push to git

Secret Management: Agenix Integration

Handling secrets in a declarative system is tricky, but agenix makes it manageable. Here’s how I structure secret management, as an example:

# secrets/secrets.nix
let
  anderson = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI..."; # My SSH public key
  desktop = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...";   # Desktop host key
  laptop = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...";    # Laptop host key
in {
  "wifi-password.age".publicKeys = [ anderson desktop laptop ];
  "nextcloud-admin.age".publicKeys = [ anderson desktop ];
  "git-token.age".publicKeys = [ anderson desktop laptop ];
}

Then in my configuration, I can reference secrets declaratively:

# In a host configuration
{ config, ... }: {
  age.secrets.wifi-password = {
    file = ../secrets/wifi-password.age;
    owner = "networkmanager";
  };

  networking.wireless.networks."MyWiFi".pskRaw =
    config.age.secrets.wifi-password.path;
}

This gives me the benefits of version-controlled secrets without exposing sensitive data in plain text.

Deployment Workflow: From Code to Running System

My typical workflow for updating systems is beautifully simple:

# Make configuration changes
vim ~/nixos/host/desktop/gaming.nix

# Test the configuration (doesn't switch)
sudo nixos-rebuild test --flake ~/nixos#desktop

# If everything works, switch permanently
sudo nixos-rebuild switch --flake ~/nixos#desktop

# Update all inputs and rebuild
cd ~/nixos
nix flake update
sudo nixos-rebuild switch --flake .#$(hostname)

Because everything is declarative, I can:

  • Test changes safely before committing to them
  • Roll back to any previous generation instantly
  • Share exact configurations between machines
  • Rebuild from scratch at any time

Benefits I’ve Experienced

After using this setup for over a year, the benefits are clear:

Reproducibility: I can rebuild any machine from scratch in under an hour. Hardware dies? No problem - restore from backups and rebuild.

Consistency: All my machines have identical user environments. Muscle memory works everywhere.

Version Control: Every system change is tracked in git. I can see exactly what changed between any two points in time.

Experimentation: Want to try a new window manager? Add it to the config, test it, and either keep it or roll back instantly.

Documentation: My configuration is the documentation. Future me (and anyone else) can understand exactly how everything is set up.

The Road Ahead

This setup continues to evolve. Some things I’m working on:

  • Separating home-manager: Making it its own flake so I can use it on non-NixOS systems
  • Better secret management: Expanding agenix usage for more services
  • Service deployment: Using the same approach for home server services
  • Cross-platform support: Extending configurations to work on macOS via nix-darwin

Final Thoughts

NixOS with flakes represents a fundamentally different approach to system management. Instead of imperatively configuring systems and hoping they stay consistent, everything is declared up front and reproducibly built.

Is it more complex than traditional Linux distributions? Initially, yes. The learning curve is steep, and thinking declaratively about system configuration requires a mental shift.

But the payoff is enormous. I no longer worry about system configuration drift, broken upgrades, or losing important settings. My systems are exactly what I declare them to be, every time.

If you’re interested in exploring this approach, I’d recommend starting with a simple flake configuration and gradually building complexity. The NixOS community is helpful, and the documentation continues to improve.

My configuration is available on GitHub - feel free to explore, borrow what’s useful, but remember to adapt it for your own hardware and preferences.

There are many NixOS configurations like it, but this one is mine.