For years my dotfiles — the little config files that tell my shell, my editor, and my terminal how to behave — lived in a git repo, and I deployed them the dumb way. I’d edit something, push it, then SSH into each machine and run a sync script by hand. Five machines: dev, srv, svc, gpu, mac. Five little SSH sessions, five chances to forget one.
The forgetting is what got me. I’d fix a shell alias on my laptop, and three weeks later hit the same broken alias on the GPU box because I never synced it there. The repo said one thing; the machines said five different things. That gap — where your source of truth and your actual boxes disagree — is called drift, and once you have it you stop trusting any of them.
So I flipped the direction. Instead of me pushing config out to machines from one command center, each machine now watches the repo and pulls the config in to itself.
Here’s the trick. Every machine hosts its own GitHub Actions self-hosted runner — think of it as a tiny always-on worker that GitHub can hand jobs to — labeled with that machine’s hostname. When I push to main, a workflow fires, and every runner independently does the same three things: fetch the latest, hard-reset itself to match origin/main exactly, and re-stow the files into my home directory. (stow is the thing that symlinks the repo’s files into place.)
The word doing the heavy lifting is hard. git reset --hard doesn’t merge or negotiate — it makes the machine’s tracked files identical to the repo, full stop. That’s why drift can’t survive. A runner is never allowed to have an opinion.
The workflow and the gotchas give me the detail
The deploy step each runner runs:
git fetch origin main
git reset --hard origin/main
stow -R . -t "$HOME"Key config decisions:
- Concurrency group with
cancel-in-progress: false. Two quick pushes shouldn’t race. This serializes them so the second waits for the first instead of clobbering it mid-stow. - Asleep laptops queue, they don’t fail. The
macrunner is offline half the day. GitHub holds its job until the runner wakes and checks in — a red X would’ve trained me to ignore failures. - A read-only deploy key handles the GPU box’s fetch, since it has no business writing back.
- Escape hatch:
gh workflow run deploy.yml -f target=<host>to force one machine without touching the rest.
I mirror this exact topology for a separate Claude config repo — same runners, same reset-hard-and-restow shape.
One thing I kept, deliberately: the old manual sync script still exists. But it is not the deploy path anymore. It’s for messing around interactively on a workstation. The moment you let two systems both claim to deploy, you’ve reinvented drift — the runners assume they’re the only writer, and they should stay right about that.
If you manage config across more than two machines, stop orchestrating pushes from a central box. Make the repo the only truth, and make each machine responsible for catching up to it.