← All posts

My Dotfiles Deploy Themselves Now (I Stopped SSHing Into Five Boxes)

How I replaced a fragile per-machine SSH sync script with one self-hosted GitHub Actions runner per box, each labeled by host role, reconciling on every push to main.

  • dotfiles
  • ci-cd
  • github-actions
  • infrastructure
  • automation

For about a year, updating my shell config across machines meant SSHing into each box and running sync-machine.sh by hand — a little script that pulled my dotfiles (the config files that live in your home folder and decide how your terminal, editor, and git behave). Five machines: a dev box, a server, a services box, a GPU box, and my Mac laptop. So five SSH sessions, five runs of the script, and the near-certainty that I’d forget the GPU box because it was asleep.

The failure mode was always the same. I’d fix something on my laptop, fan it out to four machines, miss one, and three weeks later trip over the old broken config on the box I skipped. The “source of truth” was wherever I happened to have run the script last.

So I flipped it around. Instead of me pushing config out to each machine, each machine now pulls it in — automatically, the moment I push to main.

The trick is a self-hosted GitHub Actions runner on every box. A runner is just a small agent that sits on a machine, watches a repo, and runs jobs when told. Normally GitHub runs your CI on its own cloud machines; a self-hosted one runs on your hardware, which is exactly what you want when the job is “update this specific laptop.” I labeled each runner by its role — dev, srv, svc, gpu, mac — and a push to main fires the same deploy.yml on all of them at once.

Each runner does three things: fetch the repo, hard-reset its working tree to match origin/main, and re-link the dotfiles into my home directory with stow. That’s the whole deploy.

The part I didn’t appreciate until it just worked: the asleep-GPU-box problem solved itself. A runner on a sleeping machine doesn’t fail the job — the job queues and runs whenever the box wakes up. The thing that used to be my most common mistake is now impossible to make.

It also changed how I think about my home directory. The git working tree is the source of truth now. The live ~/.zshrc and friends are just symlinks pointing at a deploy target — outputs, not inputs. I don’t edit them directly anymore, the same way you don’t edit the compiled binary instead of the source.

The deploy workflow and the single-machine escape hatch give me the detail

The whole thing hinges on labeled runners plus a workflow_dispatch target selector so I can also deploy to one box on demand.

# .github/workflows/deploy.yml
on:
  push: { branches: [main] }
  workflow_dispatch:
    inputs:
      target:
        type: choice
        options: [all, dev, srv, svc, gpu, mac]

concurrency:
  group: deploy-${{ matrix.host }}
  cancel-in-progress: false   # back-to-back pushes QUEUE, never clobber

jobs:
  deploy:
    strategy:
      matrix:
        host: [dev, srv, svc, gpu, mac]
    runs-on: [self-hosted, "${{ matrix.host }}"]
    steps:
      - run: |
          git fetch origin main
          git reset --hard origin/main
          stow -R . -t "$HOME"

Two settings matter most. cancel-in-progress: false means if I push twice in a row, the second deploy waits for the first instead of cancelling it mid-stow and leaving half-linked dotfiles. And each runner authenticates to the private repo with its own deploy key, so a compromised box can’t push back upstream — it can only pull.

I kept sync-machine.sh around for exactly one case: my laptop, where I sometimes want the script’s snapshot-on-divergence behavior before blowing away local edits.

What I’d tell you to steal here isn’t “use GitHub Actions for dotfiles.” It’s that fleet config is a CI/CD problem wearing a costume. The moment you have more than two machines, stop pushing changes out and start letting each host reconcile itself against a single branch. Push to main, let the labeled runners catch up, and keep the manual script only for the one machine where you actually want a human in the loop.