Ansible for the Homelab — Part 3: Roles and Structure

Ansible for the Homelab — Part 3: Roles and Structure
Ansible for the Homelab — The Daily Stomp

In Part 2 we bootstrapped a new machine using a single playbook - one file, one long list of tasks. It worked great. But that file is 150 lines, and every time you want to bootstrap another machine you're pointing at the same monolith. Want to reuse part of it in a different playbook? You copy-paste. Need to find the fzf installation tasks six months from now? You scroll.

Roles fix this.

What Is a Role?

A role is a named, self-contained unit of Ansible logic. Tasks, files, handlers, and variables are organized into a predictable directory structure that Ansible knows how to find automatically.

roles/
└── bootstrap/
    ├── tasks/
    │   └── main.yml      ← the tasks themselves
    ├── files/            ← static files to copy to hosts
    ├── handlers/
    │   └── main.yml      ← restart triggers, etc.
    ├── defaults/
    │   └── main.yml      ← overridable defaults
    └── vars/
        └── main.yml      ← role-internal variables

You don't need every directory - Ansible only uses the ones that exist. At minimum, you need tasks/main.yml.

Refactoring bootstrap.yml

Right now the bootstrap logic lives entirely inside playbooks/bootstrap.yml. Let's move it into a role.

Create the structure:

mkdir -p roles/bootstrap/tasks
mkdir -p roles/bootstrap/files

Create roles/bootstrap/tasks/main.yml and paste everything from the tasks: key in the original playbook. The tasks themselves don't change - they just move.

# roles/bootstrap/tasks/main.yml
---
- name: Update apt cache
  apt:
    update_cache: yes
    cache_valid_time: 3600

- name: Install core packages
  apt:
    name:
      - git
      - zsh
      - curl
      - stow
      - direnv
      - zsh-autosuggestions
      - zsh-syntax-highlighting
    state: present

# ... rest of your tasks

One small change: the original playbook copied ../files/zshrc.local. In a role, static files live in roles/bootstrap/files/ and Ansible finds them automatically - no path prefix needed:

cp files/zshrc.local roles/bootstrap/files/
# Before (flat playbook):
src: ../files/zshrc.local

# After (inside the role):
src: zshrc.local

Now replace playbooks/bootstrap.yml with a clean caller:

---
- name: Bootstrap Unix Instances
  hosts: all
  become: yes

  roles:
    - bootstrap

That's it. The playbook says what hosts and which role. The role handles how. Run it exactly as before:

ansible-playbook playbooks/bootstrap.yml --limit puffin.lan

Splitting Large Task Files

If tasks/main.yml grows unwieldy, split it into focused files and import them:

# roles/bootstrap/tasks/main.yml
---
- import_tasks: packages.yml
- import_tasks: tools.yml
- import_tasks: dotfiles.yml
- import_tasks: ssh.yml

Same execution order, but now each file has a clear job. Finding the fzf setup means opening tools.yml, not searching 150 lines.

defaults/ vs vars/

Two directories, two different purposes.

defaults/main.yml holds values that callers are expected to override - things like timezone or a repo URL. They have the lowest precedence in Ansible's variable hierarchy, so anything in group_vars, host_vars, or the playbook itself wins:

# roles/bootstrap/defaults/main.yml
---
bootstrap_timezone: "America/New_York"
bootstrap_dotfiles_repo: "[email protected]:{your_username}/dotfiles.git"

vars/main.yml is for role-internal values that shouldn't be overridden. Higher precedence, hands-off.

What You Have Now

roles/
├── bootstrap/          ← newly refactored from Part 2
├── cloudflare-dns/     ← already a role ✅
└── cloudflare-tunnel/  ← already a role ✅

playbooks/
├── bootstrap.yml       ← now: hosts + roles, nothing else
└── docker-setup.yml    ← good candidate for a role next

Any playbook that needs to bootstrap a machine just lists the role. Write once, run anywhere.

What's Next

Part 4 covers secrets management - how to use Ansible Vault to encrypt API keys and passwords so you can commit your playbooks to git without exposing sensitive values.