TL;DR

  • I maintain workstations for people who are never going to run git pull — so the config lives in a repo I own and they never see.
  • It is a reference/restore repo, not config management: per-machine folders hold snapshots of what’s actually installed, and restore is a deliberate install -Dm755, not a sync.
  • The no-auto-sync part is the whole point. Silent convergence across machines that are supposed to differ is how you break someone else’s laptop from your desk.
  • The boring 40 minutes — partitioning, encryption, sudo, base packages — is an unattended installer (a Debian preseed, now on its eleventh revision). The interesting 20 minutes is the per-machine folder.
  • The agent’s job isn’t to apply config. It’s to read the repo, diff it against the live box, and tell me what drifted — then write the restore commands for me to approve.
  • What I’d do differently: put the agent’s own profile in git before you have three machines copying it from each other over SSH.

The problem with managing other people’s computers

I run a handful of machines that aren’t mine. A couple of family desktops, a bench box at the office, and a laptop belonging to someone who does knowledge work and would rather not learn what a dotfile is.

The standard homelab answer is Ansible. I use Ansible daily for cluster nodes and I deliberately did not use it here, for one reason: these machines are supposed to be different from each other. The bench box wants passwordless sudo and a pile of hardware tooling. The family desktop wants neither, and should absolutely not have an operator SSH key sitting on it. A playbook that runs everywhere converges everywhere, and convergence is exactly the wrong default when the fleet is heterogeneous by design.

The second reason is subtler. Config management assumes you can re-run it. Someone else’s daily driver is not a machine you get to re-run things on at 11pm because a role changed. If I break it, they can’t work, and I’m not there.

So the model is inverted. Instead of a system that pushes state onto machines, I keep a repo that describes each machine, and an agent whose job is to tell me where reality and the description have diverged.

Repo shape

One folder per computer, plus a shared/ folder for the handful of things that genuinely belong on more than one box.

workstation-config/
  installer/            # unattended Debian preseed (v11)
  shared/
    bw-session/         # Bitwarden CLI helpers (unlock, sync, fetch)
    remote-display/     # VNC + display-profile control for the Mac
    claude-profile/     # agent doctrine (the parts that are safe in git)
  bench-desktop/
    bin/                # things that live in ~/.local/bin
    dotfiles/           # bashrc, zshrc, gitconfig, ssh_config
    etc/                # sudoers drop-ins, apt sources
    desktop/            # .desktop launchers
    README.md           # what this box is, what's weird about it
  laptop/
  family-desktop-1/
  family-desktop-2/

Two conventions matter more than they look.

Dotfiles are stored without the leading dot. dotfiles/bashrc, not dotfiles/.bashrc. Leading-dot files hide from ls, get skipped by naive globs, and get silently dropped by about half the copy commands you’ll write in a hurry. Strip the dot in the repo, add it back on restore.

Every machine folder has a README written for a stranger. Not “install these” — what this box is, what’s unusual about it, and what will bite you. One machine’s disk is encrypted and unlocks itself from the TPM; its sibling isn’t encrypted at all. That difference is a one-line note in the README and it has already saved me from one very bad assumption.

Restore is a command, not a daemon

There is no sync. Restoring a script onto a machine looks like this:

install -Dm755 bench-desktop/bin/label-print ~/.local/bin/label-print
install -Dm644 bench-desktop/dotfiles/gitconfig ~/.gitconfig

That’s it. It’s boring, and it’s manual, and both of those are features. Every restore is a decision a human made, at a moment they chose, on a machine they were looking at.

The one exception is shared/. Those tools install as symlinks into the repo rather than copies, because they’re genuinely one implementation used from several places and I want a fix to land everywhere at once. Copies drift; symlinks can’t. The tradeoff is that a git pull in the repo now changes behaviour on the machine immediately — so shared/ is the only part gated on CI (shellcheck plus an installer smoke test) before it can merge.

That’s the whole doctrine: copies for things that are allowed to differ, symlinks for things that must not. Deciding which bucket a script belongs in is the only genuinely interesting design question in the repo.

The installer does the boring part

Nobody should hand-install an OS more than once. The first 40 minutes of a new machine — partitioning, disk encryption, TPM enrolment, users, sudo drop-ins, base package set, SSH hardening — is an unattended Debian preseed in installer/. It’s on revision eleven, which tells you how many times I was wrong about what “base” means.

