Ansible roles and best practices
In this series (10 parts)
A single playbook works fine for five tasks. It falls apart at fifty. You end up scrolling through hundreds of lines of YAML hunting for the task that configures PostgreSQL. Roles fix this by splitting playbooks into self-contained units that you can reuse across projects, share with your team, and test independently.
Role directory structure
A role is a directory with a specific layout. Ansible automatically loads files from each subdirectory.
roles/
nginx/
defaults/
main.yml # lowest-priority variables
vars/
main.yml # high-priority variables
tasks/
main.yml # task list
handlers/
main.yml # handler definitions
templates/
nginx.conf.j2 # Jinja2 templates
files/
index.html # static files
meta/
main.yml # role metadata and dependencies
molecule/
default/
molecule.yml # test configuration
graph TD A[Role: nginx] --> B[defaults/main.yml] A --> C[vars/main.yml] A --> D[tasks/main.yml] A --> E[handlers/main.yml] A --> F[templates/] A --> G[files/] A --> H[meta/main.yml] A --> I[molecule/]
Each directory serves a single purpose. Ansible loads them automatically when the role is included.
Create a role scaffold with:
ansible-galaxy role init roles/nginx
Using roles in playbooks
Reference roles in your playbook with the roles keyword:
---
- name: Configure web tier
hosts: webservers
become: true
roles:
- nginx
- certbot
- app_deploy
Ansible runs each role in order. Within a role it executes tasks/main.yml, registers handlers from handlers/main.yml, and loads variables from defaults/ and vars/. You can also include roles dynamically:
tasks:
- name: Apply nginx role
ansible.builtin.include_role:
name: nginx
vars:
nginx_port: 8080
Variable precedence
Ansible has 22 levels of variable precedence. Memorizing all of them is unnecessary. Knowing the practical hierarchy is essential.
Lowest priority Highest priority
| |
v v
role defaults -> inventory vars -> playbook vars -> role vars -> extra vars (-e)
The rule of thumb: put sane defaults in defaults/main.yml so consumers can override them easily. Put constants that should never change in vars/main.yml. Use extra vars on the command line for one-off overrides.
# roles/nginx/defaults/main.yml
nginx_worker_processes: auto
nginx_worker_connections: 1024
nginx_client_max_body_size: 10m
nginx_server_name: localhost
nginx_root: /var/www/html
A playbook consuming this role overrides just what it needs:
roles:
- role: nginx
vars:
nginx_server_name: app.example.com
nginx_root: /var/www/app
Jinja2 templates
Templates let you generate configuration files with dynamic values. Ansible uses Jinja2, the same engine behind Flask and Django templates.
# roles/nginx/templates/nginx.conf.j2
worker_processes {{ nginx_worker_processes }};
events {
worker_connections {{ nginx_worker_connections }};
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
client_max_body_size {{ nginx_client_max_body_size }};
server {
listen 80;
server_name {{ nginx_server_name }};
root {{ nginx_root }};
index index.html;
location / {
try_files $uri $uri/ =404;
}
{% if nginx_enable_ssl | default(false) %}
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/{{ nginx_server_name }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ nginx_server_name }}/privkey.pem;
{% endif %}
}
}
The task that renders this template:
# roles/nginx/tasks/main.yml
- name: Deploy Nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
validate: nginx -t -c %s
notify: Reload Nginx
The validate parameter runs nginx -t against the rendered file before putting it in place. If the syntax check fails, the task fails and the old config stays untouched.
Ansible Galaxy
Galaxy is a public repository of community roles. Install a role with:
ansible-galaxy install geerlingguy.docker
Pin versions in a requirements file:
# requirements.yml
roles:
- name: geerlingguy.docker
version: "6.1.0"
- name: geerlingguy.postgresql
version: "3.4.0"
collections:
- name: amazon.aws
version: "6.5.0"
Install everything with:
ansible-galaxy install -r requirements.yml
Collections bundle modules, plugins, and roles together. The amazon.aws collection contains all the AWS modules. Galaxy collections follow semantic versioning, so pin them to avoid surprises.
Vault for secrets
Hardcoding passwords in YAML files is a security incident waiting to happen. Ansible Vault encrypts sensitive data with AES-256.
Create an encrypted variables file:
ansible-vault create group_vars/production/vault.yml
Inside that file, store secrets with a vault_ prefix:
vault_db_password: supersecretpassword
vault_api_key: ak_live_abc123def456
Reference them in your regular variables file:
# group_vars/production/vars.yml
db_password: "{{ vault_db_password }}"
api_key: "{{ vault_api_key }}"
Run the playbook with the vault password:
ansible-playbook site.yml --ask-vault-pass
For CI pipelines, store the vault password in a file and reference it:
ansible-playbook site.yml --vault-password-file ~/.vault_pass
Never commit the vault password file to version control. Add it to .gitignore.
Molecule for testing
Molecule is the standard framework for testing Ansible roles. It creates ephemeral instances, applies your role, runs verification tests, and tears everything down.
Initialize Molecule in a role:
cd roles/nginx
molecule init scenario --driver-name docker
This creates molecule/default/ with configuration files. The main config:
# molecule/default/molecule.yml
dependency:
name: galaxy
driver:
name: docker
platforms:
- name: ubuntu-test
image: ubuntu:22.04
pre_build_image: true
command: /sbin/init
privileged: true
provisioner:
name: ansible
verifier:
name: ansible
Write a verification playbook:
# molecule/default/verify.yml
---
- name: Verify Nginx role
hosts: all
gather_facts: false
tasks:
- name: Check Nginx is installed
ansible.builtin.command: nginx -v
register: nginx_version
changed_when: false
- name: Assert Nginx is running
ansible.builtin.service_facts:
- name: Verify Nginx service state
ansible.builtin.assert:
that:
- "'nginx' in services"
- "services['nginx'].state == 'running'"
- name: Check port 80 is listening
ansible.builtin.wait_for:
port: 80
timeout: 5
Run the full test cycle:
molecule test
flowchart LR A[Create instance] --> B[Prepare] B --> C[Converge] C --> D[Idempotence check] D --> E[Verify] E --> F[Destroy]
Molecule runs create, converge, idempotence, verify, and destroy in sequence. Every step must pass.
The idempotence check runs the playbook twice. If any task reports “changed” on the second run, the test fails. This catches non-idempotent tasks before they reach production.
A complete role example
Here is the full nginx role with all the pieces connected:
# roles/nginx/meta/main.yml
galaxy_info:
author: your_name
description: Install and configure Nginx
min_ansible_version: "2.14"
platforms:
- name: Ubuntu
versions:
- jammy
dependencies: []
# roles/nginx/tasks/main.yml
---
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
- name: Deploy Nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
validate: nginx -t -c %s
notify: Reload Nginx
- name: Deploy site files
ansible.builtin.copy:
src: index.html
dest: "{{ nginx_root }}/index.html"
owner: www-data
group: www-data
mode: "0644"
- name: Ensure Nginx is running
ansible.builtin.service:
name: nginx
state: started
enabled: true
# roles/nginx/handlers/main.yml
---
- name: Reload Nginx
ansible.builtin.service:
name: nginx
state: reloaded
The playbook that ties it together:
---
- name: Production web servers
hosts: webservers
become: true
roles:
- role: nginx
vars:
nginx_server_name: app.example.com
nginx_root: /var/www/app
nginx_enable_ssl: true
When Ansible beats Terraform
Terraform excels at provisioning cloud resources. Ansible excels at configuring what runs on those resources. They complement each other, but sometimes overlap.
| Scenario | Better tool |
|---|---|
| Create VPCs, subnets, security groups | Terraform |
| Install packages, deploy configs | Ansible |
| Manage Kubernetes manifests | Terraform or kubectl |
| One-off ad hoc commands across servers | Ansible |
| Database schema migrations | Ansible |
| Cloud resource lifecycle | Terraform |
Use Terraform to create the servers. Use Ansible to configure them. Pass the Terraform output (IP addresses, hostnames) into Ansible’s dynamic inventory. This separation keeps each tool doing what it does best.
What comes next
You can now write reusable roles, encrypt secrets, template configurations, and test everything with Molecule. The next article covers Packer for machine images, where you will learn to bake pre-configured AMIs that combine Terraform provisioning with Ansible configuration into a single artifact ready for deployment.