A Nix-flake-powered personal workstation setup for Unix machines.
This repository is the single source of truth for most of my system configuration: operating system settings, packages, shell, editor, terminal, automation, mail, and day-to-day development tooling. The main target is Apple Silicon macOS using nix-darwin + Home Manager, with an experimental NixOS host as well.
It is a personal setup first, but the structure is meant to stay reusable: hosts
are composed from small feature modules, app configs live in
config/, and new machines can be bootstrapped with a single
nix run command.
Font is Pragmata Pro, theme is my own fork of vim-colors-plain, Terminal is Ghostty.
Note
Pragmata Pro is a commercial font and is not bundled with this repo. By
default, this flake expects a PragmataPro<version>.zip archive in the Nix
store. Bootstrap accepts --without-pragmatapro to skip it for that run. See
Font prerequisite.
The repo is split between reusable Nix modules in nix/ and checked-in application configuration in config/. In practice it manages:
| Area | What is configured here |
|---|---|
| System management | Nix, flake-parts, Home Manager, nix-darwin, host definitions, overlays, and small custom packages |
| Shell and terminal | Zsh, tmux, herdr, Ghostty, Kitty, direnv, zoxide, eza, fzf, bat, ripgrep, and Yazi |
| Editor and coding tools | Neovim, formatters, language servers, and Node/Bun/Python/Go/Rust tooling |
| Source control | Git, Jujutsu, gh, gh-dash, tig, and delta |
| macOS automation | Hammerspoon, Karabiner-Elements, system defaults, and window management |
| Mail and communication | aerc, isync/mbsync, msmtp, and pass |
| Notes and media | zk, markdown-oxide, qmd, Neovim notes workflow, mpv, and yt-dlp |
| AI / agent tooling | Claude, Pi, Codex, OpenCode, sb, and related llm-agents tooling/config |
Codex defaults are symlinked from
config/codex/config.toml to
/etc/codex/config.toml. Hooks and themes remain in ~/.config/codex, and
Codex can write per-user settings to ~/.config/codex/config.toml. Nix
generates hooks.json with the absolute path of the repository info hook;
rebuild after editing the hook template.
Pi's configuration covers native tools, display
settings, and model setup. Its
extensions each live in a directory
with an index.ts entrypoint. The
/simplify command runs four
parallel cleanup reviewers over the codebase and sends their findings to the
current agent to apply fixes.
These are the targets currently represented in the flake:
| System | Status | Hosts |
|---|---|---|
aarch64-darwin |
Primary target, actively used on Apple Silicon macOS | rocket, alcantara |
x86_64-linux |
Experimental NixOS host | nixos |
If you do not pass a host during bootstrap, the scripts default to
$(hostname -s).
The repo is organized around a small number of top-level directories:
| Path | Purpose |
|---|---|
| config/ | Checked-in application configs for Neovim, tmux, herdr, git, Ghostty, Kitty, Yazi, aerc, Hammerspoon, Karabiner, Claude/Pi config, and more |
| nix/ | Flake composition, reusable modules, host definitions, overlays, custom packages, and encrypted/private files |
| scripts/ | Bootstrap scripts used by nix run for macOS and NixOS |
| templates/ | Small flake templates for starting new projects |
Inside nix/, the interesting pieces are:
| Path | Purpose |
|---|---|
| nix/parts/hosts/ | Per-machine host definitions |
| nix/parts/modules/shared/ | Reusable features such as shell, git, vim, mail, yazi, ai, node, and python |
| nix/parts/modules/darwin/ | macOS-only modules such as defaults, Hammerspoon, and Karabiner |
| nix/parts/outputs/ | Flake outputs like apps, templates, overlays, formatter, and dev shells |
| nix/pkgs/ | Small custom packages and overrides used by the configuration |
| nix/secrets/ | age-encrypted or private inputs used by the setup |
The overall pattern is simple: a host declares a list of features such as
shell, git, vim, mail, or ai, and the flake wires in the matching
system and Home Manager modules automatically.
I use the new Nix installer:
Warning
Before installing, check the
quirks
section.
curl --proto '=https' --tlsv1.2 -sSfL https://artifacts.nixos.org/nix-installer | sh -s -- installThis setup installs Pragmata Pro through a custom Nix package that uses
requireFile. If you own the font, add the archive to the Nix store before the
first rebuild:
nix-store --add-fixed sha256 /path/to/PragmataPro<version>.zipTo bootstrap before importing the archive, skip the font for that invocation:
nix --experimental-features 'nix-command flakes' run 'github:ahmedelgabri/dotfiles' -- alcantara --without-pragmataproPragmata Pro remains enabled by default. The flag selects the
<host>-without-pragmatapro configuration without editing the checkout or
saving a preference. A later bootstrap or rebuild using the normal host includes
the font again and requires the archive. Other fonts remain installed.
Run the repo directly from GitHub:
nix --experimental-features 'nix-command flakes' run 'github:ahmedelgabri/dotfiles' -- <host>Important
The value after -- is the host name consumed by the bootstrap app, not a
flake reference like .#rocket.
The host is the flake attribute (e.g. rocket), independent of the machine's
current hostname β MDM-managed machines may be renamed at any time. If omitted,
it falls back to $(hostname -s), so on such machines pass the host explicitly.
Examples:
# macOS hosts
nix --experimental-features 'nix-command flakes' run 'github:ahmedelgabri/dotfiles' -- rocket
nix --experimental-features 'nix-command flakes' run 'github:ahmedelgabri/dotfiles' -- alcantara
# NixOS host
nix --experimental-features 'nix-command flakes' run 'github:ahmedelgabri/dotfiles' -- nixosOn the first run, the bootstrap app:
| Step | What happens |
|---|---|
| 1 | Uses the flake source it was launched from |
| 2 | Applies the selected host configuration |
| 3 | Clones the repo into ~/.dotfiles for later rebuilds |
| 4 | Reuses the local clone on subsequent runs |
| 5 | Runs doctor to list the setup still left to do |
Platform-specific behavior:
| Platform | Behavior |
|---|---|
macOS (aarch64-darwin) |
Installs Xcode Command Line Tools if missing, installs Rosetta when needed, moves the Nix installer's /etc/nix/nix.conf to nix.conf.before-nix-darwin (nix-darwin refuses to overwrite it), then switches the system using nix-darwin |
Linux (x86_64-linux) |
Expects nixos-rebuild to exist already, then switches the system using nixos-rebuild |
SSH keys, GPG keys and the pass store are set up by hand afterwards. Run
nix run ~/.dotfiles#doctor to see what is still missing; see
scripts for what it checks.
Once the repo is cloned locally, rebuild from ~/.dotfiles.
cd ~/.dotfiles
nixup # helper for `nix flake update`
nixsw # helper for `darwin-rebuild switch --flake .`Equivalent manual command:
darwin-rebuild switch --flake ~/.dotfiles#<host>cd ~/.dotfiles
nixup # optional helper for `nix flake update`
sudo nixos-rebuild switch --flake .#nixosI still use Homebrew mainly for GUI apps.
Nix and/or Home Manager support for GUI applications on macOS is still a bit
awkward, especially around symlinking into Applications/. Homebrew is still
managed declaratively here, but it is installed and controlled through
nix-homebrew rather than manually.
It also covers a few macOS-only dependencies that are awkward to source through
Nix alone, such as Tart, which backs the sb sandbox helper.
Each macOS host trusts its configured homebrew.taps through
nix-homebrew.trust.taps, without disabling Homebrew's trust checks. The AI
module declares openai/tools for Tart on both hosts; only rocket adds the
JetBrains and Docker taps. Removing a tap from the configuration does not revoke
its trust; use brew untrust <tap> to revoke it.
Not everything belongs in Git. This repo intentionally keeps some values outside of the tracked tree and loads them from a host-specific location.
Home Manager sets a per-host config directory under ${XDG_DATA_HOME}, and in
my shell that path is exposed as $HOST_CONFIGS.
In practice this usually means something like:
$HOST_CONFIGS = ~/.local/share/<host>
Useful files to create there:
| Path | Purpose | Example |
|---|---|---|
$HOST_CONFIGS/zshrc |
Machine-local shell setup; tokens live in the keychain (secret set <name>) and are exported as lookups, never literals |
export GITHUB_TOKEN="$(secret get github-token)" |
$HOST_CONFIGS/gitconfig |
Local Git settings that should not be committed, especially GPG signing details | [user] signingkey = |
$HOST_CONFIGS/hammerspoon/<host>.lua |
Machine-specific Hammerspoon extras loaded alongside the checked-in config in config/.hammerspoon/; <host> is the logical host name read from ~/.local/share/host-name, not the machine hostname |
Host-specific Lua config |
$HOST_CONFIGS/pi/extensions |
Machine-specific Pi extensions added to the generated Pi settings | Private extension |
$HOST_CONFIGS/pi/skills |
Machine-specific Pi skills added to the generated Pi settings | Private skill |
$HOST_CONFIGS/tmux/sessions/<name> |
Host-local mx session definitions (win over config/tmux/sessions/); see that dir's README |
Work session layouts |
If you want GitHub to show commits as Verified, the email on the commit, the email on GitHub, and the email attached to the public key all need to match.
Also remember to:
- generate SSH keys
- import or create your GPG keys
- upload the public GPG key to GitHub if you use commit signing
This repo includes agenix, but I do not use it as the source of
truth for important secrets.
The rough rule here is:
- truly sensitive credentials are generated or imported per machine and stored
in
pass agenixis used only for lower-risk private files that I still want to keep out of the public repo
Mail is one of the more opinionated parts of this setup.
The stack looks like this:
| Component | Role |
|---|---|
isync / mbsync |
Synchronizes mail |
aerc |
Mail client |
msmtp |
Handles outgoing mail |
pass |
Stores credentials |
The mail module currently has service defaults for:
| Service |
|---|
fastmail.com |
gmail.com |
cirrux.me |
It also supports two modes:
| Mode | Behavior |
|---|---|
local |
Mail is synced to local Maildir folders |
remote |
aerc connects directly to the remote service |
An account can list aliases, with fnmatch wildcards such as *@example.com for a catch-all domain; aerc replies from the alias a mail was sent to and leaves those addresses out of reply-all. Each account also takes a color (default blue). aerc renders unread mail addressed to that account's email domain in it, through a generated per-account styleset.
On a synced Gmail account A deletes the message from the local INBOX instead of moving it to an Archive folder: mbsync propagates that as removing the INBOX label, which is what archiving means on Gmail, and the message stays in All Mail. aerc hooks run mailsync-now after moves, flag changes, sends, and on quit so changes reach the server before the next timer tick.
When local accounts are enabled, synchronization runs automatically:
| Platform | Scheduler |
|---|---|
| macOS | launchd |
| NixOS | systemd --user timer |
The timer runs every 2 minutes in this setup.
Each account needs authentication for IMAP and SMTP. Some services also need extra credentials for JMAP or CardDAV.
Passwords are read from pass. For an account named Foo, the module
expects entries under the lowercased account name:
| Entry | Used for |
|---|---|
service/email/foo/password |
IMAP/SMTP password |
service/email/foo/source |
Fastmail/JMAP source access |
service/email/foo/outgoing |
Fastmail/JMAP outgoing access |
service/email/foo/contacts |
Fastmail/CardDAV access |
With two-factor authentication enabled, use an app-specific password where needed.
This repo also exports a few project templates from templates/.
Use them like this:
nix flake init --template 'github:ahmedelgabri/dotfiles#<template name>'or:
nix flake init --template 'github:ahmedelgabri/dotfiles#<template name>' ./my-project-t can be used as a shorthand for --template.
Available templates:
| Template | Description |
|---|---|
default |
Empty flake template |
node |
Simple Node / JS / TS template using pnpm |
deno |
Simple Deno template |
bun |
Simple Bun template |
python |
Simple Python project template |
python-script |
Simple one-off Python script template using uv script mode |
go |
Simple Go template |
rust |
Simple Rust template |
The plain-dark and plain-light terminal themes are the color source of
truth. Ghostty and Kitty follow the OS appearance, while terminal applications
consume named ANSI slots instead of embedding independent truecolor or xterm-256
palettes. This keeps bat, delta, Jujutsu, LS_COLORS/eza, fzf, Yazi, aerc,
tig, tmux, and the Zsh prompt synchronized automatically; Neovim's custom
plain theme uses the same accents with extra
structural shades needed by an editor. bat and delta render syntax through a
custom plain.tmTheme that mirrors the
Neovim theme's restraint (gray italic comments, blue strings and constants,
default foreground for everything else) via bat's ANSI color encoding; the bat
cache is rebuilt automatically on every Home Manager activation, so a rebuild
picks up theme changes (or run bat cache --build by hand for an instant
refresh).
File listing colors come from one checked-in file,
config/zsh.d/ls_colors, which user-shell.nix
splits at build time: lines prefixed eza: become EZA_COLORS (eza-only UI
keys, plus reset to disable eza's built-in filetype styles) and the rest are
joined into LS_COLORS, so eza, ls, fd, and zsh completions color files
identically. The file header documents the SGR syntax and grouping; adding a
color for a new extension is a one-line edit.
The dark palette is a restrained
Tomorrow Night
derivative. The light palette uses the corresponding Tomorrow family hues with
darker green, yellow, and cyan values so colored text remains readable on
#fafafa. ANSI bright black is the shared muted color, and red/green/yellow
retain error/success/warning semantics everywhere.
macOS appearance is the only switch. Ghostty selects
theme = dark:plain-dark,light:plain-light natively, Kitty resolves its
*-theme.auto.conf symlinks, and Neovim polls AppleInterfaceStyle on startup
and FocusGained to set background, which reloads
plain.lua. herdr switches between its
[theme.custom.dark] and [theme.custom.light] tables when the terminal
reports the change. Nothing else listens for appearance changes: every other
tool only emits ANSI indices, so the terminal re-renders even already-printed
output the moment its palette flips.
- Slot 0 (
black) is subtle structure: tmux pane borders andβ¦separators, aerc scrollbar and selection strips. The dark value is deliberately lifted to#303030because a true near-black is invisible against the#111111background β treat this slot as the chrome-visibility knob. - Slot 8 (
bright black) is dim text: status line text, comments, secondary info. - Red, green, and yellow keep error/success/warning semantics everywhere; blue marks constants, strings, and links. Anything that is not a signal stays on the default foreground.
- The
delta_wrapper must not pass--diff-so-fancy(or any per-line foreground style): delta disables syntax highlighting on lines whose style sets its own foreground, which hides the plain theme everywhere except context lines. Delta's default tinted add/remove backgrounds are chosen by its own light/dark detection.
The palette is intentionally duplicated in a few places that cannot reference each other. When one changes, change them all:
| What | Where |
|---|---|
| Full palettes (slots 0-15 plus the diff-tint slots 16-18 holding Neovim's DiffAdd/Delete/Change backgrounds) | config/ghostty/themes/plain-dark and plain-light, config/kitty/themes/plain-dark.conf and plain-light.conf, and terminal_palettes (slots 0-15 only; :terminal cannot remap higher slots) in plain.lua |
| Editor accents | the themes table in plain.lua mirrors the terminal accent hexes, plus editor-only structural shades and diff/conflict backgrounds |
| Syntax restraint rules | the treesitter groups in plain.lua and the scopes in plain.tmTheme express the same rules for Neovim and bat/delta respectively; the bat cache rebuild happens automatically at activation (or run bat cache --build for an instant refresh) |
| herdr surfaces | config/herdr/config.toml's [theme.custom.dark] and [theme.custom.light] copy plain.lua's norm_subtle, cursor_line, and bg_subtle shades and the background, because the terminal palette has no slots for them |
| Comment markers (TODO/FIXME/β¦) | painted by mini.hipatterns extmarks configured in plugin/mini.lua, styled via MiniHipatterns* groups that link to @comment.* in plain.lua β marker styling must go through those groups, not treesitter alone |
The flake also exposes a few outputs that are useful when editing the dotfiles repo itself:
| Command | What it does |
|---|---|
nix develop |
Provides the default development shell, including Python, Ruff, and ty |
nix fmt |
Formats the Nix code with nixfmt-rs |
nix develop .#go |
Opens the Go-focused dev shell |
nix run .#lint |
Runs every repo check CI runs: formatting, dead-code, type, shell, spelling, workflow, Go, and host evaluation checks, plus zizmor's online audits |
nix run .#test-bootstrap |
Bootstraps a host in a disposable macOS VM; see scripts |
All Python files, including the interactive startup script, use the strict Ruff
and ty settings in pyproject.toml. See
Python checks and tests for local commands
and the documented rule exceptions.
