Ansible for the Homelab — Part 3: Roles and Structure
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.