In the previous post, we completed the git setup for our chezmoi repo — identity, SSH key, remote URL, all configured automatically. After running chezmoi init --apply on a fresh machine, the dotfiles land, secrets are decrypted, and you can push immediately. The setup flow works.
But there’s something that’s been nagging at me. We’ve been solving each installation problem individually — zsh via apt, age from GitHub releases — each with its own logic baked into a shell script. That works, but it doesn’t scale. What happens when I need to install tmux? Or neovim? Or a handful of CLI tools that each have their own quirks?
The Problem With Shell Scripts#
Our current run_once_install-packages.sh script handles everything in raw bash. It detects the OS, runs apt, downloads age from GitHub releases, and moves binaries into place. For two packages, that’s fine. But as the list grows, the script will grow with it — and shell scripts aren’t great at being readable, idempotent, or cross-platform.
Think about what happens when I want to install tmux. I could add tmux to the apt-get line. Easy. But what about when I need a tool that isn’t in the Debian repos? Or a tool that requires different commands on macOS vs. Linux? The shell script approach means writing more if/elif blocks, more error handling, more complexity in a format that’s hard to maintain.
I needed something that’s designed for exactly this kind of job.
Why Ansible#
Ansible is a configuration management tool — usually associated with managing remote servers. But there’s nothing stopping you from running it locally. And that’s what makes it interesting for dotfile setups.
Here’s what Ansible gives us that shell scripts don’t:
- Idempotency by default. The
aptmodule won’t reinstall a package that’s already there. No need for manual checks. - Declarative syntax. You describe what should be installed, not the steps to get there.
- Built-in OS detection. Ansible gathers system facts automatically —
ansible_os_family,ansible_distribution,ansible_architecture— so conditional logic is clean and readable. - Roles for organization. As the list of tools grows, each tool can have its own role with separate tasks, variables, and handlers.
For now, I’m starting simple — a single playbook with a single task. But the structure is there to grow.
Adding Ansible to the Setup#
The first step is getting Ansible itself installed. Since our run_once_install-packages.sh already handles apt packages, I added ansible to the list:
$SUDO apt-get install -y \
zsh \
ansibleThat’s it. Ansible is now available after the first chezmoi apply.
The Playbook#
Next, I created an ansible/ directory in the chezmoi source and added a playbook:
---
- name: Setup workstation
hosts: localhost
connection: local
become: true
tasks:
- name: Install tmux
ansible.builtin.apt:
name: tmux
state: present
when: ansible_os_family == "Debian"It’s simple — and intentionally so. One playbook, one task: install tmux on Debian. The become: true ensures it runs with elevated privileges. The when condition limits it to Debian-based systems, which is all I’m running right now.
The important part is the structure. This playbook will grow. Each new tool becomes a new task — or eventually, a new role. But you don’t need to build the whole framework up front. Start with what you need today.
Telling chezmoi Not to Copy the Playbook#
There’s a small catch. Chezmoi’s job is to copy files into your home directory. But we don’t want ansible/playbook.yml appearing in ~/ansible/playbook.yml. The playbook should live in the chezmoi source repo, not in your home.
That’s what .chezmoiignore is for:
ansible/One line. Chezmoi will now ignore the entire ansible/ directory during chezmoi apply, but the files are still in the repo and available for scripts to use.
Triggering the Playbook With run_onchange#
This is where it gets interesting. We need chezmoi to run the playbook, but only when it changes. Enter run_onchange_after_ scripts.
We’ve seen run_once_ before — it runs a script once per machine and skips it on future applies unless the script content changes. But run_onchange_after_ is different in a useful way: it watches a different file for changes and re-runs when that file changes. The after part means it runs after chezmoi has applied all the dotfiles, not before.
Here’s .chezmoiscripts/run_onchange_after_ansible-setup.sh:
#!/bin/bash
# ansible/playbook.yml hash: {{ include "ansible/playbook.yml" | sha256sum }}
set -euo pipefail
ansible-playbook --connection=local --inventory 127.0.0.1, \
"${CHEZMOI_SOURCE_DIR}/ansible/playbook.yml"The magic is in the comment on line 2. That {{ include "ansible/playbook.yml" | sha256sum }} is a chezmoi template. It calculates the SHA-256 hash of the playbook file and embeds it in the script. When you change the playbook, the hash changes, which changes the script content, which triggers chezmoi to re-run it.
The ansible-playbook command runs the playbook locally with --connection=local and a localhost-only inventory. The trailing comma after 127.0.0.1 is important — it tells Ansible to treat this as a list, not a filename.
The Full Picture#
Let’s step back and see how all the pieces fit together. When you run chezmoi init --apply on a fresh machine, here’s the sequence:
run_once_install-packages.shruns first — installs zsh, age, and now ansible via apt- Age decrypts the SSH keys
run_once_configure-git-repo.shsets up git identity and SSH- Dotfiles are applied to the home directory
run_onchange_after_ansible-setup.shruns after everything else — executes the Ansible playbook, which installs tmux
The after in step 5 is deliberate. Ansible needs to be installed (step 1) before the playbook can run. The run_onchange_after_ prefix ensures correct ordering.
Testing It#
Same as always — disposable container:
podman run -it --rm debian:bookworm-slim bash
apt update -y && apt upgrade -y && apt install curl git nano -y
mkdir -p ~/.config/age
chmod 700 ~/.config/age
touch ~/.config/age/dotfiles-repo-key.txt
chmod 600 ~/.config/age/dotfiles-repo-key.txt
nano ~/.config/age/dotfiles-repo-key.txt # paste in key
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply knuth-info
zshAfter chezmoi finishes, verify that tmux is installed:
tmux -V # → tmux 3.3a (or similar)If you see the version number, the full chain worked: chezmoi installed ansible, then ansible installed tmux.
What We’ve Achieved So Far#
After chezmoi init --apply, a fresh machine now has:
- zsh installed and set as default shell
- The correct version of age, installed from source
- SSH keys decrypted and in place
- Git identity configured for the dotfiles repo
- SSH key linked to git, remote switched to SSH
- Ansible installed and used to manage software packages
- tmux installed via Ansible playbook
The manual steps haven’t changed — place the age key, run chezmoi init --apply. But the result keeps expanding.
What’s Next#
The playbook is deliberately minimal right now — one task, one package. But the path forward is clear. As I add more tools to the setup, each one becomes a task in the playbook or, when the logic gets more involved, its own Ansible role. Cross-platform support (macOS, other distros) becomes a matter of adding when conditions or role variables, not rewriting shell scripts.
Next up, I’ll start looking at the tools that actually need configuring — starting with tmux itself.
