This repo is a Nix flake that defines Home Manager configurations for Linux and macOS, plus a nix-darwin configuration for macOS system-level setup.
- Nix installed
- Recommended (macOS + Linux): the official multi-user Nix installer. This
flake lets nix-darwin manage
nix(channels, registry, GC), which assumes the official daemon-based install. - Determinate Systems / Lix: also fine, but Determinate manages
nix.confand the daemon itself. If you use it, setnix.enable = falsein the nix-darwin config so the two do not fight over/etc/nix/nix.conf.
- Recommended (macOS + Linux): the official multi-user Nix installer. This
flake lets nix-darwin manage
- Build tools
- macOS: install Xcode Command Line Tools:
xcode-select --install - Linux: install your distro's build essentials (e.g.
build-essential,gcc,make, etc.)
- macOS: install Xcode Command Line Tools:
Official multi-user installer:
sh <(curl -L https://nixos.org/nix/install) --daemonThen restart your terminal (or source the profile snippet the installer prints).
You need nix-command and flakes enabled. The simplest approach is to add this line to your Nix config:
experimental-features = nix-command flakesCommon locations:
- Multi-user Nix (typical on macOS + many Linux installs):
/etc/nix/nix.conf - Single-user Nix:
~/.config/nix/nix.conf
After editing /etc/nix/nix.conf, restart the Nix daemon:
- macOS:
sudo launchctl kickstart -k system/org.nixos.nix-daemon
- Linux (systemd):
sudo systemctl restart nix-daemon || true
On macOS, nix-darwin also declares these settings once activated, but the initial bootstrap still needs flakes enabled before the first switch.
git clone <this-repo-url> ~/dotfiles
cd ~/dotfilesTargets are defined in lib/hosts.nix (the hosts table) and exposed by flake.nix under homeConfigurations:
mbp(also aliased asmbp-home)linuxlinux-ailinux-privatelinux-minimallinux-aws(uses local useradmin)linux-openclaw(uses local useropenclaw)linux-armlinux-minimal-arm
There is also one NixOS host, utm-vm (nixosConfigurations.utm-vm, an
aarch64-linux UTM virtual machine), switched with just switch-utm-vm.
Each target always includes the base profile. The Linux or macOS profile is selected from the target system in lib/hosts.nix, and the ai / private suffixes add those extra profile layers.
If you're not sure, start with:
- MacBook Pro, nix-darwin:
darwinConfigurations.mbp - MacBook Pro, standalone Home Manager only:
mbp-home - x86_64 Linux:
linux - aarch64 Linux:
linux-arm
The main macOS path is darwinConfigurations.mbp. It wraps the same Home Manager modules and adds system-level pieces such as shell registration, sudo Touch ID support, macOS settings, native Nix packages, and Homebrew/Mac App Store inventory.
Build it without switching first:
nix build .#darwinConfigurations.mbp.systemThen switch when ready:
darwin-rebuild switch --flake .#mbpdarwin-rebuild switch also activates the integrated Home Manager configuration for taglia.
Homebrew and Mac App Store apps are declared in modules/darwin/homebrew.nix. Current activation behavior is intentionally declarative:
- declared brews, casks, and MAS apps are installed if missing
nix-homebrewpins the Homebrew client and the third-party Rootshell tap inflake.lock; taps are immutable and trust is limited to the Rootshell cask- official formulae and casks continue to use Homebrew's signed JSON API rather than Nix-pinned
homebrew/core/homebrew/casktaps - Homebrew metadata is not auto-updated and formulae/casks are not upgraded during activation or ordinary manual commands; run
just update-brewexplicitly instead - undeclared Homebrew and MAS apps can be removed, including related support files where Homebrew supports zapping, because
cleanup = "zap"is enabled
Mac App Store apps require the Mac to be signed into an Apple ID that owns those apps. Keep homebrew.masApps complete when cleanup is enabled.
Routine update steps are in UPDATE-RUNBOOK.md. Normal cask upgrades intentionally omit --greedy and --force; use the selective monthly recipe for a self-updating or unversioned cask that still needs Homebrew to update it.
Native Nix packages, fonts, and terminfo installed into the nix-darwin system profile live in modules/darwin/packages.nix. macOS system preferences are split by concern: Dock/Finder/Spaces in modules/darwin/desktop.nix, keyboard/trackpad/text input in modules/darwin/input.nix, and the remaining system.defaults.* (power, login window, control center, per-app defaults, environment) in modules/darwin/system.nix. AeroSpace and its native-tiling-disabling companion settings live in modules/darwin/aerospace.nix.
Docker Desktop is intentionally not managed. Container development on macOS uses Colima and Lima from Home Manager, with the Docker CLI and Docker Compose from nixpkgs. The VM backend package qemu is installed through nix-darwin because it is a host-level runtime dependency. Start the runtime manually when needed:
colima start
docker context ls
docker versionYou can run Home Manager without installing it globally:
nix run github:nix-community/home-manager/release-26.05 -- switch --flake .#mbp-homeReplace mbp-home with the target you chose, e.g.:
nix run github:nix-community/home-manager/release-26.05 -- switch --flake .#linuxnix-darwin registers bash, zsh, and fish as valid shells and sets the primary user's login shell to the nix-darwin Fish path. Standalone Home Manager systems do not change the login shell automatically. If you are not using nix-darwin and want to make Fish your login shell:
- Ensure Fish is installed (it should be after
home-manager switch):
command -v fish- Add that path to
/etc/shellsif it is not already present (required bychsh):
fish_path="$(command -v fish)"
grep -qxF "$fish_path" /etc/shells || echo "$fish_path" | sudo tee -a /etc/shells- Change your login shell:
chsh -s "$(command -v fish)"Log out and back in (or restart your terminal) to fully apply.
Some application config is managed by Home Manager from files in this repo:
- Ghostty:
files/ghostty/config - pi:
files/pi/agent/...→~/.pi/agent/...viamodules/home/pi.nix
AeroSpace is managed directly by nix-darwin through services.aerospace.
lib/catppuccin.nix is the single source of truth for the Catppuccin Mocha palette. The themed configs that used to duplicate it are now generated from it: files/starship.toml (palette table) and the kitty theme via modules/home/xdg-files.nix, the fish colors via modules/home/fish.nix, the pi theme JSON via modules/home/pi.nix, and colors.lua (injected at build time) via modules/home/sketchybar.nix. tmux and Ghostty use the upstream Catppuccin plugin / built-in theme and are intentionally not wired in. Change a color once in lib/catppuccin.nix to update every consumer.
- Stable/global pi config is managed in Home Manager under
files/pi/agent/.... - New extension development happens project-locally in
.pi/extensions/so you can edit and test with/reloadwithout runninghome-manager switchordarwin-rebuild switch. - When an extension is ready, promote it by moving it into
files/pi/agent/extensions/and rebuilding once. - Keep pi runtime state unmanaged:
~/.pi/agent/auth.json,sessions/,npm/, and similar mutable directories stay outside Home Manager.
Home Manager installs a ten-day minimum release age for package managers that support a rolling cutoff:
| Manager | Managed configuration | Policy value |
|---|---|---|
| npm | ~/.npmrc |
min-release-age=10 |
| pnpm | ~/.config/pnpm/config.yaml |
minimumReleaseAge: 14400 minutes, strict and fail-closed on missing timestamps |
| Yarn 4.12+ | ~/.yarnrc.yml |
npmMinimalAgeGate: "10d" |
| Bun | ~/.config/.bunfig.toml |
minimumReleaseAge = 864000 seconds |
| uv | ~/.config/uv/uv.toml |
exclude-newer = "10 days" |
| mise | ~/.config/mise/config.toml |
minimum_release_age = "10d" |
Pi uses the pi-npm wrapper, which also supplies npm's ten-day policy on the
command line. This gives Pi's npm packages the policy even when a project has
an .npmrc that would otherwise override the user configuration. The
minimal-web extension remains lockfile-based and installs with
npm ci --ignore-scripts.
The policy applies when a package manager resolves a registry version. It does not establish that an older package is trustworthy, replace lockfiles or hash verification, or consistently cover Git dependencies, direct URLs and versions whose source does not publish a usable timestamp. Existing installed versions are not automatically changed.
For an urgent security fix, make the narrowest temporary exception supported
by that manager and remove it immediately afterwards. Examples include npm's
--min-release-age-exclude=<package>, Yarn's one-shot --no-time-gate, uv's
--exclude-newer-package <package>=false, or an exact mise version (exact
versions intentionally bypass mise's fuzzy-version cutoff). Avoid disabling
the global policy for every package.
Nix is handled differently: flake.lock remains the version and integrity
boundary, and temporary check tools use nix shell --inputs-from . so they
resolve from that same lock. There is no additional ten-day Nix cutoff.
Homebrew and Mac App Store updates also remain explicit and manual because
neither exposes a supported, reliable per-release age gate. In particular,
the Homebrew client and Rootshell tap are flake-pinned, while official package
metadata still comes from Homebrew's signed API. Casks with their own updater
can still update outside Homebrew.
The first switch upgrades the Home Manager-provided mise binary, but mise does not automatically replace tools already installed in its mutable data directory. On an existing machine, run this once after switching so the currently old Node/npm and uv clients gain native cooldown support while the new mise policy is active:
mise upgrade node uvThis repo uses agenix for encrypted secrets. Two files are the source of
truth: secrets-machines.nix declares the machines (recipient public keys +
private key locations), and secrets.nix declares the secrets - each entry
maps an encrypted payload under secrets/ to the machines allowed to decrypt
it (publicKeys) and, optionally, an envVarFile environment variable for
the decrypted file path. Commit only encrypted .age files, secrets.nix and
secrets-machines.nix, never private SSH keys or plaintext secrets. (When
bootstrapping a fresh fork with no secrets yet, secrets.nix can simply be
{ } until the first secret is needed.)
This repo uses SSH keys as agenix identities. Prefer a machine-specific SSH key per machine; if available, use an ed25519 key over RSA.
Check which public keys exist:
find ~/.ssh -maxdepth 1 -name '*.pub' -print | sortInspect the public key type before choosing one:
awk '{print $1, FILENAME}' ~/.ssh/*.pubMachines are declared once in secrets-machines.nix:
mbp = {
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...";
identity = "/Users/taglia/.ssh/id_ed25519";
};publicKey is the age recipient referenced from secrets.nix; identity is the absolute path of the matching private key on that machine. The identity may live anywhere on the filesystem (including outside the user's home, e.g. a machine-level key), as long as it is readable by the user running Home Manager activation. Use machine-specific names such as mbp, linux_workstation, or server_name; avoid a generic personal name for machine recipients.
At activation, profiles/private.nix passes every declared identity to agenix, which silently skips the ones not present on the local machine - so each machine automatically decrypts with its own key, with no key paths hardcoded per profile. A key that exists on a machine but is not a recipient for a given secret is ignored without error (and never triggers a passphrase prompt, because age matches the recipient's public key before touching the private key). This activation-time behavior is distinct from the agenix CLI below, where you always pass an identity explicitly with -i.
-
Add one entry to
secrets.nix:"secrets/example-api-token.age" = { publicKeys = [ mbp dev-vm ]; envVarFile = "EXAMPLE_API_TOKEN_FILE"; };
publicKeysis where machine authorization lives: only a machine holding a matching private key can decrypt the payload.envVarFileis optional; see "Consuming a secret" below. -
Encrypt the payload from the repo root using the matching private key:
mkdir -p secrets agenix -e secrets/example-api-token.age -i ~/.ssh/id_ed25519The
-iflag is a CLI-only detail: it tellsagenixwhich private key to use for this manual encryption/decryption and accepts any path. It has no effect on which identities are used later at activation (those come fromsecrets-machines.nix, see "Adding a machine"). For an API token the editor buffer contains the raw token (or an env-file styleEXAMPLE_API_TOKEN=...line if a service expects an env file); for a binary file, pipe it in instead of opening an editor:agenix -e secrets/example.age < ./example.bin -
Rebuild (
darwin-rebuild switch --flake .#mbporjust switch-home mbp-home).
No other edits are needed: profiles/private.nix imports secrets.nix and
derives age.secrets and home.sessionVariables automatically, wiring only
secrets whose .age payload exists in the checkout. age secret names are
derived from the file name, e.g. secrets/example-api-token.age becomes
config.age.secrets.example_api_token.
The important rule is to pass the decrypted file path around, never the secret value. Do not use builtins.readFile on a decrypted secret or put the value directly in home.sessionVariables, because that would copy the secret into the Nix store or generated config files.
-
With
envVarFileset, activation creates a stable per-user symlink at~/.local/share/agenix/<secret-name>and exports that path (viahome.sessionVariables, so it reaches bash, zsh and fish alike). Scripts read the token at runtime:token="$(cat "$EXAMPLE_API_TOKEN_FILE")" curl -H "Authorization: Bearer $token" https://api.example.com/me
-
Without
envVarFile, the secret is just decrypted to a file. Referenceconfig.age.secrets.<name>.pathfrom any Home Manager module, e.g. ahome.filesymlink where an app expects a fixed location, or a user service that expects environment variables (store the secret as an env file,EXAMPLE_API_TOKEN=..., and setEnvironmentFile = config.age.secrets.example_api_env.path;).
After activation, agenix keeps the decrypted backing files in an OS-specific
runtime directory, typically under $XDG_RUNTIME_DIR/agenix.d/ on Linux.
Secrets with envVarFile use the stable symlinks above, so consumers do not
depend on XDG_RUNTIME_DIR being present in their shell. Do not point consumers
at agenix.d; it is the private generation directory, while
config.age.secrets.<name>.path is the stable consumer path.
If recipient keys change, re-encrypt existing secrets (the agenix CLI reads the recipient lists from secrets.nix):
agenix -r -i ~/.ssh/id_ed25519Trade-off to keep in mind: this repo standardizes on SSH keys as age identities (see above) because every machine already has one, but it does mean SSH access and secret decryption share the same credential lifecycle. Keep the keys machine-specific and not broadly reused; rotate the corresponding recipient in secrets.nix whenever a machine's key changes.
Common tasks are exposed through just:
just
just switch-darwin
just switch-home linux
just check
just check-brew-declared
just check-brew-updates
just check-brew-greedy
just gc --dry-runjust switch-home requires a target argument, e.g. just switch-home mbp-home or just switch-home linux. Update domains stay separate so flake.lock can be reviewed and checked before it changes the active Homebrew client or third-party tap. Use just update-nix for all flake inputs, just update-unstable for only nixpkgs-unstable, and just update-brew for routine Homebrew and Mac App Store updates. Follow UPDATE-RUNBOOK.md for the complete sequence.
flake.lock updates are intentionally manual (there is no automated dependency-update CI): run just update-nix (or just update-unstable for the unstable input) and review the diff before switching.
The underlying scripts can be run from anywhere, but expect to live inside this repo (flake.nix next to scripts/):
scripts/bootstrap_and_switch.sh: standalone Home Manager bootstrap; write the local identity to a git-ignoredidentity.nix(read byflake.nix'sdefaultUser) and mark it withgit add -N -fso the flake can see it, enable flakes if needed, and runhome-manager switch- This is not the primary macOS nix-darwin path. Use
darwin-rebuild switch --flake .#mbpfor nix-darwin. - On an interactive terminal, it asks whether to pass Home Manager's backup option for conflicting files.
- Use
--backupfor a timestamped backup extension,--backup backupfor.backup, or--no-backupto skip the prompt.
- This is not the primary macOS nix-darwin path. Use
scripts/set-default-shell.sh: add Fish to/etc/shellsandchshto it; useful for standalone Home Manager systems, not normally needed with nix-darwinjust update-unstable: update only thenixpkgs-unstableinputscripts/gc.sh: garbage collect old Nix generations and unreachable store paths; on macOS, also clean Homebrew orphan dependencies, stale downloads, and cached downloads- By default it runs
nix-collect-garbage --delete-older-than 7d, which keeps about one week of rollback history. - On NixOS and nix-darwin, it also runs the same Nix garbage collection through
sudowhen it detects a system profile. Use--no-sudoto limit cleanup to the current user, or--sudoto force root/system profile cleanup. - On macOS, it runs
brew autoremove,brew cleanup, andbrew cleanup --scrub. It does not runbrew bundle cleanup; nix-darwin already removes undeclared Homebrew packages during activation becausehomebrew.onActivation.cleanup = "zap"is enabled. - Use
--dry-runbefore the first real cleanup to inspect what supported tools would remove.
- By default it runs
scripts/package.sh: create a tarball underpackages/; excludes build outputs and logs
Examples:
./scripts/bootstrap_and_switch.sh
./scripts/bootstrap_and_switch.sh --target mbp-home
./scripts/bootstrap_and_switch.sh --target linux --backup
./scripts/set-default-shell.sh
./scripts/gc.sh --dry-run
./scripts/gc.sh
./scripts/gc.sh --older-than 14d --no-brewThe Home Manager and nix-darwin configurations register short Nix registry aliases:
npoints to this flake's stablenixpkgsinputupoints to this flake's unstablenixpkgs-unstableinput
Examples:
nix shell n#jq
nix run u#some-packagenix-index answers which package provides a command or file. comma uses that index for one-shot command lookup and execution. For example, if hello is not installed:
, helloThe index database is provided by nix-index-database in the Home Manager generation.
The flake exposes a formatter for supported systems:
nix fmtnix fmt -- --check exits non-zero if any file is unformatted. The formatter, deadnix, statix, shellcheck, StyLua and Prettier are all run by just check (and CI).
It also exposes Home Manager activation-package checks, grouped by system:
nix flake check