Declarative macOS configuration using nix-darwin, home-manager, and sops-nix for secrets. Supports Apple Silicon.
Everything — shell, tools, editor, fonts, apps, system settings — is managed from this repo. A single command rebuilds the entire system.
- How it works
- Project structure
- Setup
- New machine — machine not yet defined in this repo
- Existing machine — machine already defined, reinstalling or restoring
- Rebuild & update
- Regenerating your SSH key
- SSH and sops recovery
- Built-in commands
- Post-build manual steps
- Day-to-day reference
- Project templates
| Layer | Tool | What it manages |
|---|---|---|
| System | nix-darwin |
macOS settings, fonts, Homebrew apps, 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 |
When you run rebuild, Nix reads the config, computes what changed, and applies it atomically. If something breaks, roll back to the previous generation.
Secrets are encrypted using age. A standalone age key is stored at ~/.config/sops/age/keys.txt — this is the master key for decryption. Your SSH key is stored as a secret encrypted by that age key, not the other way around.
nix-config/
├── flake.nix # Entry point — defines all machines
├── flake.lock # Pinned dependency versions
├── .sops.yaml # Defines which age keys can decrypt which secrets
├── hosts/
│ ├── common/
│ │ ├── darwin-common.nix # Shared macOS settings, Homebrew apps, fonts
│ │ └── common-packages.nix # System-wide CLI tools
│ └── darwin/
│ ├── <your-machine>/ # Machine-specific config
│ └── mac-pro/ # Mac Pro config
├── home-manager/
│ ├── profiles/
│ │ ├── base.nix # User environment (shell, git, SSH, aliases...)
│ │ ├── <your-machine>.nix # Machine-specific home config
│ │ └── mac-pro.nix # Mac Pro home config
│ └── programs/
│ ├── rust/ # Rust toolchain + rust-analyzer
│ ├── nix/ # Nix LSP (nixd) + formatter (nixfmt)
│ ├── node/ # Node.js environment
│ ├── git/ # Git config
│ └── iterm2/ # iTerm2 preferences (declarative)
├── assets/
│ ├── starship/ # Prompt config
│ ├── idea/ # IntelliJ layout
│ ├── raycast/ # Raycast settings (import manually)
│ └── wallpapers/
├── secrets/
│ ├── <your-machine>/ # Machine-specific secrets (ssh_key, tokens)
│ ├── shared/ # Secrets accessible by all machines
│ └── secrets_example.yaml # Template for machine secrets
└── templates/ # Flake templates for new projects
├── node-lts/
└── esp32-rust-project/
Which path should I follow?
- New machine — you're setting up a machine that is not yet defined in
flake.nix(brand new or third machine)- Existing machine — the machine is already in
flake.nix, you're reinstalling or restoring
Steps 1–6 are shared, then the paths diverge.
Required for git and other build tools.
xcode-select --installcurl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- installOpen a new terminal after installation so nix is on your PATH.
This SSH key will become your permanent key — it will be stored encrypted in the repo and deployed on every rebuild.
ssh-keygen -t ed25519 -C "utopiaeh01@gmail.com"Copy the public key and add it to GitHub at Settings → SSH and GPG keys:
cat ~/.ssh/id_ed25519.pubgit clone git@github.com:utopiaeh/nix-config.git ~/nix-config
cd ~/nix-confignix developThis drops you into a shell with sops, age, and ssh-to-age available — the tools needed to set up secrets before the first build.
sops encrypts secrets using age. Generate a dedicated age key and save it locally — this key decrypts secrets during every build and on boot.
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txtGet your age public key — you will need it in the next step:
age-keygen -y ~/.config/sops/age/keys.txt
# outputs something like: age1abc123...Keep
~/.config/sops/age/keys.txtsafe. This file is not managed by Nix — back it up somewhere secure (1Password, etc). Losing it means losing the ability to decrypt your secrets.
sudo scutil --set HostName <your-machine>
sudo scutil --set LocalHostName <your-machine>Add it to flake.nix under darwinConfigurations:
<your-machine> = libx.mkDarwin { hostname = "<your-machine>"; };Add its age public key to .sops.yaml (the key from step 6):
- path_regex: ^secrets/<your-machine>/.*\.yaml$
key_groups:
- age:
- age1abc123...Create the machine config at hosts/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.sshKeyPathshere. Using the SSH key as the sops decryption key creates a circular dependency — sops needs the SSH key to decrypt secrets, but the SSH key is itself a secret. Theage.keyFilealone is sufficient and avoids this.
Create the home-manager profile at home-manager/profiles/<your-machine>.nix:
{ ... }:
{
imports = [ ./base.nix ];
}mkdir -p secrets/<your-machine>
cp secrets/secrets_example.yaml secrets/<your-machine>/secrets.yamlOpen secrets/<your-machine>/secrets.yaml and fill in:
ssh_key— paste the full contents of~/.ssh/id_ed25519- any other tokens required
Encrypt it:
sops -e secrets/<your-machine>/secrets.yaml > secrets/<your-machine>/secrets.enc.yaml
rm secrets/<your-machine>/secrets.yamlNever commit the unencrypted
.yamlfile — only the.enc.yaml.
nix run .#rebuild calls darwin-rebuild internally, which doesn't exist until nix-darwin is installed. Use this two-step bootstrap for the very first build:
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 completes, darwin-rebuild is on your PATH and nix run .#rebuild works for all future updates.
If you see errors like
Cannot read ssh key '/etc/ssh/ssh_host_rsa_key', runsudo ssh-keygen -Aand rebuild.
Use this path when the machine is already defined in flake.nix — you're reinstalling, restoring, or setting up on the same machine again.
xcode-select --installcurl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- installOpen a new terminal after installation.
If you already have the SSH key (e.g. from a backup), add its public key to GitHub at Settings → SSH and GPG keys:
cat ~/.ssh/id_ed25519.pubIf you don't have the key anymore, follow the new machine path and regenerate everything.
git clone git@github.com:utopiaeh/nix-config.git ~/nix-config
cd ~/nix-confignix developsops needs the age private key at ~/.config/sops/age/keys.txt to decrypt secrets during the build. Restore it from your backup (1Password, etc).
mkdir -p ~/.config/sops/age
# paste or copy your backed-up keys.txt content here
chmod 600 ~/.config/sops/age/keys.txtIf you have lost this key entirely, follow the SSH and sops recovery section instead.
Make sure your hostname matches what's defined in flake.nix:
scutil --get LocalHostNameTo 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)"If you see errors like
Cannot read ssh key '/etc/ssh/ssh_host_rsa_key', runsudo ssh-keygen -Aand rebuild.
Rebuild after any config change:
nix run .#rebuildUpdate all dependencies and rebuild:
nix flake update && nix run .#rebuildUpdate a single input (e.g. rust toolchain):
nix flake update rust-overlay && nix run .#rebuildRoll back to the previous generation:
nix run .#rollbackList all generations:
darwin-rebuild --list-generationsThe SSH private key is stored as a sops-encrypted secret and deployed by sops-nix to ~/.ssh/id_ed25519 on every rebuild. Because of this, you can't just run ssh-keygen — the key is managed by Nix.
The tricky part: sops uses your current SSH key (converted to an age key) to decrypt secrets. If you replace the key without a proper transition, sops won't be able to decrypt anything during the next rebuild. The solution is to keep both old and new age keys active during the transition, then remove the old one after the new key is deployed.
ssh-keygen -t ed25519 -f /tmp/new_ssh_key -N "" -C "utopiaeh01@gmail.com"Saves to
/tmpbecause~/.ssh/id_ed25519is a symlink managed by sops-nix — you can't write to it directly.-N ""means no passphrase.
nix shell nixpkgs#ssh-to-age --command ssh-to-age < /tmp/new_ssh_key.pubsops doesn't use SSH keys directly — it works with age keys. This converts your new SSH public key into the
age1...string you'll put in.sops.yaml.
- age:
- age1oldkey... # old — keep during transition so rebuild can still decrypt
- age1newkey... # newBoth keys are needed at this point. The next rebuild will still use the old SSH key (still on disk) to decrypt — removing it now would break the rebuild before the new key is deployed.
sops updatekeys secrets/<your-machine>/secrets.enc.yaml --yes
sops updatekeys secrets/shared/secrets.enc.yaml --yesNow both old and new age keys can decrypt the secrets files.
sops secrets/<your-machine>/secrets.enc.yamlOpens the secrets file decrypted in your editor. Replace the
ssh_keyvalue with the contents of/tmp/new_ssh_key, save, and close — sops re-encrypts automatically on exit.
nix run .#rebuildsops decrypts using the old SSH key (still active on disk), reads the new
ssh_keyvalue, and writes it to/run/secrets/ssh_key→~/.ssh/id_ed25519.
sops-nix only manages the private key — ~/.ssh/id_ed25519.pub is not updated automatically and will be stale.
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 and delete the old one.
Test the connection:
ssh -T git@github.comEdit .sops.yaml to remove the old age key, keeping only the new one. Then re-encrypt:
sops updatekeys secrets/<your-machine>/secrets.enc.yaml --yes
sops updatekeys secrets/shared/secrets.enc.yaml --yesNow only the new key can decrypt the secrets. The transition is complete.
rm /tmp/new_ssh_key /tmp/new_ssh_key.pubUse this if sops fails to activate on boot (secrets not decrypted, ~/.ssh/id_ed25519 missing or broken).
sops-nix decrypts secrets on every boot using ~/.config/sops/age/keys.txt. If that file is missing or incorrect, decryption fails, no secrets are placed, and your SSH key disappears. This can leave you unable to push to GitHub.
Do not configure
age.sshKeyPathspointing at your SSH key in the sops config. This creates a circular dependency — sops needs the SSH key to decrypt, but the SSH key is the secret it needs to decrypt. Always useage.keyFileonly.
The age key file exists but sops still failed. Try re-running activation manually:
# Verify the key file decrypts your secrets
SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt sops --decrypt secrets/<your-machine>/secrets.enc.yaml
# If that works, rebuild to re-activate
darwin-rebuild switch --flake ".#$(scutil --get LocalHostName)"mkdir -p ~/.config/sops/age
# restore keys.txt from 1Password or wherever you backed it up
chmod 600 ~/.config/sops/age/keys.txt
darwin-rebuild switch --flake ".#$(scutil --get LocalHostName)"You cannot decrypt the old secrets. Generate everything from scratch:
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 "your@email.com" -f /tmp/new_ssh_key -N ""3. Update .sops.yaml with the new age public key from step 1.
4. Re-encrypt secrets (the old file is unreadable — create a new one):
export SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt
nix develop # get sops in PATH
sops secrets/<your-machine>/secrets.enc.yamlPaste the contents of /tmp/new_ssh_key as the ssh_key value. Add any other tokens.
5. 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, then remove the old one.
8. Back up ~/.config/sops/age/keys.txt somewhere secure so this doesn't happen again.
Available in every terminal after rebuild:
| Command | What it does |
|---|---|
fix-sound |
Kills and restarts the macOS audio daemon |
idea [path] |
Opens a project in IntelliJ IDEA |
dev |
cd ~/Developer |
lg |
Opens lazygit |
, package-name |
Runs a Nix package without installing it |
tpl-node |
Initializes a Node.js project from the flake template |
tpl-esp32 |
Initializes an ESP32-S3 Rust project from the flake template |
Runs any Nix package without installing it. Downloaded on first use, cached for instant reuse. Nothing ends up on your PATH permanently.
, cowsay hello
, ffmpeg -i video.mp4 output.gif
, python3| Command | What it does |
|---|---|
nix run .#rebuild |
Build and switch to current config |
nix run .#rollback |
Roll back to the previous generation |
nix run .#cleanup |
Garbage collect generations older than 14 days |
Most configuration is applied automatically on rebuild. A few things require a manual step.
Preferences are managed declaratively and applied on each rebuild. If the theme or font looks wrong, quit and reopen iTerm2.
Font for macOS Terminal (if preferred over iTerm2):
MesloLGL Nerd Font
Import settings manually from assets/raycast/.
Config is applied automatically from home-manager/programs/flashspace/ on each rebuild.
- MiddleClick — enable in Accessibility settings
- AltTab — grant Screen Recording permission
- BetterDisplay — grant Screen Recording permission
| What | Where |
|---|---|
| New GUI app | homebrew.casks in darwin-common.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/<your-machine>/default.nix |
Managed via rust-overlay in home-manager/programs/rust/default.nix. Includes rust-analyzer, rust-src, and llvm-tools. Updates automatically after nix flake update rust-overlay && nix run .#rebuild.
nixd is configured in home-manager/programs/nix/default.nix and Zed is pointed to it via ~/.config/zed/settings.json. No manual setup needed.
nix run .#cleanupGarbage collects store paths older than 14 days.
Bootstrap new projects with automatic direnv integration. Both direnv and nix-direnv are enabled — entering a project directory loads its dev environment without losing your shell aliases or prompt.
| Command | Description |
|---|---|
tpl-node |
Node.js project (nodejs, pnpm, yarn, typescript) |
tpl-esp32 |
ESP32-S3 Rust project (espflash, ldproxy, esp-generate) |
mkdir -p ~/Developer/my-app && cd ~/Developer/my-app
tpl-node # or tpl-esp32
direnv allowAfter direnv allow, entering the directory automatically loads the dev environment. To reload after editing flake.nix:
direnv reload