Nix as a Simpler Alternative to Homebrew
Moving from Linux to macOS means dealing with a less mature package management ecosystem. Homebrew was spending too much time upgrading packages for comfort, so I decided to try the nix package manager. Despite its reputation for complexity—complete with its own programming language—I found a simple workflow that avoids configuration files and learning Nix's language. Here's how to get useful work done with nix without adopting its more advanced features.
What Makes Nix Useful
Nix is often described as "declarative package management," but for practical purposes, two features stand out:
- Binary packages hosted at
https://cache.nixos.org/install quickly - Crafting packages for software that lacks binaries is surprisingly straightforward
The compilation story is strong because nix resolves three common problems:
- Multiple versions of libraries and programs can coexist simultaneously. I currently have two node.js installations:
/nix/store/4ykq0lpvmskdlhrvz1j3kwslgc6c7pnv-nodejs-16.17.1and/nix/store/5y4bd2r99zhdbir95w5pf51bwfg37bwa-nodejs-18.9.1 - Builds run in isolation, using only the explicitly declared dependencies. This eliminates the guesswork around
LD_LIBRARY_PATHconflicts - The nixpkgs repository documents dependency trees meticulously
Getting Started Without the Complexity
A minimal nix setup requires just a few steps:
- Install nix—either the official installer or the zero-to-nix.com installer. Since uninstalling the standard multi-user install on macOS is complicated, choosing an installation method with simpler removal instructions is worth considering
- Add
~/.nix-profile/binto yourPATH - Install packages with
nix-env -iA nixpkgs.NAME
This treats nix-env -iA as a drop-in replacement for brew install or apt-get install. For example, installing fish:
nix-env -iA nixpkgs.fish
This simply downloads binaries from the cache. I haven't adopted many tools often associated with nix—notably NixOS, nix-shell, flakes, home-manager, or devenv.sh. They're optional; most tutorials present them as mandatory, which creates the false impression that nix requires steep upfront investment.
Package Discovery
Packages live in the nixpkgs repository, searchable at search.nixos.org/packages. Command-line search options have shortcomings: nix-env -qaP NAME is painfully slow, and nix --extra-experimental-features 'nix-command flakes' search nixpkgs NAME outputs packages prefixed with legacyPackages. I generated a local index instead:
- Run
nix-env -qa '*' > nix-packages.txtonce - Use a small
nix-searchscript that greps the list:cat ~/bin/nix-packages.txt | awk '{print $1}' | rg "$1"
How Installations Work: Symlinks and Profiles
Instead of one global bin directory, nix layers symlinks. On my machine:
~/.nix-profileeventually points to/nix/var/nix/profiles/per-user/bork/profile-111-link/~/.nix-profile/bin/fishlinks to/nix/store/afkwn6k8p8g97jiqgx9nd26503s35mgi-fish-3.5.1/bin/fish
Each installation creates a new profile link directory and updates ~/.nix-profile. This design permits easy rollback via nix-env --rollback, which switches back to a previous profile.
Uninstalling works by removing symlinks rather than deleting the package from the nix store. Disk space isn't freed:
$ nix-env --uninstall oil
Garbage collection partially addresses this, but doesn't always remove everything:
$ nix-collect-garbage
...
85 store paths deleted, 74.90 MiB freed
After running it, oil still exists at /nix/store/8pjnk6jr54z77jiq5g2dbx8887dnxbda-oil-0.14.0. The more aggressive nix-collect-garbage variant deletes old profile versions, disabling rollback:
$ nix-collect-garbage -d --delete-old
Even that leaves the store entry untouched.
Upgrading Packages
The apt-style upgrade flow works:
nix-channel --update
nix-env --upgrade
Though comparisons with apt-get update && apt-get upgrade are apt, one developer's experience suggests nix-env --upgrade may behave in unexpected ways. Since everything is immutable, nix-env --rollback remains available if an upgrade fails.
Building a Custom Package
The real test for nix came when I needed paperjam, a program unavailable in nixpkgs. Compiling it without nix had been problematic because of an installed libiconv version mismatch. Nix made the build work, but learning how was confusing. A simpler test case—building the existing dash package—helped map the workflow:
Step 1: Fetch the package definition from nixpkgs:
wget https://raw.githubusercontent.com/NixOS/nixpkgs/47993510dcb7713a29591517cb6ce682cc40f0ca/pkgs/shells/dash/default.nix -O dash.nix
Step 2: Replace the package's first statement (in dash's case, { lib , stdenv , buildPackages , autoreconfHook , pkg-config , fetchurl , fetchpatch , libedit , runCommand , dash }:) with with import <nixpkgs> {};. This works, though the reason isn't obvious.
Step 3: Run nix-build dash.nix to compile it.
Step 4: Run nix-env -i -f dash.nix to install into ~/.nix-profile.
With that foundation, creating a new package becomes copy-paste work. paperjam depends on libpaper, which also lacked a package. My libpaper.nix file followed patterns copied from nixpkgs:
with import <nixpkgs> {};
stdenv.mkDerivation rec {
pname = "libpaper";
version = "0.1";
src = fetchFromGitHub {
owner = "naota";
repo = "libpaper";
rev = "51ca11ec543f2828672d15e4e77b92619b497ccd";
hash = "sha256-S1pzVQ/ceNsx0vGmzdDWw2TjPVLiRgzR4edFblWsekY=";
};
buildInputs = [ ];
meta = with lib; {
homepage = "https://github.com/naota/libpaper";
description = "libpaper";
platforms = platforms.unix;
license = with licenses; [ bsd3 gpl2 ];
};
}
This approximates "download source, run make install," and it works for C projects. Missing hashes surface as error messages; copy them from the build output. For paperjam itself, I added dependencies like asciidoc and set installFlags = [ "PREFIX=$(out)" ] to override the default /usr/local/bin prefix. The PREFIX approach came from searching rg PREFIX in nixpkgs—another project almost certainly needed the same fix.
Running after that:
nix-build paperjam.nix
nix-env -i -f paperjam.nix
The package installed and worked, solving the libiconv compilation issue with far less effort.
Installing an Old Hugo Version
My blog runs on Hugo 0.40 from 2018. Though Linux made this easy—Hugo ships static binaries—macOS hardware changes broke the old binary, and go build failed because Go's build norms shifted dramatically since then. Running Hugo in a Linux Docker container was slow and felt inelegant.
Nix solved it:
Step 1: Locate Hugo 0.40's nix file in nixpkgs at pkgs/applications/misc/hugo/default.nix around commit 17b2ef2.
Step 2: Download default.nix and its companion deps.nix, replace the header with with import <nixpkgs> {};, and build. Two tweaks were needed:
with stdenv.libbecamewith lib- The package was renamed
hugo040to avoid conflicting with the other installedhugo
Step 3: Add a post-install step to rename the binary:
postInstall = ''
mv $out/bin/hugo $out/bin/hugo-0.40
'';
Typical nixpkgs patterns—found via rg 'mv ' and adapted—provided the syntax.
Step 4: Install with nix-env -i -f hugo.nix.
Hugo 0.40 now works on this Mac as hugo-0.40, with the nix file saved in a personal repository for reuse.
What the Nix Workflow Gets Right
This approach works because people invested substantial effort in packaging Hugo 0.40 reproducibly years ago. Nix's power isn't magic—it's accumulated dependency documentation.
The nix learning curve discourages casual adoption, but starting small proved valuable. I never planned to embrace the full feature set, and I haven't needed to yet. The minimal workflow—install packages, occasionally build custom ones, skip configuration and language learning—turned out to be far easier than wrestling with compilation on macOS, even if the project source I needed was challenging to build by other means.
Six Months Later: Maintainability Concerns
Post-script: this approach has held up, with one exception. Every nix-env -iA installation eventually failed with the error "bad meta.outputsToInstall." A script from Ross Light solves the problem by listing every derivation in the current profile and rebuilding an identical one. Building the same code should logically be a no-op; this feels like a nix bug, but I haven't investigated further.



