Skip to content
matthewcm.dev

    ↑ ↓ move · ↵ open · tab scopeopen graph →

    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.

    Growingtended planted 4 min read
    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 Brewfile run by brew bundle or the community.general.homebrew and homebrew_cask modules, 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-vault for 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

    ↪ Links to (2)