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.

New Laptop to First Clone Ordered provisioning stages from a fresh OS install to cloning the first repository. New Laptop to First Clone 1 — OS package manager installed 2 — system packages from manifest Brewfile 3 — dotfiles applied chezmoi 4 — Git identity, SSH and signing keys 5 — container runtime and toolchain manager 6 — clone first repo, run make bootstrap
Each stage is scripted and re-runnable; the project layer takes over after the clone.

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"
  1. Keep the team Brewfile small: only tools every project assumes. Project tools belong in the project's toolchain file.
  2. Run brew bundle --file=Brewfile from the bootstrap script.
  3. Run brew bundle check as 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.

Wiki Checklist vs Package Manifest Comparison of a prose install checklist against a declarative package manifest. Wiki Checklist vs Package Manifest wiki checklist Brewfile or package list copied by hand brew bundle installs silently outdated changed by pull request no drift detection bundle check exits 1 one OS per page one manifest per OS
The manifest is executable, re-runnable and reviewable; the checklist is none of those.

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'"
How chezmoi Renders a Dotfile Flow from a template in the dotfiles repository through per-machine data to the file in the home directory. How chezmoi Renders a Dotfile source template dot_gitconfig.tmpl machine data name, email, OS chezmoi apply renders ~/.gitconfig per machine
One template serves every machine; the data file holds what differs.

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.

Provisioning Tool per Platform Table listing the package manifest, dotfile tool and bootstrap entry point for each platform. Provisioning Tool per Platform Platform Packages Dotfiles Entry point macOS Brewfile chezmoi install.sh Linux apt list, Ansible chezmoi install.sh Windows winget chezmoi in WSL bootstrap.ps1
Dotfiles are shared across all three; only the package layer differs.

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.

A Scripted First Morning Timeline of a new hire's first morning when workstation provisioning is scripted. A Scripted First Morning 09:00 laptop unboxed, SSO login 09:10 install.sh starts 09:35 packages and dotfiles done 09:45 keys registered, signing on 10:05 first repo bootstrapped
Measured on a recent macOS laptop with a warm package mirror; unscripted, the same steps took a day and a half.

Platform caveats

macOS: Homebrew installs under /opt/homebrew on Apple Silicon and /usr/local on 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.

Every guide in this topic