Declarative macOS configuration using nix-darwin, home-manager, and sops-nix. Supports Apple Silicon.
Everything — shell, tools, editor, fonts, apps, system settings — is managed from this repo. A single command rebuilds the entire system.
For a Mac already registered in this repo (your hostname already exists in flake.nix). You'll need your age key from your password manager backup (see Secrets) — it's a small text file.
1. Install Xcode tools and Nix:
xcode-select --install
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- installOpen a new terminal window after this finishes.
2. Restore your age key (from the Passwords app, 1Password, etc.):
mkdir -p ~/.config/sops/age
nano ~/.config/sops/age/keys.txt # paste the key, save (Ctrl+O, Enter, Ctrl+X)
chmod 600 ~/.config/sops/age/keys.txt3. Clone this repo:
git clone https://github.com/utopiaeh/nix-config.git ~/nix-config
cd ~/nix-config4. Build your Mac:
nix --extra-experimental-features 'nix-command flakes' build ".#darwinConfigurations.$(scutil --get LocalHostName).system"
./result/sw/bin/darwin-rebuild switch --flake ".#$(scutil --get LocalHostName)"This takes a while the first time — it's installing everything.
5. Add your SSH key to GitHub:
cat ~/.ssh/id_ed25519.pubCopy the output, then go to github.com → Settings → SSH and GPG keys → New SSH key, and paste it.
Done. From now on, run nix run .#rebuild any time you want to pull in updates.
Something went wrong? See Existing machine below for the detailed version, or Recovery if secrets aren't working.
| Layer | Tool | What it manages |
|---|---|---|
| System | nix-darwin |
macOS settings, fonts, Homebrew, system packages |
| User | home-manager |
Shell, dev tools, git, SSH, editor config |
| Secrets | sops-nix |
SSH keys, API tokens — encrypted at rest |
| Apps | homebrew |
GUI apps (casks) and Mac App Store apps |
Run rebuild → Nix computes what changed → applies atomically. If something breaks, roll back.
Secrets are encrypted with age. A standalone age key lives at ~/.config/sops/age/keys.txt — this is the master decryption key. On every build, sops-nix uses it to decrypt secrets.enc.yaml and place secrets at their configured paths (e.g. ~/.ssh/id_ed25519).
Back up
keys.txtimmediately after generating it — save it to the macOS Passwords app, 1Password, or similar. This file is not managed by Nix and is never committed to git. Losing it means losing access to all your secrets.
nix-config/
├── flake.nix # Entry point — defines all machines
├── flake.lock # Pinned dependency versions
├── .sops.yaml # Which age keys can decrypt which secrets
├── .github/workflows/ # CI — flake check, build, weekly input updates
├── hosts/
│ ├── common/
│ │ ├── darwin/default.nix # Shared macOS settings, fonts, activation
│ │ ├── darwin/homebrew.nix # Homebrew casks, taps, Mac App Store apps
│ │ └── common-packages.nix # System-wide CLI tools
│ └── darwin/
│ └── <machine>/ # Machine-specific config
├── home-manager/
│ ├── profiles/
│ │ ├── base.nix # Shell, git, SSH, aliases, env vars
│ │ └── <machine>.nix # Machine-specific home config
│ └── programs/ # rust, node, git, nix LSP, iterm2...
├── assets/ # starship theme, wallpapers...
├── secrets/
│ ├── <machine>/secrets.enc.yaml # Encrypted machine secrets
│ └── secrets_example.yaml # Template
└── templates/ # Flake templates (node, esp32)
Which path?
- New machine — not yet in
flake.nix- Existing machine — already in
flake.nix, restoring or reinstalling
xcode-select --install
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- installOpen a new terminal after Nix installs.
This key will be stored as an encrypted secret and managed by Nix after the first build.
ssh-keygen -t ed25519 -C "you@email.com" -f ~/.ssh/id_ed25519 -N ""
cat ~/.ssh/id_ed25519.pub # add this to github.com → Settings → SSH and GPG keysgit clone git@github.com:utopiaeh/nix-config.git ~/nix-config
cd ~/nix-confignix developThis gives you sops, age, and related tools before the first build.
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txt
age-keygen -y ~/.config/sops/age/keys.txt # note this public key — needed in next stepBack this up now. Save
~/.config/sops/age/keys.txtto the macOS Passwords app or 1Password before continuing.
sudo scutil --set HostName <your-machine>
sudo scutil --set LocalHostName <your-machine>flake.nix — add under darwinConfigurations:
<your-machine> = libx.mkDarwin { hostname = "<your-machine>"; };.sops.yaml — add the age public key from step 5:
- path_regex: ^secrets/<your-machine>/.*\.yaml$
key_groups:
- age:
- age1abc123... # public key from step 5hosts/darwin/<your-machine>/default.nix:
{ config, username, pkgs, lib, ... }:
{
sops = {
age.keyFile = "/Users/${username}/.config/sops/age/keys.txt";
defaultSopsFile = ../../../secrets/${config.networking.hostName}/secrets.enc.yaml;
secrets."ssh_key" = {
path = "/Users/${username}/.ssh/id_ed25519";
owner = username;
mode = "0600";
};
};
}Do not add
age.sshKeyPathspointing at~/.ssh/id_ed25519. That creates a circular dependency — sops needs the SSH key to decrypt secrets, but the SSH key is itself a secret. Useage.keyFileonly.
home-manager/profiles/<your-machine>.nix:
{ ... }:
{
imports = [ ./base.nix ];
}mkdir -p secrets/<your-machine>
cp secrets/secrets_example.yaml /tmp/secrets.yamlEdit /tmp/secrets.yaml — paste the contents of ~/.ssh/id_ed25519 as the ssh_key value.
nix shell nixpkgs#sops --command sops --encrypt /tmp/secrets.yaml > secrets/<your-machine>/secrets.enc.yaml
rm /tmp/secrets.yaml # never commit the unencrypted fileAfter the first build, use
nix run .#edit-secrets -- <your-machine>to edit secrets instead.
darwin-rebuild doesn't exist yet — use this bootstrap:
nix --extra-experimental-features 'nix-command flakes' build ".#darwinConfigurations.$(scutil --get LocalHostName).system"
./result/sw/bin/darwin-rebuild switch --flake ".#$(scutil --get LocalHostName)"After this, nix run .#rebuild works for all future updates.
If you see
Cannot read ssh key '/etc/ssh/ssh_host_rsa_key', runsudo ssh-keygen -Aand rebuild.
Use this when the machine is already defined in flake.nix — reinstalling or restoring.
xcode-select --install
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- installOpen a new terminal after Nix installs.
sops-nix needs ~/.config/sops/age/keys.txt to decrypt secrets during the build.
mkdir -p ~/.config/sops/age
# paste the key from your backup (Passwords app, 1Password, etc.)
nano ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txtIf you've lost the age key, stop here and follow the full recovery steps instead.
You need an SSH key to clone via SSH. Your SSH key will be deployed by sops-nix after the build — but to get there you need to clone first. Options:
- Clone via HTTPS:
git clone https://github.com/utopiaeh/nix-config.git ~/nix-config- Or place a temporary SSH key: check your GitHub Settings → SSH and GPG keys for the public key, restore the private key from backup if available
git clone https://github.com/utopiaeh/nix-config.git ~/nix-config
cd ~/nix-configscutil --get LocalHostName # must match what's in flake.nixTo change it:
sudo scutil --set HostName <your-machine>
sudo scutil --set LocalHostName <your-machine>nix --extra-experimental-features 'nix-command flakes' build ".#darwinConfigurations.$(scutil --get LocalHostName).system"
./result/sw/bin/darwin-rebuild switch --flake ".#$(scutil --get LocalHostName)"After a successful build, sops-nix places your SSH key at ~/.ssh/id_ed25519. Add the public key to GitHub if you haven't already:
cat ~/.ssh/id_ed25519.pub # add to github.com → Settings → SSH and GPG keys
ssh -T git@github.com # verifynix run .#rebuild # apply config changes
nix flake update && nix run .#rebuild # update all dependencies
nix flake update rust-overlay && nix run .#rebuild # update one input
nix run .#rollback # roll back to previous generation
nix run .#cleanup # garbage collect (older than 14 days)
nix run .#edit-secrets -- <machine> # edit encrypted secrets
nix run .#check-secrets -- <machine> # verify secrets decrypt cleanly
darwin-rebuild --list-generations # list all generations| Command | What it does |
|---|---|
fix-sound |
Restarts the macOS audio daemon |
dev |
cd ~/Developer |
cl |
Clear terminal |
lg |
Opens lazygit |
tscl |
Runs npx tsc |
, <package> |
Runs a Nix package without installing it |
tpl-node |
Initializes a Node.js project from template |
tpl-esp32 |
Initializes an ESP32-S3 Rust project from template |
The , command is especially useful — e.g. , ffmpeg -i video.mp4 output.gif. Downloaded on first use, cached for reuse, nothing stays on your PATH permanently.
| What | Where |
|---|---|
| New GUI app | homebrew.casks in hosts/common/darwin/homebrew.nix |
| New CLI tool (system-wide) | environment.systemPackages in common-packages.nix |
| New CLI tool (personal) | home.packages in base.nix |
| Shell alias | programs.zsh.shellAliases in base.nix |
| Environment variable | home.sessionVariables in base.nix |
| Machine-specific package | hosts/darwin/<machine>/default.nix |
- iTerm2 — if theme or font looks wrong, quit and reopen
- FlashSpace — config applied automatically from
home-manager/programs/flashspace/ - MiddleClick — enable in Accessibility settings
- BetterDisplay — grant Screen Recording permission
mkdir -p ~/Developer/my-app && cd ~/Developer/my-app
tpl-node # Node.js (pnpm, typescript)
tpl-esp32 # ESP32-S3 Rust project
direnv allow # load dev environmentGitHub Actions (.github/workflows/) runs on every push/PR:
check.yml—nix flake checkplus a full build of bothmac-proandflow48system derivations. Secrets aren't needed for this — sops-nix only decrypts at activation (darwin-rebuild switch), not at build time.update-flake.yml— weekly (Mondays), opens a PR bumping all flake inputs. Review and merge manually; nothing auto-merges.
Garbage collection is manual, not automatic (nix.gc.automatic = false) — run nix run .#cleanup when you want to reclaim space.
The age key in keys.txt is independent of the SSH key — rotating SSH only requires updating the secret value. No .sops.yaml changes needed.
ssh-keygen -t ed25519 -f /tmp/new_ssh_key -N "" -C "you@email.com"Saves to
/tmpbecause~/.ssh/id_ed25519is a symlink managed by sops-nix.
nix run .#edit-secrets -- <your-machine>Replace the ssh_key value with the contents of /tmp/new_ssh_key, save and close.
nix run .#rebuildsops-nix only manages the private key — the public key file won't update automatically.
ssh-keygen -yf /run/secrets/ssh_key > ~/.ssh/id_ed25519.pub
ssh-add -D && ssh-add ~/.ssh/id_ed25519Go to github.com → Settings → SSH and GPG keys, add the new public key, remove the old one.
ssh -T git@github.com # verifyrm /tmp/new_ssh_key /tmp/new_ssh_key.pubUse this if sops fails to activate on boot — ~/.ssh/id_ed25519 is missing or broken and you can't push to GitHub.
Why it happens: sops-nix decrypts secrets on boot using keys.txt. If that file is missing, decryption fails and no secrets are placed.
# verify decryption works manually
nix shell nixpkgs#sops --command sops --decrypt secrets/<your-machine>/secrets.enc.yaml
# if that works, just rebuild
darwin-rebuild switch --flake ".#$(scutil --get LocalHostName)"mkdir -p ~/.config/sops/age
nano ~/.config/sops/age/keys.txt # paste from Passwords app / 1Password
chmod 600 ~/.config/sops/age/keys.txt
darwin-rebuild switch --flake ".#$(scutil --get LocalHostName)"You cannot decrypt the old secrets. Regenerate everything.
1. Generate a new age key:
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txt
age-keygen -y ~/.config/sops/age/keys.txt # note the public key2. Generate a new SSH key:
ssh-keygen -t ed25519 -C "you@email.com" -f /tmp/new_ssh_key -N ""3. Update .sops.yaml — replace the old age public key with the new one from step 1.
4. Re-create secrets — create a plaintext file:
# /tmp/secrets.yaml
ssh_key: |
<contents of /tmp/new_ssh_key>Encrypt it:
nix shell nixpkgs#sops --command sops --encrypt /tmp/secrets.yaml > secrets/<your-machine>/secrets.enc.yaml
rm /tmp/secrets.yaml5. Place the SSH key temporarily so the build can complete:
cp /tmp/new_ssh_key ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed255196. Rebuild:
darwin-rebuild switch --flake ".#$(scutil --get LocalHostName)"7. Add the new SSH public key to GitHub, remove the old one.
8. Back up keys.txt to the macOS Passwords app or 1Password.
rm /tmp/new_ssh_key /tmp/new_ssh_key.pub