The Problem#
Every developer has felt it - that creeping anxiety when you need to set up a new machine, reinstall macOS, or even just remember how you configured that one tool six months ago. The hidden complexity of developer environments scales poorly across machines and time.
You’ve accumulated years of tweaks: shell aliases, git configs, editor settings, carefully curated applications. Maybe you have a dotfiles repo with some symlink scripts. Maybe you have a README with installation steps. Maybe you just… wing it and hope muscle memory carries you through.
This doesn’t work. “It works on my machine” isn’t just a joke about production deployments - it’s a problem you face with yourself, across your own machines, across time.
Working at Shopify, I saw what good tooling could do. The internal dev tool made repository setup trivial: dev clone repo && dev up and you had databases, dependencies, services - everything running. It solved the environment problem for individual services. I wanted that same philosophy - declarative, reproducible, one-command setup - but applied to my entire machine.
The Declarative Revolution#
What if you could treat your personal development environment with the same rigor as production infrastructure? What if your entire macOS setup - system settings, 85+ applications, shell configuration, development tools, and even your AI coding assistants - was defined in code, version-controlled, and reproducible with one command?
This is what nix-darwin enables. Not just dotfiles. Not just a collection of shell scripts. A complete, declarative system configuration for macOS.
Here’s what my setup manages:
- 85+ applications via Homebrew (Docker, Cursor, Slack, 1Password, etc.)
- System preferences (Dock layout, Finder settings, Trackpad configuration)
- Shell environment (Zsh with zinit, Starship prompt, modern CLI tools)
- Development tools (Git with multi-identity support, Ruby/Node/Python via mise)
- AI coding assistants (Claude Code, Cursor, Codex, Gemini - all configured via Nix)
- MCP servers (Model Context Protocol servers shared across AI tools)
- Secrets management (1Password CLI integration without storing secrets)
All of this lives in 2,435 lines of modular Nix configuration, version-controlled, and deployable to a fresh Mac in about 20 minutes.
Architecture: Two Tiers, One Source of Truth#
The foundation is Nix, a purely functional package manager that brings reproducibility to system configuration. On macOS, this manifests as a two-tier architecture:
nix-darwin (system-level)
├── Applications (Homebrew casks, Mac App Store apps)
├── System settings (Dock, Finder, Trackpad, macOS defaults)
├── Fonts (Nerd Fonts, custom fonts)
└── System packages
Home Manager (user-level)
├── Shell configuration (Zsh, Starship, plugins)
├── Development tools (Git, SSH, mise)
├── Editor settings (Cursor, VS Code)
├── AI agent configurations (Claude, Gemini, Codex)
└── User application settings
Everything starts with nix/flake.nix:
{
description = "Darwin system flake";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
nix-darwin.url = "github:LnL7/nix-darwin";
nix-homebrew.url = "github:zhaofengli-wip/nix-homebrew";
home-manager.url = "github:nix-community/home-manager";
};
outputs = inputs@{ self, nix-darwin, nixpkgs, nix-homebrew, home-manager }:
let
lib = import ./modules/lib.nix;
moduleIndex = import ./modules/default.nix;
username = getEnvOrFallback "NIX_FULL_NAME" "bootstrap-user" "placeholder-user";
homeDirectory = "/Users/${username}";
in {
darwinConfigurations."macbook_setup" = nix-darwin.lib.darwinSystem {
modules =
moduleIndex.systemModules
++ [
nix-homebrew.darwinModules.nix-homebrew
home-manager.darwinModules.home-manager
{
home-manager = {
useGlobalPkgs = true;
useUserPackages = true;
extraSpecialArgs = commonConfig // { dotlib = lib; };
users.${username} = {
imports = [ ./home.nix ];
};
};
}
];
};
};
}
The flake.lock file pins exact versions of all dependencies - nixpkgs, nix-darwin, home-manager. This means the same configuration produces the same result, every time, on any machine.
The Secret Sauce: Bootstrap Mode + 1Password#
Here’s the challenge: I want my dotfiles repository to be public on GitHub, but my configuration needs personal information - email addresses, GitHub usernames, SSH keys, server IPs. How do you reconcile public code with private data?
Organizing Secrets in 1Password#
The first step was creating a dedicated vault. I created a “Nix” vault in 1Password specifically for dotfiles configuration, keeping it separate from passwords, credit cards, and other personal data. Within that vault, I organized secrets into logical items:
- MainID: Primary GitHub identity for most things (email, username, signing keys, SSH keys)
- PrivateID: Alternate git identity for certain private repositories
- Server: Server connection details (IPs, hostnames, SSH keyfiles)
- SSH: Server SSH public keys
- Git Config: Additional git configuration (Forgejo domain)
- Github: API tokens for tooling (MCP servers)
This organization makes it easy to manage related secrets together and understand what each item is for. The vault structure also makes it straightforward to grant access if I need to share specific configuration details.
The Three-Tier Fallback System#
With secrets organized in 1Password, the next challenge was loading them safely. The answer is a three-tier environment variable fallback system, implemented in nix/modules/lib.nix:
{
getEnvOrFallback = envVar: bootstrapVal: fallbackVal:
let
isBootstrap = builtins.getEnv "NIX_BOOTSTRAP_MODE" == "1";
in
if isBootstrap then bootstrapVal else
let envValue = builtins.getEnv envVar;
in if envValue != "" then envValue else fallbackVal;
}
This elegant function provides three levels of configuration:
- Bootstrap values: Safe placeholders for fresh system installation
- Environment variables: Real values loaded from 1Password CLI
- Fallback values: Placeholders for missing configuration
In practice, this looks like:
personalEmail = getEnvOrFallback "NIX_PERSONAL_EMAIL" "bootstrap@example.com" "placeholder@example.com";
githubUser = getEnvOrFallback "NIX_GITHUB_USER" "bootstrap-user" "placeholder-user";
signingKey = getEnvOrFallback "NIX_SIGNING_KEY" "bootstrap-key" "ssh-ed25519 PLACEHOLDER";
The nixup-with-secrets script (289 lines) orchestrates the magic:
# Detect fresh system vs. established system
is_fresh_system() {
if ! command -v op >/dev/null 2>&1; then
return 0 # Fresh system - 1Password CLI not installed
fi
return 1
}
# Load 24 secrets from 1Password in parallel
load_secrets() {
# Batch fetch all items
main_id_json="$(op item get "MainID" --vault="Nix" --format=json)"
server_json="$(op item get "Server" --vault="Nix" --format=json)"
private_id_json="$(op item get "PrivateID" --vault="Nix" --format=json)"
# Extract and export 24 environment variables
export NIX_PERSONAL_EMAIL=$(jq -r '.fields[] | select(.label == "github_email") | .value' <<< "$main_id_json")
export NIX_GITHUB_USER=$(jq -r '.fields[] | select(.label == "github_user") | .value' <<< "$main_id_json")
# ... 22 more variables
# Pass all variables to sudo
sudo --preserve-env="${PRESERVE_NIX_VARS}" darwin-rebuild switch --flake ~/dotfiles/nix#macbook_setup --impure
}
This means:
- ✅ Fresh Mac? Bootstrap mode provides safe defaults, installs 1Password CLI
- ✅ 1Password configured? Load real secrets, apply full configuration
- ✅ Public repository? No secrets committed, safe to share
- ✅ Multiple machines? Same flake, different secrets per machine
The nx Command: Developer Ergonomics Matter#
At Shopify, the dev tool made complex operations simple: dev cd repo && dev up would change the directory to the repo you asked for and start everything you needed. I wanted that same ergonomic experience for my dotfiles.
Nix commands are powerful but verbose:
# What you'd type without nx
darwin-rebuild switch --flake ~/dotfiles/nix#macbook_setup
nix flake check ~/dotfiles/nix
nix flake update --flake ~/dotfiles/nix
The nx command (355 lines) wraps this complexity with user-friendly commands:
nx up # Apply configuration
nx check # Validate flake
nx diff # Preview changes (dry-run)
nx update # Update dependencies
nx clean # Garbage collect old generations
nx edit # Open dotfiles in Cursor
nx pr # Create GitHub PR with auto-merge
Here’s an example from nx clean, showing the kind of UX polish that went into the tool:
clean_generations() {
log_info "Cleaning old nix-darwin generations..."
log_warning "This will remove old system generations. Continue? (y/N)"
read -r response
if [[ "$response" =~ ^[Yy]$ ]]; then
log_info "Starting garbage collection..."
# Show progress while garbage collection runs
sudo sh -c "nix-collect-garbage -d" &
local gc_pid=$!
local dots=0
while kill -0 "$gc_pid" 2>/dev/null; do
printf "\r%s[INFO]%s Garbage collection in progress" "${BLUE}" "${NC}"
for ((i=0; i<dots; i++)); do printf "."; done
dots=$(( (dots + 1) % 4 ))
sleep 0.5
done
# Extract and highlight freed space
if echo "$output" | grep -q "freed"; then
freed=$(echo "$output" | grep -o "[0-9.]*\s*[KMGT]*iB freed")
log_success "Garbage collection completed - $freed"
fi
fi
}
Colored output, progress indicators, confirmation prompts, extracted metrics - all the polish you’d expect from a well-crafted CLI.
The nx pr command is particularly nice - it creates a timestamped branch, commits all changes, pushes, creates a PR, and optionally enables auto-merge:
nx pr --merge --title "Update Homebrew casks"
This creates a branch like auto/pr-20250120-091602, commits changes, pushes, creates the PR, and enables auto-merge with squash. One command, complete workflow.
There’s a subtle detail here too: the 1Password plugin pattern. The GitHub CLI has 1Password plugin support, but shell aliases don’t inherit into subshells. So instead of relying on an alias, we explicitly wrap commands:
# ✅ Works in subshells and scripts
op plugin run -- gh repo view --json defaultBranchRef
# ❌ Alias won't work
gh repo view --json defaultBranchRef
This pattern is documented in the code and in the AI configuration files, ensuring consistency.
Managing 85+ Applications Declaratively#
One of the most immediately useful features is declarative application management. In nix/modules/system/homebrew.nix:
{ username, ... }: {
homebrew = {
enable = true;
casks = [
"1password@beta"
"cursor"
"docker-desktop"
"discord"
"obsidian"
"signal"
"tailscale-app"
"vlc"
# ... 77 more
];
masApps = {
"1Password for Safari" = 1569813296;
"Deliveries" = 290986013;
"Proton Pass for Safari" = 6502835663;
# ... more Mac App Store apps
};
onActivation = {
cleanup = "zap"; # Remove apps not in config
autoUpdate = true;
upgrade = true;
};
};
}
That cleanup = "zap" line is important - it means applications not in the configuration are automatically uninstalled. Your installed apps always match your configuration.
This creates a powerful workflow: want to try a new app? Just install it normally with brew install --cask whatever. Play with it. If you like it, add it to the config. If you don’t, the next nx up removes it automatically. No manual cleanup, no forgotten trial apps cluttering your system. Every installed application becomes an intentional choice, explicitly declared in your configuration.
The Dock is managed declaratively too. While nix-darwin has built-in Dock management options, I hit limitations with folder organization and sort order customization. So I implemented a custom solution using optimized batch plist operations in nix/modules/system/system-defaults.nix:
dockApps = [
"${cryptexAppsDir}/Safari.app"
"${appsDir}/Cursor.app"
"${appsDir}/Discord.app"
"${systemAppsDir}/Messages.app"
# ... 16 more
];
# Batched operation for performance
dockSetupCommand = ''
defaults write com.apple.dock persistent-apps -array
defaults write com.apple.dock persistent-apps -array ${lib.concatStringsSep " " (map (entry: "'" + entry + "'") dockPlistEntries)}'';
This custom approach creates a 20-app Dock layout in one operation, instead of 20 individual plist writes. More importantly, it gives complete control over folder configurations and sort orders that the built-in options couldn’t provide.
System preferences are declarative too:
system.defaults = {
finder = {
AppleShowAllExtensions = true;
ShowStatusBar = true;
FXPreferredViewStyle = "icnv"; # Icon view
};
NSGlobalDomain = {
"com.apple.swipescrolldirection" = false; # Non-natural scrolling
AppleInterfaceStyle = "Dark";
};
CustomUserPreferences = {
"com.apple.dock" = {
show-recents = false;
tilesize = 64;
orientation = "left";
};
"com.apple.trackpad" = {
forceClick = true;
scaling = "1.5";
};
};
};
AI Agent Configuration as Code: The Unique Story#
Here’s where things get really interesting. I don’t just configure my operating system and applications - I configure my AI coding assistants declaratively through Nix.
This solves a real problem: modern development involves multiple AI tools (Claude Code, Cursor, Gemini, Codex), each with their own configuration format and specialties. As the AI arms race pushes forward, different tools excel at different tasks, and which one is “best” shifts constantly. How do you maintain consistency across tools? How do you share MCP servers? How do you enforce security constraints when you’re juggling multiple AI assistants?
The answer is a four-layer system:
Layer 1: Shared MCP Infrastructure#
Model Context Protocol (MCP) servers provide AI tools with external capabilities - reading GitHub repositories, understanding Rails codebases, etc. Instead of configuring these separately for each tool, I define them once in nix/modules/home/mcp-shared.nix:
let
githubMcpToken = getEnvOrFallback "NIX_GITHUB_MCP_TOKEN" "bootstrap-token" "placeholder-token";
mcpServers = {
github = {
command = "github-mcp-server";
args = [ "stdio" ];
env = { GITHUB_PERSONAL_ACCESS_TOKEN = githubMcpToken; };
};
rails = {
command = "rails-mcp-server";
args = [ "stdio" ];
env = { };
};
};
# Cursor needs a "type" field
cursorMcpServers = {
github = mcpServers.github // { type = "stdio"; };
rails = mcpServers.rails // { type = "stdio"; };
};
in
{
_module.args = {
mcpServers = mcpServers;
cursorMcpServers = cursorMcpServers;
};
}
This configuration is exported to all AI tool modules via _module.args, ensuring DRY (Don’t Repeat Yourself) principles.
Layer 2: Tool-Specific Configurations#
Each AI tool gets its own Nix module that imports the shared MCP configuration and adds tool-specific settings.
Claude Code (nix/modules/home/claude.nix):
This is the most sophisticated configuration - 221 lines defining a granular permission system. You can probably tell which AI tool I use most:
let
devTools = [
"npm run lint"
"npm run test:*"
"cargo check"
"pytest:*"
"make test"
];
rubyTools = [
"bundle:*"
"bundle exec rubocop:*"
"bundle exec rspec:*"
# ... more Ruby tools
];
gitOps = [
"git status"
"git diff:*"
"git add:*"
"git commit:*"
];
toBashPermissions = commands: map (cmd: "Bash(${cmd})") commands;
in
{
programs.claude-code = {
enable = true;
settings = {
statusLine = {
type = "command";
command = "starship prompt"; # Starship integration
};
permissions = {
allow =
(toBashPermissions devTools)
++ (toBashPermissions rubyTools)
++ (toBashPermissions gitOps)
++ [ "WebSearch" ]
++ [
"mcp__github__get_commit"
"mcp__github__list_issues"
"mcp__github__search_code"
# ... 20+ MCP tools
];
ask = toBashPermissions [
"git push:*"
"rm:*"
"sudo:*"
];
deny = [];
};
};
mcpServers = mcpServers; # From shared config
};
}
This means Claude Code can:
- ✅ Run linting and tests without asking
- ✅ Use MCP servers for GitHub operations
- ✅ Read safe files like package.json and README.md
- ⚠️ Ask before pushing to git
- ⚠️ Ask before deleting files
- ❌ Never run sudo commands automatically
Cursor (nix/modules/home/cursor.nix):
Generates settings.json and MCP configuration:
{
home.file = {
"Library/Application Support/Cursor/User/settings.json".text = ''
{
"workbench.startupEditor": "none",
"[ruby]": {
"editor.defaultFormatter": "Shopify.ruby-lsp",
"editor.formatOnSave": true
},
"rubyLsp.rubyVersionManager": {
"identifier": "mise",
"path": "/Users/${fullName}/.local/bin/mise"
}
}
'';
".cursor/mcp.json".source = cursorMcpJson; # Pretty-formatted MCP config
};
}
Codex (nix/modules/home/codex.nix):
{
programs.codex = {
enable = true;
settings = {
approval_policy = "on-request";
sandbox_mode = "danger-full-access";
file_opener = "cursor";
tools = { web_search = true; };
mcp_servers = mcpServers; # Shared MCP config
};
custom-instructions = ''
- Prefer object-oriented programming patterns
- Use descriptive variable names
- Always run linting and tests before committing
'';
};
}
Gemini (nix/modules/home/gemini.nix):
{
programs.gemini-cli = {
enable = true;
settings = {
ui.theme = "GitHub";
history_limit = 20;
max_tokens = 8192;
mcp_servers = mcpServers; # Shared MCP config
};
};
}
Layer 3: Global AI Policies#
The nix/modules/home/ai-globals.nix module (379 lines) generates global configuration files that all AI tools can reference. This creates a hierarchical configuration system: global defaults → project-specific overrides.
It generates:
~/AGENTS.md - Universal execution constraints:
# Global AI Agent Configuration
## Mandatory Validation Commands
Before any code changes are submitted, AI agents MUST execute:
```bash
# For Nix projects
nix flake check
# For shell scripts
shellcheck *.sh bin/*
# For markdown
markdownlint *.md
Security Requirements#
AI agents are PROHIBITED from:
- Executing destructive file operations (
rm -rf, etc.) - Running commands with
sudoprivileges - Modifying database schemas without approval
- Making external API calls to unknown endpoints
AI agents MAY execute:
- Linting and formatting commands
- Test suites and validation scripts
- Build and compilation commands (dry-run)
- Git status and diff operations
~/.claude/CLAUDE.md - Claude-specific global preferences:
# Global Claude Code Configuration
## Available Modern CLI Tools
- bat - cat with syntax highlighting (use instead of cat)
- eza - modern ls with git integration (use instead of ls)
- ripgrep (rg) - fast grep alternative (use for searching)
- fd - fast find alternative (use for finding files)
## Security Guidelines
### 1Password CLI Integration
- Shell aliases don't work in non-interactive subshells
- In scripts, explicitly wrap: `op plugin run -- gh <command>`
~/.cursor/rules/global-ai-integration.md - Cursor global rules:
---
type: always
description: "Global AI Agent Configuration Integration"
---
# Global AI Agent Configuration
This rule integrates with global configuration files.
## Validation Requirements
All AI agents must execute appropriate validation commands before changes:
- `nix flake check` for Nix projects
- `shellcheck *.sh bin/*` for shell scripts
- Language-specific linting and testing
## Security Boundaries
- No destructive operations without approval
- No sudo or system modifications
- Principle of least privilege
The magic here is that these are generated as Nix home.file entries, so they’re managed declaratively:
{
home.file = {
"AGENTS.md".text = ''
# Global AI Agent Configuration
${generateValidationSection}
${generateSecuritySection}
'';
".claude/CLAUDE.md".text = ''
# Global Claude Code Configuration
${generateToolPreferences}
${generateSecurityGuidelines}
'';
".cursor/rules/global-ai-integration.md".text = ''
---
type: always
---
${globalConfiguration}
'';
};
}
Layer 4: Rails MCP Server Automation#
The Rails MCP server is special - it needs the rails-mcp-server Ruby gem installed and resource packs downloaded. This is handled via Home Manager activation scripts in nix/modules/home/rails-mcp.nix:
{
home.activation.installRailsMcpServer = lib.hm.dag.entryAfter ["writeBoundary"] ''
ruby_version="$(cat "$HOME/.ruby-version" 2>/dev/null || echo "latest")"
if ! mise x ruby@"$ruby_version" -- gem list -i rails-mcp-server >/dev/null 2>&1; then
mise x ruby@"$ruby_version" -- gem install rails-mcp-server --no-document
fi
'';
home.activation.updateRailsMcpResources = lib.hm.dag.entryAfter ["installRailsMcpServer"] ''
for pack in rails turbo stimulus kamal; do
rails-mcp-server-download-resources "$pack" || echo "Warning: failed $pack"
done
'';
home.activation.ensureRailsMcpConfig = lib.hm.dag.entryAfter ["updateRailsMcpResources"] ''
if [ ! -f "$HOME/.config/rails-mcp/projects.yml" ]; then
cat > "$HOME/.config/rails-mcp/projects.yml" <<'YAML'
projects: []
YAML
fi
'';
}
This runs during nx up and ensures:
- ✅ Rails MCP gem is installed for current Ruby version
- ✅ Official Rails guides are downloaded as MCP resources
- ✅ Configuration file exists for project management
Why This Matters#
This multi-layer AI configuration system provides:
- DRY Principles: MCP servers defined once, used everywhere
- Consistency: All AI tools follow the same security constraints
- Version Control: AI assistant behavior is tracked in git
- Portability: Same configuration across all machines
- Auditability: Permission changes are visible in diffs
- Hierarchical Configuration: Global defaults + project overrides
- Security: Constraints defined declaratively, not per-tool
When I run nx up, my AI assistants are configured just as declaratively as my OS, with the same reproducibility guarantees.
Module Deep Dives#
Git Configuration: Multi-Identity Support#
The nix/modules/home/git.nix module handles complex git configuration, including multiple identities via conditional includes:
let
# Main identity
personalEmail = getEnvOrFallback "NIX_PERSONAL_EMAIL" "bootstrap@example.com" "placeholder@example.com";
githubUser = getEnvOrFallback "NIX_GITHUB_USER" "bootstrap-user" "placeholder-user";
signingKey = getEnvOrFallback "NIX_SIGNING_KEY" "bootstrap-key" "PLACEHOLDER";
# Private identity
privateEmail = getEnvOrFallback "NIX_PRIVATE_EMAIL" "bootstrap-private@example.com" "placeholder-private@example.com";
privateUser = getEnvOrFallback "NIX_PRIVATE_USER" "bootstrap-private-user" "placeholder-private-user";
privateSigningKey = getEnvOrFallback "NIX_PRIVATE_SIGNING_KEY" "bootstrap-key" "PLACEHOLDER";
privateGitDir = getEnvOrFallback "NIX_PRIVATE_GITDIR" "~/dev/bootstrap/" "~/dev/placeholder/";
in
{
programs.git = {
enable = true;
userName = githubUser;
userEmail = personalEmail;
signing = {
key = signingKey;
signByDefault = true;
};
# Delta for beautiful diffs
delta = {
enable = true;
options = {
features = "line-numbers decorations";
navigate = true;
side-by-side = true;
syntax-theme = "TwoDark";
};
};
# Conditional includes for private repos
includes = [{
condition = "gitdir:${privateGitDir}";
contents.user = {
email = privateEmail;
name = privateUser;
signingKey = privateSigningKey;
};
}];
extraConfig = {
push.autoSetupRemote = true;
gpg = {
format = "ssh";
ssh.program = "/Applications/1Password.app/Contents/MacOS/op-ssh-sign";
};
init.defaultBranch = "main";
tag.gpgsign = true;
core.editor = "cursor --wait";
};
};
}
This enables automatic identity switching based on repository location - perfect for separating personal and work projects.
Zsh: Modern Shell Configuration#
The nix/modules/home/zsh.nix module configures a fast, modern shell with zinit plugin manager and Starship prompt:
{
programs.zsh = {
enable = true;
initContent = ''
# Load zinit plugin manager
source ${pkgs.zinit}/share/zinit/zinit.zsh
# Zinit plugins
zinit light-mode for \
zsh-users/zsh-autosuggestions \
zdharma-continuum/fast-syntax-highlighting \
zsh-users/zsh-completions \
Aloxaf/fzf-tab \
zsh-users/zsh-history-substring-search
# Oh-My-Zsh snippets
zinit snippet OMZP::git
zinit snippet OMZP::sudo
zinit snippet OMZP::colored-man-pages
# Initialize zoxide (cd replacement)
eval "$(${pkgs.zoxide}/bin/zoxide init zsh --cmd cd)"
'';
shellAliases = {
cat = "bat"; # Syntax highlighting
grep = "rg"; # Fast search
find = "fd"; # Modern find
man = "batman"; # Man pages with highlighting
};
sessionVariables = {
EDITOR = "cursor --wait";
HOMEBREW_NO_ANALYTICS = "1";
};
};
programs.fzf.enable = true;
programs.zoxide.enable = true;
}
Modern CLI tools are aliased by default: bat instead of cat, eza instead of ls, rg instead of grep, fd instead of find. The shell experience is significantly improved.
The One-Command Setup Journey#
Starting from a fresh Mac, here’s what happens:
curl -sSL https://raw.githubusercontent.com/mmenanno/dotfiles/main/bootstrap.sh | bash
Minute 0-5: Nix Installation#
- Downloads and installs Nix package manager
- Configures Nix daemon
- Enables flakes and nix-command
Minute 5-20: Bootstrap Mode#
- Detects fresh system (no 1Password CLI)
- Sets
NIX_BOOTSTRAP_MODE=1 - Applies configuration with safe placeholder values
- Installs 85+ applications via Homebrew
- Configures system preferences
- Sets up shell environment
- Installs 1Password CLI and other essentials
At this point, you have a functional Mac with all applications installed and basic configuration applied.
Post-Bootstrap: 1Password Configuration#
Launch 1Password and sign in
Enable CLI integration: Settings → Developer → Command Line Interface
Run
op signinCreate required 1Password items in “Nix” vault (if you don’t have them from another machine):
- MainID: GitHub email, username, signing key, SSH keys
- Server: Server IPs, names, SSH keyfiles
- PrivateID: Alternate git identity
- SSH: Server SSH keys
- Git Config: Forgejo domain
- Github: MCP token
Run
nixup(ornx up)
Minute 20-25: Full Configuration#
- Loads 24 environment variables from 1Password
- Applies complete configuration with real secrets
- Configures git identities
- Sets up SSH keys
- Configures AI agent permissions
- Installs MCP servers and resources
Result: A fully configured, production-ready Mac that matches your exact specifications.
Daily Workflow#
My daily interaction with this system is smooth:
# Open dotfiles in Cursor
nx edit
# Make changes to modules (add an app, tweak git config, etc.)
# Update dependencies
nx update
# Validate changes
nx check
# Preview what will change
nx diff
# Apply configuration
nx up
# Create PR for the changes
nx pr --merge --title "Add Obsidian app and update git config"
The entire edit → validate → preview → apply → PR workflow is streamlined through the nx command.
When I need to update applications:
// nix/modules/system/homebrew.nix
casks = [
"cursor"
"docker-desktop"
"new-app-here" // Add new app
// "removed-app" // Remove by commenting or deleting
];
Run nx up, and the change is applied: new app installed, removed app uninstalled.
The Philosophy#
This approach represents a fundamental shift in how I think about personal infrastructure:
Infrastructure as Code Isn’t Just for Servers#
The same principles that make infrastructure-as-code valuable in production apply to personal development environments:
- Reproducibility enables fearless experimentation
- Version control provides audit trails and rollback capability
- Declarative configuration is self-documenting
- Modular architecture scales with complexity
Declarative Beats Imperative#
Shell scripts are imperative: “do this, then do that, then do the other thing.” Nix is declarative: “the system should look like this.” The difference is huge:
- Scripts are fragile (what if step 3 fails?)
- Scripts aren’t idempotent (run twice = different results)
- Scripts don’t handle drift (manual changes break assumptions)
- Declarative configuration is always in sync with reality
Investment in Ergonomics Pays Dividends#
The nx command is 355 lines of code. The AI configuration system is 379 lines generating global configs. The secret management is 289 lines. Was this worth it?
Absolutely. I interact with this system daily. Every command I run, every configuration change I make, benefits from that investment. The Shopify dev tool taught me this: good developer experience isn’t a luxury, it’s a productivity multiplier.
AI Tools Need Governance#
As AI coding assistants become more capable, they need guardrails. Managing AI agent configurations declaratively through Nix ensures:
- Consistent security constraints across tools
- Auditable permission changes
- Shared infrastructure (MCP servers) without duplication
- Version-controlled behavior
The AGENTS.md pattern, global configuration files, and hierarchical overrides provide governance without micromanagement.
Documentation as a Side Effect#
When your entire system is defined in code, documentation happens automatically:
- Want to know what apps are installed? Read
homebrew.nix - What’s the Dock layout? Check
system-defaults.nix - What can Claude Code do? Look at the permissions in
claude.nix - How are secrets managed? The code tells the story
The configuration files are the documentation, always up-to-date because they’re the source of truth.
What This Enables#
The compounding value of this setup manifests in several ways:
Fearless Experimentation#
Want to try a new app or package? Add it to homebrew.nix or packages.nix, run nx up. Don’t like it? Remove the line, run nx up again. It’s uninstalled. No residual configuration, no orphaned files.
Want to change system settings? Edit system-defaults.nix, preview with nx diff, apply with nx up. Don’t like the change? Git revert, nx up. Back to previous state.
Nix generations provide rollback capability, but more importantly, the declarative nature means you can experiment without fear.
Multi-Machine Synchronization#
The same flake can be used across multiple machines. The getEnvOrFallback pattern allows machine-specific secrets while keeping the core configuration shared.
If I get a new MacBook or add a desktop, they can run the same configuration with different personal details loaded from 1Password - same code, different secrets per machine.
Rapid Onboarding#
New MacBook? 20 minutes from unboxing to fully configured, with all 85+ apps installed, system preferences set, shell configured, development tools ready, and AI assistants configured.
Complete Disaster Recovery#
Hard drive failure? Fresh OS install? No problem. curl | bash, configure 1Password, nx up. Everything is restored exactly as it was.
AI Assistants with Consistent Behavior#
All my AI tools share the same MCP servers, follow the same security constraints, and have consistent global policies. When I update a security rule or add an MCP server, all tools get the update automatically.
Living Documentation#
New team member wants to know what tools I use? Point them to homebrew.nix. Want to know my git workflow? Check git.nix. This blog post is essentially just walking through the code.
Conclusion#
This isn’t just dotfiles. It’s not even just a development environment. It’s treating my personal macOS configuration as a first-class software project, with all the rigor that implies:
- Version control for everything
- Code review for changes (via
nx pr) - Reproducibility across machines and time
- Modularity for maintainability
- Documentation through declaration
- Testing via
nx checkandnx diff - Security through separation and constraints
- Ergonomics through investment in tooling
The emerging pattern of managing AI agents declaratively alongside system configuration is particularly exciting. As AI coding assistants become more central to development workflows, having them configured as code - with version control, auditability, and reproducibility - becomes increasingly valuable.
The inspiration from Shopify’s dev tool showed me that great developer experience comes from thoughtful investment in tooling. The 2,435 lines of Nix configuration, the 355-line nx CLI, the 379-line AI globals generator - these aren’t overhead, they’re leverage.
If you’re still managing your development environment through a collection of shell scripts, manual steps, and hope, I encourage you to consider the declarative approach. It’s not about Nix specifically (though Nix is excellent for this), it’s about treating your personal infrastructure with the same care you treat production systems.
Your development environment is infrastructure. Manage it like it is.
Resources:
