Provisioning My Laptop with Ansible
An ansible-pull playbook that turns a fresh Ubuntu install into my dev machine: tagged tasks, a per-organisation git identity, pinned versions, a self-healing cron loop, and the idempotency lessons I learnt on the way.
On this page
My dotfiles handle configuration (Dotfiles: An Environment That Follows Me), but they don’t install anything. In autumn 2022 I was rebuilding Linux machines often enough that the README checklist of “install this, then this” started to annoy me, so I wrote an Ansible playbook for my own laptop. It’s small, a bit rough in places, and taught me more about infrastructure as code than any tutorial.
ansible-pull and a local playbook
Most Ansible guides start with a control machine pushing to a fleet of servers over SSH. For a laptop you don’t need any
of that. ansible-pull clones a git repo and runs a playbook against the machine it’s running on:
sudo ansible-pull -U <your-ansible-repo>
The playbook targets localhost with a local connection, updates apt first, then includes one task file per concern:
- hosts: localhost
connection: local
become: true
pre_tasks:
- name: Update Apt
apt:
force_apt_get: true
update_cache: true
state: present
tags: [install, core]
tasks:
- include: tasks/git-setup.yml
- include: tasks/core-setup.yml
- include: tasks/fonts-setup.yml
- include: tasks/nvm-setup.yml
- include: tasks/lazygit-setup.yml
- include: tasks/zsh-setup.yml
- include: tasks/nvim-setup.yml
- include: tasks/productivity-tools.yml
- include: tasks/dotfiles.yml
- include: tasks/npm-packages.yml
- include: tasks/automation.yml
(include was the idiom then; today you’d write include_tasks or import_tasks.) The task files cover build tools,
fonts, Node via nvm, lazygit, zsh with oh-my-zsh and powerlevel10k, Neovim built from source, the everyday CLI tools
(tmux, fzf, ripgrep, exa, ranger, tldr), and finally cloning my dotfiles and running their Stow script. There’s a Slack task too,
commented out of the main playbook.
Tags: run only the bit you need
Every task carries tags such as install, core, neovim, dotfiles or zsh. That turned out to be the most useful
design decision, because most days I don’t want to rebuild the whole machine. I want to update one thing:
sudo ansible-pull -U <your-ansible-repo> --tags neovim
The zsh tasks share one tag list through a YAML anchor, so they can’t drift apart:
- name: Install ZSH
apt: name=zsh
tags: &tags_for_zsh_tasks ['install', 'productivity', 'dotfiles', 'zsh']
- name: Change shell to zsh
shell: chsh -s $(which zsh)
tags: *tags_for_zsh_tasks
A day-one trick: git identity per organisation
My favourite small idea in the repo. Git identity is set with git_config tasks, grouped by tag: one tag for my personal
identity, another for a work identity. On a new work laptop, the first thing I run is the tag for that organisation:
- name: Git work email
git_config:
name: user.email
scope: global
value: "<work-email>"
tags: [git-work]
- name: Git personal email
git_config:
name: user.email
scope: global
value: "you@example.com"
tags: [git-personal]
sudo ansible-pull -U <your-ansible-repo> --tags git-work
No more first commits at a new company made with your personal email. Today I’d go one step further and use git’s
includeIf to switch identity by directory, so personal and work repos can live on the same machine.
Pinning versions
Some things were pinned on purpose: nvm at a specific tag, Node 14 (what my projects needed at the time), and lazygit from a specific release tarball.
- name: Install Lazygit
ansible.builtin.unarchive:
src: "https://github.com/jesseduffield/lazygit/releases/download/v0.35/lazygit_0.35_Linux_x86_64.tar.gz"
dest: /usr/local/bin
remote_src: yes
mode: '0755'
tags: lazygit
Pinning makes rebuilds reproducible. The cost is that pins quietly age: Node 14 is long out of support now. A pinned version needs an owner and a reminder, or it becomes the thing that breaks on the next machine.
The self-healing loop
The most ambitious part was automation.yml. It creates a dedicated system user, gives it sudo through a drop-in file,
and installs a cron job that re-runs the playbook every ten minutes:
- name: add provisioning user
user:
name: provisioner
system: yes
- name: sudo for the provisioning user
copy:
src: files/sudoer_provisioner
dest: /etc/sudoers.d/provisioner
owner: root
group: root
mode: "0440"
- name: re-apply the playbook every 10 minutes
cron:
name: ansible auto-provision
user: provisioner
minute: "*/10"
job: ansible-pull -o -U <your-ansible-repo>
The -o flag only runs the playbook when the repo has changed. In effect it’s GitOps for a laptop: push a commit, and
within ten minutes every machine pulling that repo converges on it. Drift fixes itself.
I’ll be plain about the trade-off. The drop-in gave that user passwordless sudo for everything
(ALL=(ALL) NOPASSWD: ALL). Combined with a job that runs whatever is in a git repo, that means anyone who can push to the
repo can run anything as root on my laptop. It was fine for a personal repo only I could push to, but I’d scope it much
tighter now: limit the sudoers entry to the ansible-pull command, pull from a signed or protected branch, and keep the
user’s access to the minimum.
Idempotency, learnt the honest way
Ansible’s promise is idempotency: run the playbook twice and the second run changes nothing. Modules like apt, git
and git_config give you that for free. My shell tasks didn’t.
The Neovim task file starts with this:
- name: Remove neovim
shell: rm -rf ~/neovim
It then clones Neovim and builds it from source with make. Every run deletes the checkout and rebuilds the editor,
which is slow, reports “changed” every time, and would happily replace a working editor with a broken nightly. Fine when
I ran it by hand; not fine every ten minutes from cron.
The zsh tasks show the better pattern. Installing oh-my-zsh is a shell script, so it’s guarded by a stat check:
- name: Check that oh-my-zsh already exists
stat:
path: "{{ ansible_env.HOME }}/.oh-my-zsh"
register: oh_my_stats
- name: Oh-My-Zsh
shell: curl -L https://raw.github.com/robbyrussell/oh-my-zsh/master/tools/install.sh > ~/.oh-my-installer && chmod +x ~/.oh-my-installer && ~/.oh-my-installer
when: oh_my_stats.stat.exists == False
The rule I took away: prefer a module; if you must shell out, guard it (stat + when, or creates:), and never
put a destructive step at the top of a task that runs on a schedule.
What I’d change today
- A macOS path. I’m on a Mac now, and the playbook is Ubuntu-only. I’d add a Homebrew role, either a
Brewfilerun bybrew bundleor thecommunity.general.homebrewandhomebrew_caskmodules, and use facts to branch by OS. - Tests. Run the playbook in a container (or with Molecule) in CI on every push, twice, and fail if the second run reports changes. That catches the idempotency bugs above before they reach a real machine.
- Secrets. Nothing secret lives in the repo, which kept it safe but also meant SSH keys and tokens were manual. I’d use
ansible-vaultfor anything that must be in the repo, or better, fetch secrets at run time from a password manager’s CLI. - Roles instead of loose task files, so the same pieces can be shared, which is where Infrastructure as Code for People, Not Just Servers picks up.
Was it worth it?
Honestly, I didn’t use it for every machine afterwards. My later WSL and Mac setups were partly manual checklists again. But writing it changed how I think: my environment is something I declare, not something I remember, and the tags mean I can be productive on a new machine in the time it takes to run one command and make a coffee.
- #ansible
- #infrastructure-as-code
- #dotfiles
- #idempotency
- #developer-experience
- #gitops
↩ Linked from (2)
That gap is what pushed me towards Provisioning My Laptop with Ansible. And it's also fine. A setup that's 80% automated and 100% understood beats one that's fully automated and fragile.
I've automated my own machine for years (Dotfiles: An Environment That Follows Me, Provisioning My Laptop with Ansible). This note is a proposal, not a case study: what it would look like to take the same idea to a team…
↪ Links to (2)
Related by topic
- Growing
Books That Shaped How I Build
Career - Growingcase study
Building a Web Platform for 80 Sites
Platform engineering - Seed
Don't Read the Code? Verifying AI-Written Code
Platform engineering - Seed
Low-Code Platforms: Where Do They Belong?
Platform engineering