Workstation Provisioning and Team Dotfiles
Project-level automation — a bootstrap target, a pinned toolchain, a Compose stack — assumes the machine underneath is already usable: a package manager installed, Git configured with the right name and email, SSH keys registered, Docker running, the shell set up so version managers activate. On a new laptop none of that is true, and the first day disappears into a checklist copied from a wiki that was last accurate two OS versions ago. This topic, part of developer onboarding architecture and friction mapping, covers the layer below the repository: provisioning the workstation itself with scripts, so a new machine goes from unboxing to cloning the first repository in under an hour with no tribal knowledge.
The boundary between workstation and project matters, and getting it wrong causes most of the friction. Anything the project needs at a specific version belongs in the project — in mise.toml, devbox.json or a dev container — so two projects can disagree safely. The workstation layer should provide only what every project assumes and what must exist before any repository can be cloned: the OS package manager, Git and its identity, SSH or signing keys, a container runtime, the toolchain manager itself, an editor, and shell integration for all of those. Keeping the workstation layer small keeps it maintainable, and keeps project versions out of machine-wide installs where they would conflict.
Three tools cover almost every team's needs. A package manifest — a Brewfile on macOS, a package list for apt or dnf on Linux, winget or Scoop manifests on Windows — declares system packages. A dotfile manager such as chezmoi applies shell, Git and editor configuration from a repository, with templates for per-machine differences. And a small bootstrap script ties them together, is safe to re-run, and ends by running a health check. Configuration management tools such as Ansible fit when a platform team provisions many Linux workstations centrally.
Prerequisites
- Admin rights on the new machine, or an IT-provided self-service portal that grants them for the provisioning step. On managed Macs, some steps (Rosetta, Homebrew's
/opt/homebrew) need an administrator once. - A dotfiles repository the team can read, with no secrets in it. Personal overrides live in each developer's own fork or in local, untracked files.
- An identity provider for Git hosting: SSO-backed accounts on GitHub or GitLab, so keys and signing configuration attach to a real identity.
- A decision on the container runtime — see choosing and tuning a local container runtime — since the workstation layer installs it.
- A way to measure the result. Time from unboxing to first clone, recorded by the bootstrap script, as part of time-to-first-PR metrics.
A preflight run before anything is installed confirms the starting point and records it:
#!/usr/bin/env bash
set -euo pipefail
printf 'os=%s version=%s arch=%s\n' "$(uname -s)" "$(sw_vers -productVersion 2>/dev/null || . /etc/os-release && echo "$VERSION_ID")" "$(uname -m)"
for t in brew git ssh docker mise chezmoi code; do
printf '%-8s %s\n' "$t" "$(command -v "$t" || echo missing)"
done
df -h "$HOME" | tail -1
Declaring system packages in a manifest
A manifest turns "install these things" from prose into data. On macOS, a Brewfile lists formulae, casks and Mac App Store apps, and brew bundle installs whatever is missing — idempotently, so it can run every week to catch drift:
tap "homebrew/bundle"
brew "git"
brew "gh"
brew "jq"
brew "mise"
brew "chezmoi"
brew "colima"
brew "docker"
brew "docker-compose"
brew "docker-buildx"
brew "mkcert"
cask "visual-studio-code"
cask "1password-cli"
cask "font-jetbrains-mono"
- Keep the team Brewfile small: only tools every project assumes. Project tools belong in the project's toolchain file.
- Run
brew bundle --file=Brewfilefrom the bootstrap script. - Run
brew bundle checkas the drift diagnostic; it exits non-zero if anything is missing.
#!/usr/bin/env bash
set -euo pipefail
brew bundle check --file=Brewfile --verbose || { echo "run: brew bundle --file=Brewfile"; exit 1; }
Versions deserve a deliberate choice here. Homebrew does not pin formula versions in a Brewfile, which is acceptable for workstation tools because they should be current — Git, jq, the GitHub CLI — and harmful for anything a project depends on at a specific version. That is the practical test for what goes in the team Brewfile: if a newer version next month would be fine, it belongs here; if not, it belongs in the project's toolchain file where it can be pinned. brew bundle dump on a senior engineer's machine is a tempting starting point but produces hundreds of personal packages; start from an empty file and add only what a new hire genuinely needs on day one.
Linux and Windows have equivalents: a list of apt or dnf packages installed by a script or Ansible role, and winget import with a JSON manifest or a Scoop bucket list. The Brewfile provisioning guide covers splitting required and optional packages and handling casks that need admin rights.
Team dotfiles with chezmoi
Dotfiles — ~/.gitconfig, ~/.zshrc, ~/.ssh/config, editor settings — are where most "works on my machine" differences hide: a missing mise activate line, a Git alias that others assume, a different pull.rebase default. Sharing them through a repository fixes that, but naive sharing (symlinking a directory) breaks as soon as machines differ: work versus personal email, macOS versus Linux paths, Intel versus Apple Silicon Homebrew prefixes. chezmoi manages dotfiles from a source repository and renders templates per machine, so one source serves every variant:
[user]
name = {{ .name }}
email = {{ .email }}
signingkey = {{ .chezmoi.homeDir }}/.ssh/id_ed25519.pub
[gpg]
format = ssh
[commit]
gpgsign = true
[pull]
rebase = true
[init]
defaultBranch = main
{{- if eq .chezmoi.os "darwin" }}
[credential]
helper = osxkeychain
{{- end }}
Saved as dot_gitconfig.tmpl in the chezmoi source directory, this renders a correct ~/.gitconfig for each machine from values the developer enters once (chezmoi init prompts for name and email). chezmoi diff shows what would change before applying, and chezmoi apply writes it. The chezmoi guide covers splitting team defaults from personal settings and keeping secrets out of the repository.
The drift diagnostic is chezmoi status, which lists files whose target differs from what the source would produce — the local edits that make one machine behave differently.
#!/usr/bin/env bash
set -euo pipefail
chezmoi status || true
chezmoi verify && echo "dotfiles match source" || echo "dotfiles drifted: review with 'chezmoi diff'"
Windows developers and WSL2
Windows developers usually get the worst onboarding: instructions written for macOS, translated by hand. The most reliable path for container-based development on Windows is WSL2 with a Linux distribution, where the same Linux instructions, dotfiles and project scripts apply. The Windows side then needs only a small set of things — WSL itself, Docker Desktop or a WSL-native engine, an editor with WSL remote support, Git credential sharing — and a bootstrap script run from PowerShell can install all of them:
$ErrorActionPreference = 'Stop'
wsl --install -d Ubuntu-24.04 --no-launch
winget install --id Docker.DockerDesktop --exact --accept-package-agreements --accept-source-agreements
winget install --id Microsoft.VisualStudioCode --exact --accept-package-agreements
winget install --id Git.Git --exact --accept-package-agreements
wsl -d Ubuntu-24.04 -- bash -lc "curl -fsSL https://raw.githubusercontent.com/acme/dotfiles/main/install.sh | bash"
A reboot is usually required after wsl --install on a machine that never had the Virtual Machine Platform feature enabled, which is why real bootstrap scripts split into two phases: a first phase that enables features and schedules itself to continue after restart, and a second that installs applications and runs the Linux bootstrap. Writing a marker file after each phase makes the script safe to run again from the start, which is what a confused new hire will inevitably do.
The last line runs the same Linux bootstrap inside the distribution, so WSL2 users end up with identical dotfiles and tools to Linux users. The WSL2 bootstrap guide covers .wslconfig limits, systemd, and keeping repositories in the Linux filesystem.
Centrally managed Linux workstations
When a platform or IT team provisions many Linux workstations — for example, a fleet of developer desktops or VMs — per-user scripts give way to configuration management. Ansible is a natural fit: agentless, idempotent, readable, and able to run against the local machine or a whole inventory. A role for a developer workstation installs the package baseline, the container engine and the toolchain manager, and leaves dotfiles to chezmoi:
- name: Developer workstation baseline
hosts: workstations
become: true
vars:
dev_packages: [git, jq, curl, build-essential, pkg-config, libssl-dev, make]
tasks:
- name: Install baseline packages
ansible.builtin.apt:
name: "{{ dev_packages }}"
state: present
update_cache: true
- name: Install Docker Engine from the distribution repository
ansible.builtin.apt:
name: [docker.io, docker-compose-v2]
state: present
- name: Add developer to the docker group
ansible.builtin.user:
name: "{{ ansible_user }}"
groups: docker
append: true
The same playbook can serve self-service and central use. Developers run it against their own machine with ansible-playbook -i localhost, -c local workstation.yml --ask-become-pass; the platform team runs it against an inventory of desktops from a scheduled job. Keeping one playbook for both avoids the familiar split where the centrally managed baseline and the "just run this script" instructions quietly diverge. Put the package list in a variables file reviewed like any other code, and a new baseline tool becomes a one-line pull request that reaches every machine on the next run.
Running it with --check --diff is the drift diagnostic: it reports exactly what would change on a machine that has drifted from the baseline, without changing anything. The Ansible workstation guide covers running against localhost for self-service and inventories for fleets.
Identity with Git and SSH commit signing
The last workstation step before a developer can contribute is identity: a Git name and email that match the hosting account, an SSH key registered with the host, and — increasingly required by branch protection rules — signed commits. SSH-based signing (Git 2.34+) reuses the SSH key for signatures, which avoids managing GPG keys entirely:
#!/usr/bin/env bash
set -euo pipefail
key="$HOME/.ssh/id_ed25519"
[ -f "$key" ] || ssh-keygen -t ed25519 -C "$(git config --get user.email)" -f "$key" -N ""
gh auth login --git-protocol ssh --web
gh ssh-key add "$key.pub" --title "$(hostname) auth" --type authentication
gh ssh-key add "$key.pub" --title "$(hostname) signing" --type signing
git config --global gpg.format ssh
git config --global user.signingkey "$key.pub"
git config --global commit.gpgsign true
ssh -T [email protected] 2>&1 | head -1
Doing this in the script rather than by hand removes the most common first-push surprises: commits authored with a personal email that the hosting service does not associate with the work account, keys added only for authentication so signatures show as unverified, and SSH agents that forget the key after a reboot. The script also records exactly which keys were registered from which machine, which makes offboarding and laptop replacement a matter of deleting the named keys rather than guessing.
The Git and SSH signing guide covers hardware-backed keys, 1Password's SSH agent, and verifying signatures locally so Unverified badges do not surprise anyone after their first push.
Platform caveats
macOS: Homebrew installs under
/opt/homebrewon Apple Silicon and/usr/localon Intel. Dotfile templates must not hard-code either; use$(brew --prefix)or a chezmoi template condition on.chezmoi.arch.
Apple Silicon (ARM64): some casks and older tools still need Rosetta 2. Install it non-interactively at the start of provisioning (
softwareupdate --install-rosetta --agree-to-license) to avoid a prompt halfway through.
WSL2: provisioning happens twice — on Windows for WSL, Docker and the editor, and inside the distribution for everything else. Keep the Windows side minimal so there is one source of truth for tools.
Managed devices: MDM profiles may block kernel extensions, VM frameworks or unsigned binaries. Check the provisioning script against a freshly enrolled test machine whenever IT changes policy.
Rollback and recovery
Every step is idempotent and additive, so the usual recovery is to re-run the bootstrap script, which reinstalls anything missing and reapplies dotfiles. To undo dotfile changes, chezmoi can restore from its source repository history; packages installed by a manifest can be removed with the manifest's cleanup mode:
#!/usr/bin/env bash
set -euo pipefail
brew bundle cleanup --file=Brewfile # lists packages not in the manifest
chezmoi git -- log --oneline -5 # dotfile history
chezmoi git -- revert --no-edit HEAD && chezmoi apply
For a machine that has drifted badly, a clean reinstall followed by the scripted provisioning is usually faster than repairing it — which is the strongest argument for scripting provisioning in the first place.
Frequently Asked Questions
What belongs in workstation provisioning versus the project?
The workstation layer installs what every project assumes and what must exist before cloning: package manager, Git and keys, container runtime, toolchain manager, editor. Anything a project needs at a specific version belongs in that project's toolchain file or dev container.
Should dotfiles be shared across the whole team?
Share team defaults — Git settings, shell integration for the toolchain manager, common aliases — and let each developer layer personal preferences on top. chezmoi templates and local override files make that split explicit.
How do we keep the provisioning script working?
Run it on a clean virtual machine or CI runner for each supported OS on a schedule. Scripts that only run on new hires' first day break silently between hires.
Is Ansible necessary?
No. For self-service laptops, a Brewfile, chezmoi and a short script are enough. Ansible fits when a team manages many Linux workstations centrally or needs to enforce and report on a baseline.
Related
- Provision macOS laptops from a Brewfile
- Share dotfiles safely with chezmoi
- Hand off to the project with one bootstrap command
- Reduce setup friction for junior engineers
Every guide in this topic
- Automating WSL2 Developer Setup With a Bootstrap ScriptScript a Windows developer's setup end to end: WSL2 and a distro, .wslconfig limits, systemd, Docker integration and repos in the Linux filesystem.
- Managing Team Dotfiles With chezmoiShare Git, shell and editor defaults across a team with chezmoi: templates for OS and work or personal differences, local overrides, and no secrets in git.
- Provisioning Linux Workstations With AnsibleApply one idempotent Ansible playbook to developer Linux machines, self-service on localhost or across a fleet, with check-mode drift reports and Docker group fixes.
- Provisioning macOS Laptops With a BrewfileInstall a new Mac's developer baseline with brew bundle: a team Brewfile, required vs optional packages, Rosetta and cask prompts, and weekly drift checks.
- Setting Up Git and SSH Commit Signing for New HiresGet a new hire's first push accepted: Git identity, an SSH key for auth and signing, allowed_signers for local verification, and fixing Unverified commits.