These dotfiles are the single source of truth for one developer's environment across five machines — a MacBook Pro, a Mac mini, a Raspberry Pi Ansible controller, a local server, and a Hetzner VPS. chezmoi renders the right configuration for each host from one templated repo, so a single apply brings any machine to a known state.
A single chezmoi apply — or dots update — brings any host to a known state: it pulls the source, renders this machine's slice, and writes it into place. The same round trip runs on all five machines.
Scope of this wiki These pages document architecture, not credentials. SSH host topology, RustDesk peer records, and the /etc/hosts payload are excluded — they're operational data, not documentation. The source repository is private for the same reason (see Secrets & safety).
Getting started
Getting started
Bring a machine online, then meet the fleet it joins. Start with the guided installer, then see how one repo targets five very different hosts.
Two paths, by machine type: a guided installer for Macs, and Ansible for the Linux fleet. Because the repo is private, a bare machine must authenticate to GitHub before it can clone — the installer handles that for you.
One command on a clean Mac. It installs Homebrew, installs chezmoi and gh, signs you into GitHub through the browser, then applies the dotfiles:
bash
$bash<(curl-fsSLhttps://cdn.levine.io/install.sh)
cdn.levine.io/install.sh is a Cloudflare redirect to a public gist (no secrets), fetched anonymously; the installer then authenticates to reach the private dotfiles. Add -y to accept every prompt automatically. The command it ultimately runs is chezmoi init --apply davelevineinstall.sh155–168.
Once dotfiles apply, chezmoi runs the bootstrap automatically:
Templated files reference groqApiKey / ntfyAccessToken; until you create a local chezmoi.toml they render as empty strings. To fill them in (values from Bitwarden Secrets Manager) and re-apply:
Sign into the App Store first Mac App Store apps (mas entries) require being signed in, or brew bundle skips them with a warning. Sign in, then run chezmoi apply again.
Linux — Ansible & fallback
Managed Linux hosts are provisioned by an Ansible dotfiles role, which installs chezmoi, templates secrets from Bitwarden, and runs chezmoi apply:
ansible-control
$./ansible-bw.shplaybooks/site.yml--tagsdotfiles# all hosts$./ansible-bw.shplaybooks/control-node.yml--tagsdotfiles
For one-off boxes outside the fleet, the same guided installer runs on Linux too — it installs chezmoi via get.chezmoi.io, authenticates with gh or a Personal Access Token over HTTPS, then applies. Unlike the Ansible role it does not template secrets, so they stay empty until you create chezmoi.tomlinstall.sh105–153.
Setup · 02
Machines
Five hosts, one repo, each bootstrapped a little differently. The two Macs share the same config — the darwin gate covers both — and sync to each other through GitHub. The Linux hosts each render their own slice.
.chezmoiignore.tmpl evaluates on each machine and lists the paths chezmoi should skip there. Because it's a template gated on {{ .chezmoi.hostname }} and {{ .chezmoi.os }}, one repo yields a full Mac config and a lean headless-server config with no branching .chezmoiignore.tmpl5–34.
What each host receives
Both Macs
Everything: shell, Homebrew, macOS settings, LaunchAgents, apps, and the full sync tooling. They auto-sync to each other.
Linux hosts
Shell config only — .zshrc, .gitconfig, plus a few host-specific extras. No Mac setup scripts run, and updates are manual (chezmoi update).
How it works
How it works
The model underneath the dotfiles: how chezmoi renders one repository into each host, and the ordered scripts it runs during apply.
chezmoi keeps one source repo and renders it into your home directory, selecting and templating files per host. There's no separate manifest for where files go or what permissions they carry — that's encoded in each file's name. Throughout the repo, dots is the shell alias for chezmoi.
Learn the prefixes and you can read the repository directly — the filename tells you exactly what chezmoi will produce. So private_dot_ssh/config deploys as an owner-only ~/.ssh/config.
Files ending in .tmpl render through Go's template engine at apply time. This repo uses it for three jobs: per-host differences (a Git safe.directory that exists only on xenlab), per-OS branches (LFS filters only on macOS), and secret injection (an API key read from local config). Values come from two layers — non-sensitive defaults in .chezmoidata.yaml, and machine-specific overrides in an untracked ~/.config/chezmoi/chezmoi.tomldot_gitconfig.tmpl.
The apply lifecycle
During chezmoi apply, run_ scripts execute in filename order, interleaved with file changes. A run_once_ script runs a single time per machine; a run_onchange_ script re-runs whenever its rendered content changes — triggered here by embedding a sha256 of the file it depends on, so editing that file changes the script and forces a re-run. _before_/_after_ place a script relative to file application; the number orders scripts within a phase. See Lifecycle scripts for the sequence.
▲
private_ is not encryption private_ only restricts file permissions to the owner. The contents are committed to Git in plain text. Confidential material is kept out of the tree entirely (templated from local config), not protected by this prefix — see Secrets & safety.
Foundations · 04
Lifecycle scripts
chezmoi runs shell scripts at defined points during apply. This repo has ten, numbered to fix their order. One runs once per machine to bootstrap Homebrew before any files land; the rest are on-change hooks that re-run only when their input changes, so a no-op apply stays silent. All are gated to macOS.
Runs brew bundle install --global (one retry), then uninstalls leaves dropped from the Brewfile — capped at 8 removals for safety — and verifies with brew bundle check. Hashes dot_Brewfile.
Installs the pinned extension list (Claude Code, Dracula, Prettier, Remote-SSH, Tailscale…) for any not already present. Triggered by editing the inline list.
Runs macos-defaults.sh to apply System Settings and keyboard shortcuts. Runs after 20 so Dock app tiles already exist. Hashes the script + hotkeys plist.
Upserts the self-hosted relay/rendezvous server and public key into RustDesk2.toml — network keys only, device identity untouched. Rendered from .chezmoidata.yaml.
Writes a Chromium enterprise policy plist to /Library/Managed Preferences/ (via sudo) that turns off Arc's built-in async DNS client, so it defers to the system resolver and rides through Tailscale MagicDNS changes cleanly instead of desyncing.
Merges a Dracula color theme into ForkLift's preferences (via an embedded Python step), preserving any other themes. Triggered by editing the inline colors.
Three things you'll actually do: change a file, push it, pull it elsewhere. On the Macs, dotsync automates all of this — these commands are for when you want something to happen right now, and they're the whole workflow on the Linux hosts.
Edit → commit → push. Always use dots edit — it opens the file's source (templates included), so the change sticks:
zsh
$dotsedit~/.zshrc# edits the source; applies locally on save$dotscd# enter the source repo$gitadd-A$gitcommit-m"update zshrc"$gitpush$exit
▲
Hand-edits don't stick Editing ~/.zshrc directly is lost on the next apply — chezmoi rebuilds it from the template. dots re-add can't recover it either; it skips templated files. It only helps for plain, non-templated files like VS Code's settings.json.
Receiving changes
On another machine, pull and apply in one step — equivalent to git pull + chezmoi apply:
zsh
$dotsupdate
Other useful commands
zsh
$dotsdiff# preview what would change before applying$dotsapply# apply source state without pulling$dotsre-add# save a hand-edited plain file back to source
Everyday · 06
Sync workflow
dotsync is the one command to remember. On its own it runs the full round trip — pull + apply, then capture this Mac's drift and push — so you never string the low-level verbs together by hand. It self-locks, and a LaunchAgent runs it at login and every ~4 hours.
dotsync macosRe-apply System Settings via defaults (wraps macos-defaults.sh).
dotsync helpList all subcommands.
What a run does
Lock — an atomic mkdir lock means a scheduled run and a manual one can't collide; a second run just exits.
Self-heal the branch — if the source is on a stale feature branch already merged into main, switch back to main and delete it.
Pull + apply — chezmoi update (this also fires any run_ scripts whose inputs changed).
Guard — if not on main, stop before capturing, so mid-work branches are never disturbed.
Capture & push — brew-bundle-sync.sh for packages, a defaults export for keyboard shortcuts, and rustdesk-peers-capture.sh for peers; commit and push only if something changed.
GitHub is the only hub — there's no direct Mac-to-Mac sync. Each machine pushes up and pulls down. The chezmoi verbs dotsync is built from, when you want a single direction:
Render source into $HOME and run changed run_ scripts. No network.
dots update
GitHub → home
git pull then apply. Inbound only — never pushes.
dots re-add
home → source
Copy a hand-edited plain file back into the source (skips templates).
Automatic syncing
A launchd agent, io.levine.dotsync (installed by lifecycle script 30), runs dotsync at login and every four hours (StartInterval 14400) at low I/O priority, logging to ~/.local/state/dotsync.logio.levine.dotsync.plist.tmpl14–17.
↻
Removals propagate Remove a package on one Mac and the next apply on the other uninstalls it there too (leaves-only, capped at 8) — so the Macs converge on removals, not just the union of installs.
Everyday · 07
Packages
~/.Brewfile is a Homebrew Bundle manifest naming every formula, cask, and Mac App Store title the Macs carry — 127 entries across 2 taps. It's generated, not written: the sync dumps current state into it and pushes, so both Macs reproduce from the manifest alone.
Each sync refreshes the Brewfile in two steps. First, adopt: auto-adopt-apps.sh scans /Applications for cask-available apps that aren't managed yet and match the cask version exactly, then adopts each with brew install --cask --adopt (never --force). Each app is copied aside first and restored if a failed adopt would remove it. Then dump: brew bundle dump snapshots Homebrew state and chezmoi re-add folds it back into the repo, committing only if the Brewfile actually changed.
Adopting apps by hand
To bring a newly hand-installed app under management, run the helper in a real Terminal:
Ghostty
$~/.local/bin/adopt-apps.sh# adopt apps matching the cask version$~/.local/bin/adopt-apps.sh--force# upgrade-and-adopt stragglers
▲
Use a real Terminal --force does a full remove-then-reinstall; without Full Disk Access an error mid-way can leave an app deleted. Quit the apps first, and never run with sudo — Homebrew refuses to run as root. Logs: ~/.local/state/auto-adopt.log.
Manual installs (no cask)
These have no Homebrew cask and must be reinstalled from source on a fresh machine: Aerial Companion, Caldera Amp, CompressX, DeskRest, Family Tree Maker 2019, Feishin-plex, Folder Tidy, Greendot, HiFidelity, Kigo Movie One, SD Card Formatter.
System
System
Deeper configuration: macOS System Settings and hardening, the shell environment and prompt, and the helper scripts that tie it all together.
macOS state is reproduced as code — System Settings via defaults, a conservative security pass, and a few app snapshots. What restores automatically and what stays manual is spelled out below.
macos-defaults.sh writes a hand-curated snapshot of preferences that differ from stock — Dark mode, natural scrolling off, always-show scroll bars, 24-hour clock, Finder in list view with folders first, a left-side auto-hiding Dock with a fixed app order and a screen-saver hot corner, trackpad tap-to-click, custom pointer speed, and ForkLift as the default file viewer. It's applied automatically on apply via lifecycle script 50; re-assert it by hand with:
zsh
$dotsyncmacos
Security hardening
macos-security.sh applies a conservative, reversible set of privacy settings: require password immediately after sleep, the application firewall on (stealth mode deliberately off so LAN tools keep working), the guest account disabled, and Touch ID for sudo via a pam_tid drop-in that survives OS updates. It deliberately leaves all remote access untouched — SSH, Screen Sharing, RustDesk, Tailscale — to avoid locking yourself out. FileVault and a firmware password are left for you to enable manually.
zsh
$dotsyncharden# prompts for sudo (firewall + guest account)
What restores, what doesn't
Casks and Mac App Store apps come back from the Brewfile automatically. GUI-app preference plists are mostly not managed — they're binary, churn on every launch, and don't restore reliably through cfprefsd caching. The exception is iStat Menus, whose license and menu-bar layout are snapshotted and re-imported on apply (lifecycle script 60). Keyboard shortcuts are captured too, exported as symbolichotkeys during each dotsync.
System · 09
Linux hosts
Three Linux machines — ansible (a Raspberry Pi controller), xenlab (a local server), and ubuntu-8gb-nbg1-1 (a Hetzner VPS) — share the same repo as the Macs, each rendering its own slice. They're provisioned by Ansible, not the guided installer, and receive shell configuration only — none of the macOS setup scripts run.
All three run the Ansible dotfiles role; they differ only in role and how the deploy authenticates to GitHub.
Host
Role
Deploy auth
ansible
ansible-control · Raspberry Pi
Ansible role over SSH
xenlab
Local server
Ansible role · HTTPS + PAT
ubuntu-8gb-nbg1-1
Hetzner VPS (unifiadmin)
Ansible role · HTTPS + PAT
Provisioned by Ansible
The Linux fleet is set up from an Ansible control node, not the Mac installer. The dotfiles role installs chezmoi, initializes the repo, templates secrets where needed, and runs chezmoi apply — this is the source of truth for the managed hosts:
control node
$./ansible-bw.shplaybooks/site.yml--tagsdotfiles# all hosts$./ansible-bw.shplaybooks/control-node.yml--tagsdotfiles# just ansible-control
◆
Secrets fill in automatically On the Macs you write chezmoi.toml by hand. On the Linux hosts the Ansible role templates it from Bitwarden Secrets Manager during the deploy — groqApiKey and ntfyAccessToken land without manual steps (see Secrets & safety).
What each host gets
Linux hosts render shell configuration only — the same source files as the Macs, minus everything macOS-specific. The ten lifecycle scripts are all gated to macOS, so none run here.
Shared with the Macs
.zshrc and .gitconfig, rendered from the same templates against each host's own data.
Host-specific extras
On ansible-control, .bashrc adds NVM and an ansible-playbook locale alias dot_bashrc
Not applied
No Homebrew, no brew bundle, no macOS defaults, LaunchAgent, iStat, RustDesk, or theme scripts.
Daily workflow
Nothing is automated the way dotsync drives the Macs — there's no LaunchAgent. A host changes only when you run the commands by hand:
chezmoi updatePull the latest from GitHub and apply it. Run it on the host to get changes made elsewhere.
dots edit ~/.zshrcEdit the master template, then commit + push. Editing the rendered file directly is lost on the next apply.
Standalone fallback
The guided installer also runs on Linux for one-off boxes outside the Ansible fleet — it installs chezmoi via get.chezmoi.io, ensures git, and authenticates to GitHub (gh if present, otherwise a PAT over HTTPS). Use it only when Ansible doesn't manage the machine.
▲
No secrets on the fallback path Unlike the Ansible role, the standalone installer does not template secrets from Bitwarden — groqApiKey and ntfyAccessToken stay empty until you fill them in locally.
System · 10
Shell & configs
The actual environment the dotfiles build — shell, prompt, Git, terminal, and window management. The Dracula Pro palette this wiki wears is the same one running in Ghostty, ncspot, and the shell prompt.
Cached completions, EDITOR=nano, and zsh-autosuggestions + zsh-syntax-highlighting from Homebrew. Notable aliases dot_zshrc.tmpl15–37:
Alias
Expands to
dots
chezmoi — the primary management command everywhere
ls
ls -la
lst
eza --tree
info
macchina (system info)
jdev
A dev server launch with secrets injected from templates
snow / train
snowmachine / sl | lolcat — for fun
Zsh (Linux hosts)
Oh My Zsh with the robbyrussell theme. Per-host blocks add: on ansible, an ssh-agent + key load, NVM with .nvmrc auto-switching, Bitwarden secrets, and cd ~/homelab-iac on start; on xenlab, smartctl disk-health aliases, an ollama-in-Docker alias, and pnpm on PATH.
Prompt (Oh My Posh "craver")
A two-line boxed prompt in Dracula Pro colors: OS icon, battery, time, an agnoster_full path, Git status with stash/upstream, .NET version, exit status, and command execution time (red past 1 s). Initialized from ~/Documents/craver.omp.jsoncraver.omp.json.
Git & tools
Git identity Dave <dave@davelevine.io>, pull.rebase = true, LFS on macOS, and per-host credential.helper / safe.directory branches. Other configs: Ghostty (the dracula-pro theme), ncspot (Dracula theme), gh (git_protocol: https, alias co → pr checkout), code-server (bound to 0.0.0.0:8080, auth deferred to the network), and AWS CLI profiles (no secrets).
Window management (Hammerspoon)
An Amethyst-style tiling manager with four layouts — 3column (default), tall, tall-right, fullscreen — plus a browser/utility float allowlist. Key bindings: ⇧⌘Space cycles layouts, ⌥⇧A/S/D/F jump to a layout, ⌥⇧T toggles float, ⌥⇧R retiles, and ⌥⇧J/K move focus. A separate menu-bar speed test (⌃⌥⌘S) runs speedtest-go with a live gauge dot_hammerspoon/init.lua604–648.
System · 11
Scripts & tooling
The managed helpers that live in ~/.local/bin. Some are the machinery behind dotsync; others are standalone tools you run by hand. All target macOS (several are written for system bash 3.2).
The installer plus chezmoi apply restore every dotfile, package, cask, and App Store app. What's left is the manual layer a fresh Mac still needs — ordered by what unblocks you first.
The installer and chezmoi apply handle the top of this sequence automatically; the checklist below is the manual layer that remains, ordered by what unblocks you first.
The checklist
Secrets — recreate chezmoi.toml with the groqApiKey / ntfyAccessToken values from Bitwarden, then chezmoi apply.
SSH keys — restore ~/.ssh/ansible, id_rsa_mac, and id_rsa_hetzner from backup or Bitwarden. The keys are never committed; without them you can't reach other hosts or push to the repo.
App Store / iCloud — sign in beforebrew bundle, or every mas app is skipped.
System Settings — automatic on apply via macos-defaults.sh; run dotsync harden for the security pass (prompts for sudo).
Licenses — re-enter for CleanMyMac, Bartender, ForkLift, TablePlus, Carbon Copy Cloner, HazeOver, Lunar, eM Client, Paragon NTFS, Xnapper. (iStat Menus is automatic.)
Permissions — grant Accessibility, Screen Recording, Input Monitoring, etc. when each app first prompts.
Manual apps — reinstall the no-cask apps listed under Packages.
Connection data lives elsewhere App connection lists (Windows App / RDP, the RustDesk address book, Arc) are secret-bearing and churny, so they stay out of the repo. The Carbon Copy Cloner backup keeps them instead — restore by browsing the backup in Finder and copying the paths back.
Recovery · 13
Secrets & safety
No secret value is committed to this repository. Templated files reference variables — an API key, a notification token — but their definitions in .chezmoidata.yaml are empty strings. Real values live only in an untracked local config, injected at apply time.
A fresh machine applies cleanly with secret variables blank until you supply them. Values come from ~/.config/chezmoi/chezmoi.toml, whose [data] section overrides the empty defaults; on the Ansible-managed Linux hosts, that file is templated from Bitwarden Secrets Manager automatically .chezmoidata.yaml4–5.
Layer
Tracked in Git?
Holds
.chezmoidata.yaml
Yes
Empty defaults & non-secret values
~/.config/chezmoi/chezmoi.toml
No — local only
Real secret values & host specifics
Rendered dotfiles
N/A — generated
Secrets injected at apply time
What's intentionally omitted
Beyond secrets, some tracked files are operational data rather than documentation and are kept out of this wiki: the SSH client config (host topology), the RustDesk address book, and the reference entries in the /etc/hosts block. Even the RustDesk peers that are versioned are sanitised — rustdesk-peers-capture.sh strips passwords and volatile fields before they ever reach the repo. This is also why the repository itself stays private: publishing it would expose that map even though no credentials sit in it.
✓
Verified clean A scan of the full commit history — every blob across all 166 commits — found no API keys, tokens, or private-key material. The secret placeholders have been empty in every revision.