Managing dotfiles on macOS with Nix
In part one of this series we
installed Nix, set up our system configuration with nix-darwin
, and installed some
packages at the system level. In this post, we’ll set up
home-manager
.
Unlike nix-darwin
, home-manager
is cross-platform: it works across NixOS, macOS, and
anywhere else Nix can be installed. It was difficult at first for me to understand how
home-manager
and nix-darwin
should interact. While there is definitely overlap with
what these two Nix libraries can do, nix-darwin
is used for managing system-wide
settings and applications: it brings the power of NixOS to the Mac. home-manager
on the
other hand is most useful for managing user-level configuration and dotfiles.
By the end of this post we’ll have installed home-manager
and used it to set up
configuration for vim, zsh, and git.
Install home-manager
home-manager
will be another input to our flake. In our inputs
attribute, add
home-manager
under nix-darwin
in flake.nix
:
{
description = "My system configuration";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
nix-darwin = {
url = "github:LnL7/nix-darwin";
inputs.nixpkgs.follows = "nixpkgs";
};
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
};
# ...
We will then add the minimal home-manager config as another module under configuration
:
# ...
outputs = inputs@{ self, nixpkgs, nix-darwin, home-manager }:
let
configuration = {pkgs, ... }: {
# ... nix-darwin configuration from part 1
# remains unchanged here
};
homeconfig = {pkgs, ...}: {
# this is internal compatibility configuration
# for home-manager, don't change this!
home.stateVersion = "23.05";
# Let home-manager install and manage itself.
programs.home-manager.enable = true;
home.packages = with pkgs; [];
home.sessionVariables = {
EDITOR = "vim";
};
};
in
{
darwinConfigurations."$HOSTNAME" = nix-darwin.lib.darwinSystem {
modules = [
configuration
home-manager.darwinModules.home-manager {
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
home-manager.verbose = true;
home-manager.users.$USER = homeconfig;
}
];
};
};
}
Our darwinSystem
setup now includes another module that bridges together home-manager
and nix-darwin
. Remember to replace $USER
with your system user!
Run darwin-rebuild switch --flake ~/.config/nix
to ensure everything is set up
correctly.
Managing .vimrc
Let’s manage our first dotfile! First, we’ll create a new file in our repository and add it to git so that Nix can recognize it:
$ cd ~/.config/nix
$ echo "set number" > vim_configuration
$ git add vim_configuration
Since this isn’t a vim configuration series we’ll just have a small config that will
demonstrate how home-manager
manages dotfiles. You should be able to see line numbers
when running vim after this configuration is applied.
Inside our homeconfig
block before the closing };
, add this line:
home.file.".vimrc".source = ./vim_configuration;
This one line highlights a few subtleties of the Nix language. Let’s dig in a bit before moving on.
Paths
In Nix, unlike most languages, Paths are first class primitive
values that are not inside
of quotes and start with ./
for paths relative to the current file or /
for absolute
paths. ./vim_configuration
points to the file that we created in the same directory as
flake.nix
.
You’ll run into type errors if you try passing strings to options where paths are expected.
Setting attributes in Nix
Attribute sets are probably the most ubiquitious datatype in Nix. They’re the equivalent of dictionaries in other languages. Something that’s pretty unique to Nix is that setting nested attributes are supported with some clever syntactic sugar. Desugaring our vim dotfile line above, we would get:
home = {
file = {
".vimrc" = {
source = ./vim_configuration;
};
};
};
This shorthand is useful when our attribute sets are sparse, and they can be combined with the “full” attribute set literal along the path. If we had two dotfiles, this:
home.file.".vimrc".source = ./vim_configuration;
home.file.".bash_profile".source = ./bash_configuration;
Is equivalent to this:
home.file = {
".vimrc".source = ./vim_configuration;
".bash_profile".source = ./bash_configuration;
};
There are other options to pass to dotfiles besides source
like onChange
and
recursive
. See the home-manager
documentation
for more details.
Once you run darwin-rebuild switch --flake ~/.config/nix
, run vim ~/.config/nix/flake.nix
. You should see line numbers in the left gutter:
1 {
2 description = "My system configuration";
3
4 inputs = {
5 nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
6 nix-darwin = {
7 url = "github:LnL7/nix-darwin";
8 inputs.nixpkgs.follows = "nixpkgs";
9 };
10 home-manager = {
11 url = "github:nix-community/home-manager";
12 inputs.nixpkgs.follows = "nixpkgs";
13 };
14 };
Not the most exciting change, but we’ve configured an application using home-manager
!
You can exit vim by hitting ESC
followed by :q!<enter>
.
Configuring zsh with a home-manager
DSL
You’re probably getting tired of typing out or copy-pasting darwin-rebuild switch --flake ~/.config/nix
all the time. Now, let’s add a shell alias! Our fingers will thank us.
We could create a zsh_configuration
file and use home.file.".zshrc"
to symlink it to
the right spot, but I want to demonstrate how to use some of the built-in configuration
that home-manager
provides. Add the following inside our homeconfig
block:
programs.zsh = {
enable = true;
shellAliases = {
switch = "darwin-rebuild switch --flake ~/.config/nix";
};
};
After running darwin-rebuild switch --flake ~/.config/nix
for the last time, you’ll be
able to just run switch
from now on.
When programs.zsh.enable
is true
, home-manager
will install zsh to your PATH and use
the different options under the attribute set to generate a .zshrc
for you. If you
already have a .zshrc
, Nix will ask you to move the file so it’s not overwritten.
Configuring git
Let’s add another stanza for configuring git with some common options while we’re here. Make sure to put in your name and email address:
programs.git = {
enable = true;
userName = "$FIRSTNAME $LASTNAME";
userEmail = "me@example.com";
ignores = [ ".DS_Store" ];
extraConfig = {
init.defaultBranch = "main";
push.autoSetupRemote = true;
};
};
Installing packages with home-manager
vs nix-darwin
When should you prefer to use home-manager
versus nix-darwin
? This is really a matter
of opinion, but it’s important to keep in mind that former is cross-platform while
latter is locked to macOS.
I generally default to using home-manager
for configuring anything that is not
macOS-specific. All your home-manager
config will continue to work if you ever decide to
bring your config to a NixOS system.
Conclusion
You can find the full configuration after this blog post here.
It’s a matter of preference when you use home-manager’s provided DSLs for configuring your apps and when you’d rather just write dotfiles and sync them to the right spot. Personally, I like the DSLs, even if they lock me more into using Nix. Feel free to explore the home-manager configuration options to see what’s possible!
In Part 3, we’ll use home-manager
to fully configure VSCode — declaratively managing
extensions, themes and user settings, all through Nix. See you then!