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:
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.mkIfinternally 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:
{
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:
{ 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:
{ 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:
{ 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:
{ 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:
# ~/.gitconfig-work
[user]
name = Jane Doe
email = jane.doe@acme-corp.com# ~/.gitconfig-personal
[user]
name = Jane Doe
email = janedoe@example.comIf 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:
{ 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:
$ sudo darwin-rebuild switch --flake .On Linux:
$ home-manager switch --flake .#developer@linuxBoth commands evaluate the configuration for the current platform and atomically activate it. If something goes wrong, roll back:
$ home-manager generations # list previous generations
$ sudo darwin-rebuild --list-generations # on macOSValidate both platforms in CI without applying:
$ nix flake checkWhy this works
Whether you are managing a single personal setup or rolling this out across a team, the approach provides concrete benefits:
Instant setup. Clone the repo, run one
switchcommand, and get a fully configured desktop. For teams, this turns onboarding from days into minutes.Pinned versions everywhere. The
flake.lockensures every machine uses the same nixpkgs revision. Update it in a PR, review, merge — every machine gets the update on the nextswitch.Multi-platform parity. macOS laptops and Linux workstations share the same shell, applications, and configuration. The
mkIfguards handle platform differences transparently.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.
No ambient state. Applications come from the Nix store, not from
brew installorapt-getorcurl | sh. There is no hidden state that differs between machines.
Quick reference
| Command | What it does |
|---|---|
sudo darwin-rebuild switch --flake . | Apply config on macOS |
home-manager switch --flake .#user@linux | Apply config on Linux |
nix flake check | Validate both platforms without applying |
nix flake update | Update all inputs to latest |
nix flake update nixpkgs | Update only nixpkgs |
home-manager generations | List previous generations |
sudo darwin-rebuild --list-generations | List generations on macOS |