Guides

End Environment Drift: Manage macOS & Linux from a Single Nix Repo

Manage your entire desktop environment — dotfiles, applications, shell, system preferences — declaratively across macOS and Linux from a single repository.

8 min read · Nix Home Manager nix-darwin Dotfiles

The problem: environment drift

Anyone who uses more than one computer knows the pain: you spend hours setting up your shell, editor, applications, and system preferences on one machine, only to start from scratch on the next. Multiply that across macOS and Linux, personal and work machines, and the gap widens. Developers feel this acutely — different tool versions cause bugs that appear on one machine but not another — but the problem applies to any desktop user who wants a consistent environment.

This is environment drift, and it compounds over time. The more devices and platforms you use, the more things slip through the cracks.

Nix eliminates drift at the root. With Home Manager and nix-darwin, you declare your entire desktop environment — applications, shell, editor, terminal, git, system preferences — in Nix. The same flake.lock pins every dependency to the same version on every machine. One repository, one source of truth, across macOS and Linux.

Note: This guide presents one opinionated way to structure a Nix-based dotfiles setup. There are many valid approaches — the goal here is to outline the overall idea and give you a concrete starting point you can adapt to your own workflow.

Repository structure

A well-organized dotfiles repo separates concerns into modules. Each tool gets its own file, and platform-specific logic lives inside the modules themselves:

text
dotfiles/
├── flake.nix              # inputs, outputs, platform targets
├── flake.lock             # pinned dependency versions
├── home.nix               # root Home Manager config (imports modules)
├── darwin.nix             # macOS system-level settings (nix-darwin)
├── modules/
│   ├── packages.nix       # shared package declarations
│   ├── sh.nix             # shell configuration
│   ├── git.nix            # Git settings, aliases, signing
│   ├── direnv.nix         # automatic environment loading
│   └── ...                # editor, terminal, prompt, etc.
├── dotfiles/              # platform-specific static files
│   ├── common/            # shared across all platforms
│   ├── darwin/            # macOS-only
│   └── linux/             # Linux-only
└── secrets/               # encrypted secrets (sops)

One module per concern. Every module imports on both platforms and uses lib.mkIf internally to handle differences. No platform-specific import lists to maintain.

Step 1: The flake

The flake.nix defines inputs and outputs for each platform target. A helper function keeps things DRY:

nix
{
  description = "Dotfiles — cross-platform desktop environment";

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

  outputs = { self, nixpkgs, home-manager, nix-darwin, ... }:
  let
    username = "developer";

    mkPkgs = system: import nixpkgs {
      inherit system;
      config.allowUnfree = true;
    };

    mkHome = system: home-manager.lib.homeManagerConfiguration {
      pkgs = mkPkgs system;
      modules = [ ./home.nix ];
      extraSpecialArgs = { inherit self username; };
    };
  in {
    # Linux: standalone Home Manager
    homeConfigurations."${username}@linux" = mkHome "x86_64-linux";

    # macOS: nix-darwin wraps Home Manager
    darwinConfigurations.${username} = nix-darwin.lib.darwinSystem {
      pkgs = mkPkgs "aarch64-darwin";
      specialArgs = { inherit username; };
      modules = [
        ./darwin.nix
        home-manager.darwinModules.home-manager
        {
          home-manager.useGlobalPkgs = true;
          home-manager.useUserPackages = true;
          home-manager.users.${username} = import ./home.nix;
          home-manager.extraSpecialArgs = { inherit self username; };
          users.users.${username}.home = "/Users/${username}";
        }
      ];
    };

    # Validate both platforms in CI
    checks = {
      "x86_64-linux".home = (mkHome "x86_64-linux").activationPackage;
      "aarch64-darwin".darwin =
        self.darwinConfigurations.${username}.system;
    };
  };
}

Linux uses standalone Home Manager (home-manager switch). macOS uses nix-darwin, which wraps Home Manager so you get both user-level and system-level configuration in one darwin-rebuild switch. The checks output validates both platform configurations — run nix flake check in CI to catch breakage before it hits anyone’s machine.

Step 2: Home Manager root config

