4 min read
Managing Multiple Systems with a Single Nix Flake

One of the most powerful features of Nix is its ability to manage system configurations in a declarative and reproducible way. When you start using Nix on multiple machines, especially across different operating systems like NixOS and macOS, a well-structured Nix Flake becomes essential. In this post, we’ll explore how to create a clean, maintainable, and scalable multi-system setup using a single Nix Flake.

The Goal: A Unified Configuration

The main goal is to have a single repository that manages the configurations for all your machines, whether they are running NixOS or macOS. This allows you to share configurations, packages, and modules across your systems, ensuring a consistent environment everywhere.

The flake.nix Structure

The heart of this setup is the flake.nix file. Let’s break down how it’s structured to handle multiple systems.

Inputs

First, we define all the necessary inputs for our flake. This includes nixpkgs, home-manager, and nix-darwin, as well as any other flakes we might need.

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
    home-manager = {
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    nix-darwin = {
      url = "github:LnL7/nix-darwin/master";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    // ... other inputs
  };
  // ...
}

Outputs and Helper Functions

The outputs section is where the magic happens. We define helper functions, mkNixosConfiguration and mkDarwinConfiguration, to generate the system configurations. This approach keeps our code DRY (Don’t Repeat Yourself) and makes it easy to add new hosts.

Here’s what the mkNixosConfiguration function looks like:

# Function for NixOS system configuration
mkNixosConfiguration = system: hostname: username:
  nixpkgs.lib.nixosSystem {
    specialArgs = {
      inherit inputs outputs username hostname system;
      userConfig = users.${username};
      hostConfig = hosts.${hostname};
      nixosModules = "${self}/modules/nixos";
      vars = import ./hosts/${hostname}/vars.nix;
    };
    modules = [/* ... */];
  };

And the mkDarwinConfiguration function:

# Function for nix-darwin system configuration
mkDarwinConfiguration = system: hostname: username:
  nix-darwin.lib.darwinSystem {
    system = system;
    specialArgs = { /* ... */ };
    modules = [/* ... */];
  };

These functions take the system (e.g., x86_64-linux), hostname, and username as arguments and return a complete system configuration. They use specialArgs to pass down inputs, user and host configurations, and paths to reusable modules to the rest of the configuration.

Defining the Systems

With these helper functions in place, defining the actual system configurations becomes very clean and simple:

nixosConfigurations = {
  orbstack = mkNixosConfiguration "aarch64-linux" "orbstack" "arar";
  homelab = mkNixosConfiguration "x86_64-linux" "homelab" "arar";
};

darwinConfigurations = {
  "work-mac" = mkDarwinConfiguration "aarch64-darwin" "kpn-mac" "arar";
  "mac" = mkDarwinConfiguration "aarch64-darwin" "mac" "arar";
};

Shared Modules and Home Manager

This setup also makes it easy to share configurations between systems. For example, you can have a common module for home-manager that is imported by both your NixOS and macOS configurations. This allows you to share your shell aliases, editor configuration, and other dotfiles across all your machines.

In the mkNixosConfiguration and mkDarwinConfiguration functions, you can see how home-manager is integrated and how it imports modules from a shared path.

Conclusion

Using a single Nix Flake to manage multiple systems is a powerful way to maintain a consistent and reproducible environment. By using helper functions and a modular structure, you can create a clean, scalable, and easy-to-manage configuration for all your machines, regardless of their operating system.