The most recent machine I built came up from bare metal with an encrypted root that unlocks itself from the TPM, a passwordless-sudo admin account, a separate operator account carrying the key I manage it with, and key-only SSH. I didn’t type a single answer during the install.

Two things I’d tell anyone writing one of these:

Write the as-built record the same day. When the install finishes, the agent writes an as-built.md for that machine: what the installer actually did, what it verified, and what it couldn’t. Six weeks later that document is the only reason you know whether the TPM enrolment on box three was verified or merely assumed.

Expect the encryption to fall back, and document it as normal. TPM-sealed unlock is bound to a firmware measurement. A firmware update changes the measurement and the machine asks for the recovery passphrase at boot. That isn’t a failure, it’s the design working — but if it isn’t written down as expected behaviour, the person sitting in front of it at 8am reasonably concludes the disk is dying.

Where the agent actually helps

I want to be precise here, because “AI manages my configs” is a sentence that oversells this badly.

The agent does not apply configuration. It has three jobs.

1. Drift reporting. Point it at a machine folder and the live box: what’s in the repo that isn’t installed, what’s installed that isn’t in the repo, and what differs. A shell script could do the mechanical part; the useful version needs judgement, because a changed timestamp isn’t drift and a changed sudoers.d drop-in absolutely is. What I want back is three lists, ranked by how much I should care.

2. Snapshot-back. After I fix something live on a box — and I always fix things live on a box, that’s how emergencies work — the agent walks the machine, finds what changed relative to the repo, and writes it back into the machine folder with a commit message explaining why. This is the step humans skip, and skipping it is precisely what turns a reference repo into fiction.

3. Writing the restore commands. Not running them. Writing them, in a block I can read and paste. The gap between “here are the eleven install lines” and “I ran the eleven install lines” is the entire safety margin on someone else’s daily driver.

Anything destructive is blocked at the harness level rather than by asking nicely: a pre-tool hook regex-matches rm -rf, DROP, truncate, kubectl delete and friends and refuses them outright. Deterministic, not a judgement call. That sits at the bottom of the ladder I described in the autonomy ladder in practice, and config work on machines belonging to other people stays on the low rungs permanently. I don’t expect that to change.

The part I got wrong

The agent’s own profile — the instructions, the skills, the accumulated memory of how these machines work — was for months not in git at all.

It lived on one laptop’s disk. When I built the next machine, I wrote a pull-profile script that copies the whole profile over SSH from the laptop. Which works. It also means the canonical copy of the most valuable thing in the setup was a directory on an unencrypted disk, replicated by a shell script, with no history and no review.

I’ve since started moving the durable parts — the doctrine, the shared tooling — into shared/claude-profile/, where they get reviewed like anything else. The machine-specific instructions and the memory files are still outside it. That’s the open item, and I’m writing it down here mostly so I stop pretending it isn’t one.

If you’re setting this up: put the profile in git on day one, before there are two machines. The moment there are two machines, the SSH-copy hack works well enough that you’ll never go back and fix it.

Gotchas

A reinstall wipes the trust you set up remotely. One machine got reinstalled and its host key changed, which invalidated the key material I manage it with. Every remote path in and out was dead until someone was physically in front of it. If you manage a box remotely, the recovery path has to assume you can’t SSH in — write down what the human at the keyboard needs to type.

Per-device credentials, always. Every machine gets its own SSH keypair and its own access token, never a shared one. The point isn’t cryptographic hygiene, it’s that revoking one machine shouldn’t be an event. Shared credentials make every compromise a fleet-wide compromise and every rotation a fleet-wide outage.

“Fill from live machine” folders lie. I scaffolded folders for two family desktops months ago and they’re still empty. An empty machine folder in a reference repo reads, at a glance, exactly like a machine with nothing unusual about it. Either populate it or put a loud STATUS: not captured yet at the top of the README.

Don’t let a bench convention leak onto a person’s laptop. Passwordless sudo is correct on a box that gets reimaged weekly. It is not correct on someone’s work laptop. The per-machine folder makes that difference explicit, which is the entire reason I didn’t reach for a playbook.

Where this sits

Four machines, no config management system, no convergence, no scheduled runs. A repo that describes each box, an installer that does the tedious first hour, and an agent whose job is to notice when the description stopped being true.

It scales worse than Ansible and it’s correct more often, because the failure mode of the manual version is “nothing happened” and the failure mode of the automated version is “something happened everywhere.” On machines other people depend on, I’ll take the first one.