home.nix imports every module and sets platform-aware defaults:

nix
{ config, pkgs, username, ... }:
{
  imports = [
    ./modules/packages.nix
    ./modules/sh.nix
    ./modules/git.nix
    ./modules/direnv.nix
    # add more modules as needed: editor, terminal, prompt, etc.
  ];

  home.username = username;
  home.homeDirectory =
    if pkgs.stdenv.isDarwin
    then "/Users/${config.home.username}"
    else "/home/${config.home.username}";

  xdg.enable = true;
  programs.home-manager.enable = true;
  home.stateVersion = "26.05";
}

Every module is imported on both platforms. Platform differences are handled inside each module with pkgs.stdenv.isDarwin and pkgs.stdenv.isLinux.

Step 3: Shared packages

Declare your packages once. Every machine gets the same versions:

nix
{ pkgs, ... }:
let
  isLinux = pkgs.stdenv.isLinux;
in {
  home.packages = with pkgs; [
    bat eza fd ripgrep fzf wget  # core CLI
    go gopls golangci-lint       # development (example: Go)
    trivy opentofu               # infrastructure
  ]
  ++ pkgs.lib.optionals isLinux [
    glibcLocales  # locale data for Nix programs on non-NixOS
  ];
}

The lib.optionals isLinux pattern adds packages conditionally. On non-NixOS distributions (Ubuntu, Fedora, Arch), Nix-installed programs need glibcLocales to find locale data — on NixOS or macOS it is already available.

Step 4: Cross-platform shell

Both platforms get the same aliases, history, and completions — only platform-specific integrations differ:

nix
{ config, pkgs, lib, ... }:
let
  isLinux = pkgs.stdenv.isLinux;
  isDarwin = pkgs.stdenv.isDarwin;
in {
  programs.zsh = {
    enable = true;
    autosuggestion.enable = true;
    syntaxHighlighting.enable = true;
    enableCompletion = true;

    history = {
      size = 50000;
      ignoreDups = true;
      ignoreAllDups = true;
      share = true;
    };

    shellAliases = {
      ls = "eza --group-directories-first";
      ll = "eza -l --group-directories-first --git --icons=always";
      la = "eza -la --group-directories-first";
      lt = "eza --tree --group-directories-first";
    };

    initContent = ''
      # Word navigation with Ctrl+arrow
      bindkey '^[[1;5D' backward-word
      bindkey '^[[1;5C' forward-word
      bindkey '^H'      backward-kill-word
    ''
    + lib.optionalString isDarwin ''
      eval "$(/opt/homebrew/bin/brew shellenv)"
    ''
    + lib.optionalString isLinux ''
      [ -f "$HOME/.nix-profile/etc/profile.d/hm-session-vars.sh" ] \
        && . "$HOME/.nix-profile/etc/profile.d/hm-session-vars.sh"
    '';
  };

  # Expose Nix binaries to the systemd session (desktop entries, etc.)
  xdg.configFile."environment.d/10-nix-path.conf" = lib.mkIf isLinux {
    text = "PATH=$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH";
  };

  # Locale data for Nix programs on non-NixOS Linux
  home.sessionVariables = pkgs.lib.mkIf isLinux {
    LOCALE_ARCHIVE = "${pkgs.glibcLocales}/lib/locale/locale-archive";
  };
}

The lib.optionalString and lib.mkIf patterns are the building blocks. Every module follows the same approach: shared config at the top, platform-specific blocks gated by isDarwin / isLinux.

Step 5: Git with conditional identities

Declarative git config with conditional includes is especially valuable when switching between work and personal repositories:

nix
{ config, pkgs, ... }:
let
  isDarwin = pkgs.stdenv.isDarwin;
  signingKey =
    if isDarwin
    then "${config.home.homeDirectory}/.ssh/id_ed25519_sk.pub"
    else "${config.home.homeDirectory}/.ssh/id_ed25519.pub";
