Twenty laptops, one installer: closing the day-2 gap with a shared dotfiles collection
Picture a twenty-person dev agency. Every developer runs Ubuntu. The company has a polished Subiquity autoinstall file that turns a blank laptop into a workstation: encrypted LVM, Docker, build tools, the IDE, the chat apps, a local dev environment for the client projects, even a first-boot service that installs the mkcert CA. There is even a curl-able post-install script with skip-if-installed checks, so software can be added to an existing machine too. Day 0 is genuinely solved, twice.
And every developer has their own dotfiles repo, doing things their own way, symlink scripts, hand-edited .zshrc, a setup.sh that worked once in 2023.
This post is about the gap between those two facts, and how an Ansible-based dotfiles collection closes it without taking anyone's personal setup away.
What the company tooling already solves
Give credit where due, this is a well-built stack, and many companies converge on something like it:
- the autoinstall owns day 0: disk layout and encryption, user creation, locale, apt packages and snaps
- a post-install framework (make targets, a curl-able installer) owns software on existing machines: each target checks "already installed?" and skips if so, and developers can cherry-pick optional tools
- one-shot tweaks ride along in
late-commands: raise inotify limits, install a version manager, drop a systemd unit for first boot
This is better than what most companies have. So where is the gap?
Installed is not configured
An install framework enforces exactly one predicate: is it installed? That predicate is true forever after day 1, which is why re-running it feels pointless ("it's already installed"). But installed is the part of a machine that never breaks. What breaks is configured:
- Drift. The framework copies a company
.gitconfigand.zshrconce. From that moment they drift: a changed template never re-applies, an updated apt repo definition is invisible behind "the binary exists", and no two laptops answer "what state is this machine in?" the same way. - The seam. The company layer writes
.zshrcand.gitconfig; every developer's personal dotfiles repo writes those same files. Two unrelated systems, one file, last-writer-wins, twenty developers, twenty different collision behaviours, none of them defined. - Rollout. "Everyone please add this kubectl alias / trust this new CA / update this config" is install-shaped only if it is a new package. If it is a change to existing state, it becomes a Slack message and twenty manual edits.
- Onboarding. The installer gets a new hire to a desktop. Git identity, SSH keys, the fifteen client repositories they need, the shell setup their team shares, that knowledge lives in colleagues' heads and twenty diverging personal repos.
The company tooling answers "is the software present?". Nothing answers "is the machine still right?", and right includes configuration, identity, repositories, and the personal layer.
Three layers, no fork
This collection is built for exactly that second question, and the team setup needs no fork of anything. Each developer's personal repo installs two collections side by side:
# requirements.yml (personal repo)
---
collections:
- name: dotfilespro.roles # shared roles (Ansible Galaxy)
version: ">=1.0.0" # upstream: pin a tag, upgrade deliberately
- name: company.roles # your company-specific roles
type: git
source: https://gitlab.example.com/company/dotfiles.git
version: main # yours: track main, merging an MR is the rollout# playbook.yaml
---
- name: Configure localhost
hosts: localhost
connection: local
gather_facts: true
diff: true
roles:
- dotfilespro.roles.ubuntu
- dotfilespro.roles.ssh
- dotfilespro.roles.git
- dotfilespro.roles.docker
- dotfilespro.roles.kubernetes
- company.roles.devstack # the platform-specific dev environmentThat makes three layers, each owning what only it can know:
| Layer | Owns | Examples |
|---|---|---|
dotfilespro.roles | Generic developer tooling | git, docker, kubectl + aliases, starship, ssh keychain |
company.roles | Company-specific tooling and baseline defaults | the client-project dev environment, internal CLIs |
| Personal repo | Identity and taste | git email, repository list, personal aliases, own roles |
The dividing line is simple: if another company could use the role unchanged, it belongs upstream (and is worth contributing, mkcert, node, a terminal emulator); if only your company needs it, it goes in company.roles; if only you want it, it stays in your personal repo under roles/.
And because all three layers run as one engine in one run, the seam problem dissolves: company baseline plus personal override is a merged, deterministic outcome, not whoever wrote the file last.
Building that second collection? The company collection guide is this section as steps, the company.roles repo and its galaxy.yml, baseline defaults, mixing both collections per machine, and rolling out a change by merge request.
Personal taste survives as variables
Standardization usually fails because it flattens people's setups. Here the shared roles are parameterized, and with hash_behaviour = merge a developer only states what differs:
# inventory.yaml (personal repo)
---
all:
vars:
git_config:
user:
name: Alice Example
email: alice@company.example
git_repositories:
client-a: git@gitlab.example.com:clients/client-a.git
client-b: git@gitlab.example.com:clients/client-b.git
aliases:
dotfiles-update: "cd ~/.dotfiles && ansible-galaxy collection install -r requirements.yml --upgrade && ansible-playbook playbook.yaml --ask-become-pass"Alice and Bob run the same roles but get their own identity, their own client repositories cloned, their own aliases. Which roles they run is equally personal: want kubernetes tooling, add the role; don't, leave it out. Anything the roles don't cover goes in a personal role next to the playbook.
This layer is an offer, not a mandate. The senior with a lovingly tuned chezmoi setup keeps it, file-syncing tools compose fine with a machine provisioner (the alternatives comparison says exactly when chezmoi is the better pick).
The offer is for everyone else: the developer who never quite gets around to building their own setup copies examples/single/, fills in ten lines of vars, and inherits the library. A floor, not a fence.
Day 0 hands off to day 2
The autoinstall keeps doing what it is good at, but its late-commands shrink to a handoff: install Ansible, and leave a note for first login.
---
# autoinstall.yaml (excerpt)
late-commands:
- curtin in-target -- apt-get install -y ansible ansible-mitogen gitAt first login the developer runs the installer, which provisions Ansible, clones the personal repo, and installs the collection; dotpro apply then converges the machine. On a keyless box it also sets up the SSH key, trying an HTTPS fallback first, otherwise generating a key and pausing for the developer to register it before retrying:
curl -LsSf https://dotfiles.pro/install.sh | sh -s -- git@gitlab.example.com:alice/mydotfiles.gitFrom then on, reinstall and update are the same code path. "Everyone add this alias" becomes a one-line merge request to company.roles or the shared collection, and each machine picks it up on its next dotpro apply, no Slack broadcast, no reimaging.
Because every role is tagged, targeted rollouts are cheap: ansible-playbook playbook.yaml --tags kubernetes.
Onboarding a new hire collapses to: run the company installer, copy the examples/single/ directory into a new personal repo, run the playbook. The fifteen client repositories clone themselves.
The Ubuntu autoinstall guide walks the imaging side step by step: the thin late-commands, the first-login handoff, and a table mapping each curl | bash installer onto a role. The installer now sets up the SSH key on a keyless machine; a fully unattended late-commands clone still needs a pre-seeded deploy key.
The objections, from a real conversation
This use case is not hypothetical, and neither are the objections. The ones an agency owner actually raised, answered honestly:
- "You reinstall once a year: is there much to win?" The reinstall is annual; change is weekly. A new tool, a new alias, a client repo, a CA to trust, a config tweak, every one of those is currently per-person handwork or waits for the next reinstall. Staying up-to-date is about the weeks between installs, not the install.
- "Copying your dotfiles over is done in a minute." True, the copy is the easy 10%. The other 90% is the apply: packages present, paths wired, machine differences handled, repositories cloned. That is the
setup.sheveryone hand-rolls, and it is exactly the part that becomes declarative here. - "Imposing tooling on developers breeds frustration, not efficiency." Agreed, which is why nothing here is imposed. Shared roles are a library you opt into per role; the personal layer is each developer's own repo; experts keep their own systems. What it removes is not freedom but twenty people re-solving the same problems alone.
- "Real efficiency is the shared dev environment, not dotfiles." Also agreed, that is the project layer, and it composes: see using this with devcontainers. This collection owns the layer underneath, the machine the dev environment runs on.
Adopting without a big bang
Nothing here requires a migration decision. Roles are idempotent, so they run safely next to an existing install framework, it is fine that the installer already installed Docker; the role applies it.
The realistic path is bottom-up: one interested developer pilots a personal repo with two or three roles, the company collection grows when it proves itself, and each installer fragment retires only when its role replaces it. Every step is reversible, and the old tooling stays the fallback until it is empty.
Gotchas for an existing fleet
zsh. If the installer sets everyone's shell to zsh with oh-my-zsh, set
shell: zshfor those hosts (on Linux it defaults to bash) and theunixrole wires the~/.profile.d/framework into~/.zprofileand~/.zshrcitself; no manual sourcing needed. Auto-detecting the login shell is still on the roadmap.WSL. The
wslrole is for Ubuntu inside WSL; on a native-Ubuntu fleet, simply leave it out of the playbook. Nothing else is WSL-specific.
Where this is heading
This use case drove a sizeable part of the roadmap. Already shipped: the curl-able bootstrap (install.sh) for late-commands, with root-vs-user detection, CI-safe non-interactive runs, the SSH-key handoff on a keyless machine, an optional systemd user timer that keeps machines up-to-date, per-role apply tests in clean containers, zsh as a first-class shell, and Homebrew via the macos role.
Still ahead: new roles (node, composer, mkcert, 1password, b2, warp, jetbrains, Linuxbrew) extracted from things fleet installers do imperatively today.
With the macos role, the same personal repo covers Ubuntu, macOS, and WSL, the operating system itself becomes a personal choice instead of a company decision.
The installer images the laptop. The collection keeps it true.