Managed with rcm (macOS) and NixOS + home-manager (Linux).
Most macOS packages are in the Brewfile. NixOS packages are declared in nixos/modules/home.nix and nixos/modules/home-desktop.nix.
- Shell: fish + starship prompt
- Terminal: Ghostty (Everblush theme)
- Editor: Zed (primary), Neovim (lazy.nvim), vim (barebones fallback)
- Git: difftastic (structural diffs), SSH signing, git-lfs
- NixOS: niri compositor, handlr-regex URL dispatching
- macOS: Caps Lock → Escape (hidutil), Touch ID sudo, Dock/Finder defaults
- 1Password (not installed via Brew for security reasons)
- BetterDisplay
- OrbStack
# Install rcm and core tools
brew install rcm fish git
brew bundle
# Set fish as default shell
sudo sh -c 'echo $(which fish) >> /etc/shells'
chsh -s $(which fish)
# Install fish plugins
fish -c 'curl -sL https://git.io/fisher | source && fisher install jorgebucaran/fisher && fisher install jorgebucaran/hydro'
# Clone and link dotfiles
git clone git@github.com:aroman/dotfiles.git .dotfiles
rcup
# Build bat theme cache
bat cache --build
# Directories
mkdir -p ~/Projects
mkdir -p ~/Pictures/Screenshots
defaults write com.apple.screencapture location ~/Pictures/Screenshots
# Keyboard
defaults write -g ApplePressAndHoldEnabled -bool false
defaults write -g InitialKeyRepeat -int 15
defaults write -g KeyRepeat -int 2
# Mail
defaults write com.apple.mail AddressesIncludeNameOnPasteboard -bool false
# Appearance
defaults write -g AppleReduceDesktopTinting -bool yes
# Finder
defaults write NSGlobalDomain AppleShowAllExtensions -bool true
chflags nohidden ~/Library
/usr/libexec/PlistBuddy -c "Set :DesktopViewSettings:IconViewSettings:arrangeBy grid" ~/Library/Preferences/com.apple.finder.plist
/usr/libexec/PlistBuddy -c "Set :FK_StandardViewSettings:IconViewSettings:arrangeBy grid" ~/Library/Preferences/com.apple.finder.plist
/usr/libexec/PlistBuddy -c "Set :StandardViewSettings:IconViewSettings:arrangeBy grid" ~/Library/Preferences/com.apple.finder.plist
# Dock
defaults write com.apple.dock autohide -bool true
defaults write com.apple.dock autohide-delay -float 0
defaults write com.apple.dock persistent-apps -array
defaults write com.apple.dock show-recents -bool false
defaults write com.apple.dock ResetLaunchPad -bool true
defaults write com.apple.dock mineffect -string scale
defaults write com.apple.Dock showhidden -bool true
# Trackpad
defaults write com.apple.AppleMultitouchTrackpad Clicking -bool true
# Velja (disable App Nap so link routing stays fast)
defaults write com.sindresorhus.Velja NSAppSleepDisabled -bool true
# Touch ID for sudo (survives macOS upgrades)
# pam-reattach is needed for tmux sessions; ignore_ssh falls back to password for SSH
# See: https://sixcolors.com/post/2023/08/in-macos-sonoma-touch-id-for-sudo-can-survive-updates/
brew install pam-reattach
printf 'auth optional /opt/homebrew/lib/pam/pam_reattach.so ignore_ssh\nauth sufficient pam_tid.so\n' | sudo tee /etc/pam.d/sudo_local > /dev/nullReboot to apply everything: sudo shutdown -r now
On the new machine (fresh NixOS with nothing installed):
# Get git in a temporary shell
nix-shell -p git
# Clone dotfiles (HTTPS — no SSH keys yet)
mkdir -p ~/Projects
git clone https://github.com/aroman/dotfiles.git ~/Projects/dotfilesThen, from another machine that already has the repo:
# 1. Enable SSH on the fresh install so you can access it remotely
# On the new machine, edit the default NixOS config:
# sudo nano /etc/nixos/configuration.nix
# Add: services.openssh.enable = true;
# Then: sudo nixos-rebuild switch
# 2. Pull the hardware config directly
mkdir -p nixos/hosts/<hostname>
# Use the machine's IP (run `ip addr` on it to find it — mDNS likely won't work yet)
scp <user>@<ip>:/etc/nixos/hardware-configuration.nix nixos/hosts/<hostname>/
# 3. Create default.nix and home.nix (see existing hosts for reference)
# Add the host to nixos/flake.nix
# 4. Push
git add nixos/hosts/<hostname> nixos/flake.nix
git commit -m "Add <hostname> NixOS host config"
git pushFinally, on the new machine:
cd ~/Projects/dotfiles
git pull
sudo nixos-rebuild switch --flake ~/Projects/dotfiles/nixos#<hostname>After the first rebuild, SSH, git, and everything else from common.nix will
be available. Set up SSH keys and switch the remote:
# Generate a machine-specific key (name it after the hostname)
ssh-keygen -t ed25519 -C "<hostname>" -f ~/.ssh/<hostname>
# Add to GitHub (auth via browser)
gh auth login
gh ssh-key add ~/.ssh/<hostname>.pub -t "<hostname>"
# Create ~/.ssh/config.local to tell SSH which key to use
echo 'Host github.com
IdentityFile ~/.ssh/<hostname>' > ~/.ssh/config.local
chmod 600 ~/.ssh/config.local
# Switch remote to SSH
git remote set-url origin git@github.com:aroman/dotfiles.git
# Headless hosts: load the key into the agent once per boot
ssh-add ~/.ssh/<hostname>
# Symlink dotfiles managed by rcm (scripts in ~/.local/bin, etc.)
rcup -K
# Build bat theme cache (for Everblush theme)
bat cache --build
# Set up git commit signing (key matches the SSH key)
echo '[user]
signingkey = ~/.ssh/<hostname>.pub' > ~/.gitconfig.local
# Add API keys for tools (ai-commit, etc.)
echo 'set -gx GEMINI_API_KEY "your-key-here"' >> ~/.config/fish/conf.d/secrets.fishOn desktops with GNOME Keyring, GCR discovers keys with matching .pub files.
Use ssh-add -T ~/.ssh/<hostname>.pub to trigger its unlock dialog, enter the
passphrase, and select Automatically unlock this key whenever I'm logged in.
The passphrase is then saved in the encrypted login keyring and reused when
the key signs a request. Plain ssh-add ~/.ssh/<hostname> only loads the key
into memory; it does not save its passphrase in the keyring.
For automatic keyring unlock, use password login at the greeter and keep the Login keyring password equal to the account password. If they differ, change the Login keyring password in Seahorse (Passwords and Keys). Fingerprint authentication cannot supply the password needed to decrypt it.
From your main machine, set up passwordless SSH:
ssh-copy-id <hostname>git clone git@github.com:aroman/dotfiles.git ~/Projects/dotfiles
nh os switch --show-activation-logs ~/Projects/dotfiles/nixos && rcup -KHosts are defined in nixos/hosts/. Rebuild function: bake. It forwards
arguments to nh os switch, shows activation logs, and runs rcup -K only
after a successful switch. NH activates the system before making it the boot
default, so a failed activation leaves the existing boot default in place.
Both NixOS machines back up /home/aroman daily to Backblaze B2 using restic. Backup health is monitored by Healthchecks.io — it alerts if a daily backup doesn't run.
Architecture: restic encrypts and deduplicates client-side, then uploads to B2. Both machines share one B2 bucket and one restic repo. Restic tags snapshots by hostname automatically.
B2 setup:
- Bucket:
aroman-backups(US West, private, no server-side encryption, no object lock) - Each machine has its own B2 application key scoped to only this bucket
- Keys are named
restic-wizardtowerandrestic-moonbinder
Retention: 7 daily, 4 weekly, 6 monthly snapshots.
Secrets (per-machine, not tracked in repo):
| File | Contents | Shared? |
|---|---|---|
/etc/restic/password |
Restic repo encryption password | Same on all machines |
/etc/restic/b2-env |
B2_ACCOUNT_ID and B2_ACCOUNT_KEY |
Different per machine |
/etc/restic/healthchecks-url |
Healthchecks.io ping URL | Different per machine |
All stored in 1Password. The directory and files should be chmod 700/600, owned by root.
Setting up a new machine:
sudo mkdir -p /etc/restic && sudo chmod 700 /etc/restic
# Use the SAME restic password as other machines (from 1Password)
sudo nano /etc/restic/password
# Create a new B2 application key scoped to aroman-backups bucket
# (B2 Console → App Keys → Add a New Application Key)
sudo nano /etc/restic/b2-env
# B2_ACCOUNT_ID=<keyID>
# B2_ACCOUNT_KEY=<applicationKey>
# Create a new Healthchecks.io check (Period: 1 day, Grace: 2 hours)
sudo nano /etc/restic/healthchecks-url
# https://hc-ping.com/<uuid>
sudo chmod 600 /etc/restic/password /etc/restic/b2-env /etc/restic/healthchecks-urlManual operations:
# Trigger a backup now
sudo systemctl start restic-backups-b2.service
# Watch backup progress
sudo journalctl -fu restic-backups-b2.service
# List snapshots
sudo bash -c 'set -a && source /etc/restic/b2-env && restic -r b2:aroman-backups --password-file /etc/restic/password snapshots'
# Restore a file
sudo bash -c 'set -a && source /etc/restic/b2-env && restic -r b2:aroman-backups --password-file /etc/restic/password restore latest --target /tmp/restic-test --include /home/aroman/path/to/file'A domain-based URL routing system that sends links to the right app and Chrome profile automatically. Think Velja for Linux.
any app
-> xdg-open
-> handlr-regex (registered as default x-scheme-handler/https)
-> regex match on URL domain
-> figma.com/* : figma-open (CDP script)
-> youtube.com/* : Chrome (Personal profile)
-> * : Chrome (magiccircle.studio profile)
Key files:
nixos/modules/home-desktop.nix-- handlr-regex package,.desktopentries, TOML config, MIME associationslocal/bin/figma-open-- Figma URL handler script~/.config/handlr/handlr.toml-- generated regex routing rules
handlr-regex is a Rust-based
xdg-open replacement that matches URLs against regex rules. A .desktop entry
registers it as the default handler for x-scheme-handler/http and
x-scheme-handler/https. When any app calls xdg-open with an http(s) URL,
handlr matches it against the rules in handlr.toml and dispatches to the
appropriate command.
Chrome profiles are selected via --profile-directory. Profile names map to
directory names under ~/.config/google-chrome/ (e.g. Default, Profile 1).
figma-open goes beyond simple URL dispatching. Figma runs as a Chrome --app
window with --remote-debugging-port=9222, exposing the
Chrome DevTools Protocol.
The script uses CDP over WebSocket (via websocat) to control the running Figma
instance:
Same-file navigation (e.g. jumping to a different artboard):
When the target URL has the same base path as the currently open file but a
different node-id query parameter, the script avoids a full page reload.
Instead, it extracts the node ID and calls Figma's Plugin API directly via
Runtime.evaluate:
const node = await figma.getNodeByIdAsync('12438:18221');
figma.currentPage.selection = [node];
figma.viewport.scrollAndZoomIntoView([node]);This is the same internal API that Figma plugins use -- it selects the node and scrolls the viewport to it instantly, with no reload.
Different-file navigation:
Uses CDP Page.navigate for a standard full navigation to the new file.
Figma not running:
Falls back to launching Chrome with the full --app, --user-data-dir,
--user-agent, and --remote-debugging-port flags.
Window focus:
After navigation, the script focuses the Figma window via
niri's IPC (niri msg action focus-window),
matching on the app_id prefix chrome-www.figma.com.
Edit the handlr.toml section in nixos/modules/home-desktop.nix. Rules are matched top-down,
first match wins:
# Route to a specific Chrome profile
[[handlers]]
exec = "google-chrome-stable --profile-directory=\"Profile 1\" %u"
regexes = ['https?://(www\.)?example\.com(/.*)?']Then rebuild: bake