As someone who deals with package management on the daily, whether it’s pnpm
or cargo
, I have grown tired of brew
rather lacklustre proposition. If you’re happy with brew
, by all means, stick with it! I’m not vibing with it anymore though.
Why I do consider brew so lacking
There are quite a few reasons for this:
- It’s slow. I don’t think ruby itself is the main reason, but the rather simplistic nature of brew’s architecture plays a part.
- You can only interact with the CLI. This is pretty bad, as there’s no file that declares your dependencies like a
package.json
or aCargo.toml
. This makes multi-machine management an exercise in frustration. - As a consequence of this, managing versions is… not exactly ideal either. Yes, things like
fnm
,virtualenv
, andrustup
are cool, but they only exist to fill a gap in most OS package managers. And that’s just programming languages, sometimes I want to avoid upgrading a binary, like postgres. - Another missing feature is the lack of lock files.
yarn
’s release was a transformative moment in the javascript ecosystem. It established how important these were, at a time wherenpm
did not provide them. - Auto upgrades can be harmful. This is due to the lack of versioning, but I’ve had breakage in my dev environment because adding a package also upgraded majors on other packages. This behaviour can be improved with
HOMEBREW_NO_INSTALL_UPGRADE
orHOMEBREW_NO_AUTO_UPDATE
, but they introduce other problems, and they aren’t the default. - It doesn’t handle configuration management. In my dotfiles, I still have to invoke
stow
. And even if I use a script,stow
sometimes generates its symbolic links too eagerly, bothersome when applications clutter their configuration directory. Yes, looking at youfish
😠 - It’s a mess on apple silicon. Once you’re past the initial humps, it’s okay-ish. OS upgrades will still be painful, and the intel experience remains simpler.
- It’s less nice than linux package managers, and I run/manage linux machines aside from my MBP, so I’d love a common tool, or something at least on par. My only BSD is a truenas server which I manage via a local web interface.
And yes, homebrew bundle exists, and fixes some of these issues. But that means it sits in an awkward space, where it fills some of the expectations, but falls short due to the parent tool not catering to that paradigm. And the fact that you can inadvertently work around it is not helping. It’s also mentioned once on brew.sh, which does not inspire confidence.
Getting an alternative
There aren’t a lot of package managers that can fill that void. Fink and MacPorts are pretty much macOS only, and linux package managers tend to be linux-only. They can run on macOS, but it’s a path full of pain, and it’s a lot of manual steps. Which is the opposite of what I’m looking for, automation is the name of the game here. The only viable solution I was able to find was nix. Nix is many things beyond a simple package manager, but I want to stay simple for now. The goal is to manage my applications, and my dotfiles. There might be something else I’m not aware of, in which case I’d love to hear about it!
Getting on with nix
Installing nix
You might be tempted to use the command that nixos.org recommends, but will be better off using another path: the Determinate Nix installer. The reasons are detailed on the Zero to Nix website. It offers a cleaner install/uninstall experience, essential when trying something out. To use it, type the following command.
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
That should bring nix to your machine, using the optimal setup for macOS, and that's it. You now have a working nix installation on your Mac. To check that things are in working order, you can type the following command:
nix search nixpkgs neovim
Now that we’re done with the installation, this is where a lot of tutorials would tell you to install nix-darwin. Don’t bother: it’s a lot of additional complexity, in an intrusive package. It allows you to tweak things at the OS level, but you pay too big a price. What’s worth it though, is home-manager
. Using a flake, it’ll allow us to forget (or rather never learn) about a lot of nix commands, and get a pre-packaged shell with all the stuff you declared.
Installing home-manager
Home-manager is what is going to be handling my dotfiles. It also enabling me to let a flake manage everything rather than editing nix’s configuration. Here’s the minimal configuration I went with, separated in two files. Replace <username>
with whatever yours is to get going (echo $USER
will tell you what you username is if you aren’t sure):
flake.nix
{
description = "Emilia's dotfiles";
# inputs are other flakes you use within your own flake, dependencies
# if you will
inputs = {
# unstable has the 'freshest' packages you will find, even the AUR
# doesn't do as good as this, and it's all precompiled.
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
};
# In this context, outputs are mostly about getting home-manager what it
# needs since it will be the one using the flake
outputs = { nixpkgs, home-manager, ... }: {
homeConfigurations = {
"<username>" = home-manager.lib.homeManagerConfiguration {
# darwin is the macOS kernel and aarch64 means ARM, i.e. apple silicon
pkgs = nixpkgs.legacyPackages.aarch64-darwin;
modules = [ ./home.nix ];
};
};
};
}
home.nix
{ ... }: {
# This is required information for home-manager to do its job
home = {
stateVersion = "23.11";
username = "<username>";
homeDirectory = "/Users/<username>";
packages = [ ];
};
programs.home-manager.enable = true;
# I use fish, but bash and zsh work just as well here. This will setup
# the shell to use home-manager properly on startup, neat!
programs.fish.enable = true;
}
Once these files exist in the directory of your choice, then you can run:
# if your directory is part of a git repo, flake will only use files that
# are properly tracked. This caught me off-guard multiple times!
git add .
# This will run home-manager after downloading it, the same way npx would
# in JS land.
nix run github:nix-community/home-manager -- switch --flake .
This will install everything necessary by reading your flake.nix
, create a flake.lock
to ensure reproducibility, and do all the necessary setup. Restart with a new shell to get all the necessary goodies. To make sure it all worked: home-manager
should now be in your PATH
, and can be invoked on the command line.
Adding your packages
Before installing a package, we need to find it. Finding it can be done one of two ways:
- On the nixOS website: NixOS Search
- On the command-line:
nix search nixpkgs <package>
Once you’ve determined what the package is, get its name, and then we can go add stuff to our home.nix
.
# We add pkgs since it's available as an argument, thanks to our inputs
{ pkgs, ... }: {
home = {
stateVersion = "23.11";
username = "emiliazapata";
homeDirectory = "/Users/emiliazapata";
# Then we add the packages we want in the array using pkgs.<name>
packages = [
pkgs.git
pkgs.neovim
];
};
# This is to ensure programs are using ~/.config rather than
# /Users/<username/Library/whatever
xdg.enable = true;
programs.home-manager.enable = true;
programs.fish.enable = true;
}
Once you’re done, you can run this command to install these packages:
home-manager switch --flake .
Adding your configuration
To manage your dotfiles, it’s going to be in the home.nix
file again. There are two ways to go about it:
- Leverage
home-manager
and the nix language to the max, by putting the entire configuration in nix files. And figure out what needs to go where with its… questionable documentation - Tell it to put specific files in specific places.
We’re gonna go with the second option, as it’s a lot less work, and doesn’t tie you to the nix ecosystem.
{ pkgs, ... }: {
home = {
stateVersion = "23.11";
username = "emiliazapata";
homeDirectory = "/Users/emiliazapata";
packages = [
pkgs.git
pkgs.neovim
];
# Tell it to map everything in the `config` directory in this
# repository to the `.config` in my home directory
file.".config" = { source = ./config; recursive = true; };
};
xdg.enable = true;
programs.home-manager.enable = true;
programs.fish.enable = true;
}
And now, don’t forget to git add
all the necessary files. Home manager will also not override files it hasn’t managed, and will ignore that silently. You will need to remove the files in your .config
, in order to get home-manager to manage them. Once you’ve checked everything, same command as last time:
home-manager switch --flake .
And that’s it! You now have a nix-managed dotfiles repository, with declared packages. To ensure it all went without a hitch, you can ls -a
in one of the configuration directory you handled. It should show the symbolic links pointing to somewhere in /nix/store/
like so:
lrwxr-xr-x@ 1 username staff 84B 17 Sep 11:38 init.lua@ -> /nix/store/7svm3r3xpwhgnfsp2g0wmpycai1fvxan-home-manager-files/.config/nvim/init.lua
It’s a pretty lengthy article, but I wanted to be thorough and explain the why which tends to be hand-waved away, especially in the nix ecosystem! You can find my dotfiles and the recent changes I made in my dotfiles repo if you’re wondering what it ended up looking like in my case.
One last note: The nix discord community is pretty okay, and they helped me figure a lot of that stuff out. If you feel adventurous enough to try nix out, you might want to come hang there.
Top comments (2)
Amazing! Cant wait to set this up. ty!
Thanks for this guide. I'm trying it on a fresh macOS 14.2.1 Sonoma install, Apple M3 Max.
During Installing home-manager I ran into an error after executing
nix run github:nix-community/home-manager -- switch --flake .
I get this:
error: cached failure of attribute 'packages.aarch64-darwin'
I'm new to nix and searching for this error did not result in any pointers that I found helpful.
If you know how to fix this, let me know please.
Also, the fresh macOS install did not yet have git in order to run git add .
MacOS prompted me to install Xcode command line tools which I did.
Then a further step seemed to be needed, running git init before I could run
git add .