in {
  programs.git = {
    enable = true;
    signing = { key = signingKey; signByDefault = true; format = "ssh"; };
    lfs.enable = true;

    # Switch identity based on repository location
    includes = [
      { path = "~/.gitconfig-work";     condition = "gitdir:~/src/gitlab.com/acme-corp/"; }
      { path = "~/.gitconfig-personal"; condition = "gitdir:~/src/github.com/"; }
    ];

    settings = {
      user.useConfigOnly = true;
      push.default = "simple";
      pull.rebase = true;
      fetch.prune = true;
      rebase.autoStash = true;
      init.defaultBranch = "main";
      pager.show = "bat";
      alias = {
        s = "status -s";
        lg = "log --graph --abbrev-commit --decorate --date=relative --format=format:'%C(bold blue)%h%C(reset) - %C(bold green)(%ar)%C(reset) %C(white)%s%C(reset) %C(dim white)- %an%C(reset)%C(bold yellow)%d%C(reset)' --all";
      };
    };
  };
}

The referenced config files contain the identity for each context:

ini
# ~/.gitconfig-work
[user]
  name = Jane Doe
  email = jane.doe@acme-corp.com
ini
# ~/.gitconfig-personal
[user]
  name = Jane Doe
  email = janedoe@example.com

If you use different signing keys for work and personal projects, you can move the signing key out of the global git config and into each conditional file instead.

The gitdir: conditions mean your work email and signing identity are automatically active inside work repos, while personal settings apply everywhere else.

Step 6: macOS system preferences

On macOS, nix-darwin declaratively manages system preferences that normally require clicking through System Settings:

nix
{ username, ... }:
{
  system.primaryUser = username;

  homebrew = {
    enable = true;
    onActivation = { autoUpdate = true; upgrade = true; cleanup = "zap"; };
    casks = [ "firefox" "rectangle" ];
    brews = [ "ffmpeg" "wireguard-tools" ];
  };

  system.defaults = {
    dock = {
      autohide = true;
      mru-spaces = false;
      show-recents = false;
    };
    finder = {
      FXEnableExtensionChangeWarning = false;
      _FXShowPosixPathInTitle = true;
    };
    NSGlobalDomain = {
      AppleShowAllExtensions = true;
      AppleInterfaceStyleSwitchesAutomatically = true;
      NSDocumentSaveNewDocumentsToCloud = false;
    };
    CustomUserPreferences."com.apple.desktopservices" = {
      DSDontWriteNetworkStores = true;
      DSDontWriteUSBStores = true;
    };
  };

  system.stateVersion = 6;
}

The homebrew.onActivation.cleanup = "zap" removes any cask or formula not listed in the config, preventing drift from ad-hoc installs.

Applying the configuration

On macOS:

terminal
$ sudo darwin-rebuild switch --flake .

On Linux:

terminal
$ home-manager switch --flake .#developer@linux

Both commands evaluate the configuration for the current platform and atomically activate it. If something goes wrong, roll back:

terminal
$ home-manager generations          # list previous generations
$ sudo darwin-rebuild --list-generations  # on macOS

Validate both platforms in CI without applying:

terminal
$ nix flake check

Why this works

Whether you are managing a single personal setup or rolling this out across a team, the approach provides concrete benefits:

  1. Instant setup. Clone the repo, run one switch command, and get a fully configured desktop. For teams, this turns onboarding from days into minutes.

  2. Pinned versions everywhere. The flake.lock ensures every machine uses the same nixpkgs revision. Update it in a PR, review, merge — every machine gets the update on the next switch.

  3. Multi-platform parity. macOS laptops and Linux workstations share the same shell, applications, and configuration. The mkIf guards handle platform differences transparently.

  4. Audit trail. Every change to the environment is a git commit. You can see exactly when a package version changed, who changed it, and why.

  5. No ambient state. Applications come from the Nix store, not from brew install or apt-get or curl | sh. There is no hidden state that differs between machines.

Quick reference

CommandWhat it does
sudo darwin-rebuild switch --flake .Apply config on macOS
home-manager switch --flake .#user@linuxApply config on Linux
nix flake checkValidate both platforms without applying
nix flake updateUpdate all inputs to latest
nix flake update nixpkgsUpdate only nixpkgs
home-manager generationsList previous generations
sudo darwin-rebuild --list-generationsList generations on macOS