Developer Workstation Setup: Dotfiles, System Provisioning, and Automation
Developer Workstation Setup: Dotfiles, System Provisioning, and Automation
Setting up a new development machine takes anywhere from half a day to a full week depending on how much tribal knowledge lives in your head instead of in automation. The right tooling turns "reinstall OS and spend three days remembering how things were configured" into "run a script and go make coffee." This guide covers dotfiles management, system provisioning, package management, and shell customization.
Dotfiles Managers
Your dotfiles (.bashrc, .gitconfig, .vimrc, .ssh/config, etc.) are the core of your development environment. Managing them manually means copying files between machines, losing track of changes, and inevitably diverging between your laptop and your work machine. A dotfiles manager puts them in version control and handles the symlinks or copies.
chezmoi
chezmoi is the most full-featured dotfiles manager. It stores your dotfiles in a source directory, applies them to your home directory, and handles machine-specific differences through templates.
# Install
sh -c "$(curl -fsLS get.chezmoi.io)"
# Initialize from a new machine
chezmoi init --apply your-github-username
# Add a dotfile
chezmoi add ~/.gitconfig
chezmoi add ~/.config/starship.toml
# Edit and apply
chezmoi edit ~/.gitconfig
chezmoi apply
The killer feature is templates. You can have a single .gitconfig that varies by machine:
# ~/.local/share/chezmoi/dot_gitconfig.tmpl
[user]
name = "Your Name"
{{ if eq .chezmoi.hostname "work-laptop" }}
email = "[email protected]"
{{ else }}
email = "[email protected]"
{{ end }}
[core]
editor = nvim
autocrlf = {{ if eq .chezmoi.os "windows" }}true{{ else }}input{{ end }}
chezmoi also supports encrypted secrets (via age or gpg), scripts that run on apply, and one-shot setup scripts for first-time initialization.
Strengths: Templates for machine-specific config, encrypted secrets, works on every OS, excellent documentation.
Weaknesses: More complex than simpler alternatives. The template syntax (Go templates) has a learning curve. Overkill if all your machines are identical.
GNU Stow
GNU Stow takes the opposite approach: no templates, no encryption, no configuration language. It just creates symlinks.
# Structure your dotfiles directory
dotfiles/
git/
.gitconfig
vim/
.vimrc
.vim/
autoload/
zsh/
.zshrc
.zsh/
aliases.zsh
# Symlink everything
cd ~/dotfiles
stow git vim zsh
# This creates:
# ~/.gitconfig -> ~/dotfiles/git/.gitconfig
# ~/.vimrc -> ~/dotfiles/vim/.vimrc
# ~/.zshrc -> ~/dotfiles/zsh/.zshrc
Each "package" (subdirectory) maps its contents to your home directory. stow git creates a symlink from ~/dotfiles/git/.gitconfig to ~/.gitconfig. That's it.
Strengths: Dead simple. No learning curve beyond understanding symlinks. Your dotfiles directory mirrors your home directory structure. Easy to selectively install packages.
Weaknesses: No templating. No machine-specific configuration. No secret management. If you need different configs on different machines, you're managing branches manually.
yadm
yadm (Yet Another Dotfiles Manager) wraps Git itself. Your home directory becomes the Git working tree, with yadm managing the .git directory elsewhere to keep things clean.
# Install and init
yadm init
yadm add ~/.gitconfig ~/.zshrc ~/.config/starship.toml
yadm commit -m "initial dotfiles"
yadm remote add origin [email protected]:you/dotfiles.git
yadm push -u origin main
# On a new machine
yadm clone [email protected]:you/dotfiles.git
yadm supports alternate files for machine-specific configs (files named .gitconfig##os.Linux vs .gitconfig##os.Darwin), encrypted files via GPG, and bootstrap scripts.
Strengths: Familiar Git workflow. No separate "source" directory -- files live where they're used. Alternate files handle machine differences without templates.
Weaknesses: Having your home directory be a Git repo (even a bare one) feels conceptually messy. File alternates are less flexible than chezmoi's templates.
Dotfiles Manager Comparison
| Feature | chezmoi | GNU Stow | yadm |
|---|---|---|---|
| Machine-specific config | Templates | Manual branches | Alternate files |
| Encrypted secrets | age/gpg | No | gpg |
| Learning curve | Moderate | Low | Low |
| Mechanism | Copy/template | Symlinks | Git in $HOME |
| Cross-platform | Excellent | Linux/macOS | Linux/macOS |
| Dependencies | Single binary | perl | git |
System Provisioning
Dotfiles handle configuration files, but setting up a machine also means installing packages, configuring system services, setting up SSH keys, and tuning OS settings. Provisioning tools automate this layer.
Ansible
Ansible is overkill for a single laptop -- but it works, and the skills transfer to server management. Write playbooks in YAML, run them locally.
# workstation.yml
---
- hosts: localhost
connection: local
become: true
tasks:
- name: Install development packages (Fedora)
dnf:
name:
- git
- neovim
- ripgrep
- fd-find
- tmux
- zsh
- jq
- htop
- docker
state: present
when: ansible_distribution == "Fedora"
- name: Install development packages (Ubuntu)
apt:
name:
- git
- neovim
- ripgrep
- fd-find
- tmux
- zsh
- jq
- htop
- docker.io
state: present
when: ansible_distribution == "Ubuntu"
- name: Set default shell to zsh
user:
name: "{{ ansible_user_id }}"
shell: /usr/bin/zsh
- name: Enable Docker service
systemd:
name: docker
enabled: true
state: started
ansible-playbook workstation.yml --ask-become-pass
Strengths: Declarative (describe desired state, not steps), idempotent (safe to run repeatedly), handles OS differences, massive module library.
Weaknesses: Slow for simple tasks. YAML playbooks get verbose. Python dependency. The abstraction layer can be frustrating when you just need to run a shell command.
Nix (Home Manager)
Nix takes a radically different approach: every package is installed in an isolated store (/nix/store/), and your environment is a declarative specification. Home Manager extends this to manage your entire user environment -- packages, dotfiles, and services.
# home.nix
{ config, pkgs, ... }:
{
home.username = "dev";
home.homeDirectory = "/home/dev";
home.packages = with pkgs; [
ripgrep
fd
jq
bat
eza
delta
starship
lazygit
];
programs.git = {
enable = true;
userName = "Your Name";
userEmail = "[email protected]";
delta.enable = true;
extraConfig = {
init.defaultBranch = "main";
push.autoSetupRemote = true;
};
};
programs.zsh = {
enable = true;
autosuggestion.enable = true;
syntaxHighlighting.enable = true;
shellAliases = {
ll = "eza -la";
gs = "git status";
gd = "git diff";
};
};
programs.starship.enable = true;
programs.bat.enable = true;
programs.fzf.enable = true;
}
home-manager switch
Strengths: Truly reproducible environments. Roll back to any previous generation. Multiple versions of the same package coexist. Declarative configuration of programs and their dotfiles in one place.
Weaknesses: Steep learning curve. The Nix language is unique and non-obvious. Build times can be long. Nix's store model conflicts with some software that expects traditional paths. Documentation is notoriously scattered.
Simple Shell Scripts
For many developers, a well-organized shell script is the right answer:
#!/bin/bash
set -euo pipefail
# setup.sh - Developer workstation setup
OS="$(uname -s)"
echo "=== Installing packages ==="
if [ "$OS" = "Darwin" ]; then
brew install git neovim ripgrep fd tmux zsh jq htop starship
elif [ -f /etc/fedora-release ]; then
sudo dnf install -y git neovim ripgrep fd-find tmux zsh jq htop
elif [ -f /etc/debian_version ]; then
sudo apt install -y git neovim ripgrep fd-find tmux zsh jq htop
fi
echo "=== Installing Rust toolchain ==="
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "=== Installing Bun ==="
curl -fsSL https://bun.sh/install | bash
echo "=== Setting up dotfiles ==="
if [ ! -d ~/dotfiles ]; then
git clone [email protected]:you/dotfiles.git ~/dotfiles
fi
cd ~/dotfiles && stow git vim zsh
echo "=== Changing shell to zsh ==="
chsh -s "$(which zsh)"
echo "Done! Restart your terminal."
Strengths: No dependencies beyond bash. Easy to understand and modify. Version-controlled in your dotfiles repo.
Weaknesses: Not idempotent without careful scripting. No built-in rollback. OS detection gets messy.
Package Management Across Operating Systems
The biggest friction in cross-platform setup is package management. Strategies for handling it:
Homebrew works on both macOS and Linux. Using it everywhere simplifies scripts but adds a dependency on Linux where native package managers work fine.
Bundlefile approach -- list desired packages per manager:
# Brewfile (macOS)
brew "git"
brew "neovim"
brew "ripgrep"
brew "fd"
brew "starship"
cask "wezterm"
cask "firefox"
cask "docker"
brew bundle install
Flatpak for GUI apps on Linux avoids distribution-specific packaging:
flatpak install flathub com.visualstudio.code
flatpak install flathub org.mozilla.firefox
Language-specific version managers: Use mise (formerly rtx) to manage Node, Python, Ruby, Go, and Java versions in one tool:
# .mise.toml (project-level or global)
[tools]
node = "22"
python = "3.12"
go = "1.23"
bun = "latest"
mise install # Installs all specified versions
Shell Customization
Starship Prompt
Starship is a cross-shell prompt that works with bash, zsh, fish, and PowerShell. It's fast (written in Rust) and configurable:
# ~/.config/starship.toml
[directory]
truncation_length = 3
[git_branch]
symbol = " "
[git_status]
conflicted = " "
ahead = "⇡ "
behind = "⇣ "
[nodejs]
symbol = " "
detect_files = ["package.json"]
[python]
symbol = " "
[cmd_duration]
min_time = 2000
show_milliseconds = false
Essential Shell Plugins
For zsh, install these with your plugin manager of choice (zinit, zsh-autosuggestions, or oh-my-zsh):
- zsh-autosuggestions: Suggests commands from history as you type
- zsh-syntax-highlighting: Colors commands green (valid) or red (invalid) as you type
- fzf integration: Fuzzy search for command history (Ctrl+R) and file paths (Ctrl+T)
# ~/.zshrc (with zinit)
zinit light zsh-users/zsh-autosuggestions
zinit light zsh-users/zsh-syntax-highlighting
Recommendations
Starting from scratch: Use chezmoi for dotfiles and a shell script for package installation. This combination handles 90% of developer needs without over-engineering.
Multiple identical machines: GNU Stow plus a Brewfile or shell script. Keep it simple when there's no machine-specific variation.
Reproducibility matters: Nix with Home Manager. The learning curve is steep but the payoff is real -- your environment is fully declared and reproducible.
Team onboarding: Write a setup script that runs in under 10 minutes. New developers should be able to clone one repo, run one command, and have a working environment. Test the script on a clean VM quarterly.
General principles:
- Automate everything, but start simple. A 50-line shell script beats a complex Ansible playbook you don't maintain.
- Keep dotfiles in Git. There's no excuse not to.
- Document the manual steps. Some things can't be automated (OS installation, hardware-specific drivers, license activations). Write them down so you don't rediscover them in a year.
- Test your setup on clean installs. Automation you don't test is automation that doesn't work when you need it.