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.nixties everything together at the top levelhost/contains machine-specific configuration (hardware, services, etc.)home/contains user environment configuration that’s shared across machinessecrets/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.