I usually hop across machines, a few Linux laptops and a Mac, so a while back I decided to begin simplifying a way of replicating my usual setup on them, and keep it all in sync. As most people know, this is essentially what a dotfiles repo is for. So I set about writing one up.
Consistent configs are fairly straightforward, just symlink files/directories to the appropriate location. What was actually difficult was keeping packages consistent across machines (and two operating systems).
Enter Nix.
Nix brands itself as a declarative package manager, something that was really attractive to me. List your packages and nix will make sure to install them all and what’s more, it promises to never leave your system in a broken state.
The frustrating thing about the Nix world though is that there seems to always be about N different ways to go about accomplishing the same thing, and it’s often not very easy to figure out the pros and cons of each approach.
In the end, it came down to one of two: home-manager
or flake.nix
with packages.
home-manager
is a way of managing your entire system. This is more than just packages, but application settings, service settings, hardware settings, etc. It’s really quite comprehensive. Think of it as a way of getting a lot of the NixOS features on non-NixOS systems.
The other option is to just have a flake.nix
with my packages and use it to keep a list of packages sync’d across systems.
In the end, I went with a simple flake.nix
for a few reasons:
home-manager
home-manager
can produce nigh-incomprehensible error messageshome-manager
doesn’t explicitly support rollbacks so if it messes up your home directory, it’s up to you to manually fix itHere’s the flake I use to manage most of my packages on Linux and Mac:
description = "declarative package setup";
inputs = {
nixpkgs.url = github:NixOS/nixpkgs/nixpkgs-unstable;
roc.url = github:roc-lang/roc;
};
outputs = { self, nixpkgs, roc, ... }:
let
pkgsFor = system: import nixpkgs {
inherit system;
};
pkgsLinux = pkgsFor "x86_64-linux";
pkgsDarwin = pkgsFor "x86_64-darwin";
essentials = sysPkgs: with sysPkgs; [
bash-completion
cmake
curl
coreutils-full
fd
fzf
gdb
git
gnumake
gnuplot
golangci-lint
go-toml
graphviz
grpc
imagemagick
libssh2
nasm
nerdfonts
openssl_3_3
podman
qemu
ripgrep
shellcheck
socat
tree
universal-ctags
wabt
wasmtime
wget
];
apps = sysPkgs: with sysPkgs; [
ansible
ansible-lint
asciidoctor
bun
devenv
doctl
emacs
ffmpeg
fish
gh
glab
helix
htop
http-server
httpie
jq
kitty
macchina
meld
minio-client
netcat
nmap
nmap-formatter
neovim
obsidian
ranger
terraform
thc-hydra
tmux
yarn
yq
];
langs = sysPkgs: with sysPkgs; [
elixir
go_1_23
lua
luarocks
maxima
nodejs
ocamlPackages.reason
poetry
python312
racket
ruby
rustup
sbcl
scala
solc
zig
];
commonPackages = sysPkgs: apps sysPkgs ++ langs sysPkgs ++ essentials sysPkgs;
linuxPkgs = with pkgsLinux; [
autotools-language-server
checksec
chromium
ghdl
ngspice
octaveFull
qucs-s
remmina
shfmt
signal-desktop
vagrant
] ++ commonPackages pkgsLinux;
darwinPkgs = with pkgsDarwin; [
alacritty
rectangle
skhd
texliveMedium
texstudio
] ++ commonPackages pkgsDarwin;
in
{
packages.x86_64-linux.default =
let
pkgs = pkgsLinux;
rocPkgs = roc.packages.x86_64-linux;
in
pkgs.buildEnv {
name = "user-linux-packages";
paths = linuxPkgs ++ [ rocPkgs.cli ];
};
packages.x86_64-darwin.default =
let
pkgs = pkgsDarwin;
rocPkgs = roc.packages.x86_64-darwin;
in
pkgs.buildEnv {
name = "user-darwin-packages";
paths = darwinPkgs ++ [ rocPkgs.cli ];
};
};
}
I install it by going to the directory with the flake and running:
nix profile install . --impure
And after that calling:
nix profile upgrade <name> --impure
to keep the list of installed packages in-sync with the flake.
To handle this case, but still declaratively, I use the excellent Brewfile package.
I have a bash alias:
darwin::brew-sync() {
brew bundle install --cleanup --file "$(realpath "$DOTFILES/darwin/Brewfile")"
}
So calling darwin::brew-sync
keeps my installed brew packages in-sync with the Brewfile.
brew 'bash'
brew 'djview4'
brew 'm-cli'
brew 'octave'
brew 'winetricks'
cask 'atom'
cask 'chromium'
cask 'discord'
cask 'gimp'
cask 'ghdl'
cask 'google-chrome'
cask 'julia'
cask 'kicad'
cask 'koodo-reader'
cask 'libreoffice'
cask 'macfuse'
cask 'signal'
cask 'spotify'
cask 'steam'
cask 'surfshark'
cask 'telegram'
cask 'thunderbird'
cask 'transmission'
cask 'vagrant'
cask 'whatsapp'
cask 'wine-stable'
cask 'vlc'
tap 'popcorn-official/popcorn-desktop', 'https://github.com/popcorn-official/popcorn-desktop.git'
cask 'popcorn-time'
tap 'cfergeau/crc'
brew 'vfkit'
I’ve been using this setup for nearly two years and I’m very happy with it! That said, I don’t think I’d ever make the move fully to NixOS or home-manager as I feel I’ve already hit the sweet spot of benefit-complexity with the flake.