Search…

Ansible roles and best practices

In this series (10 parts)
  1. Introduction to Infrastructure as Code
  2. Terraform fundamentals
  3. Terraform state management
  4. Terraform modules
  5. Terraform in CI/CD
  6. Ansible fundamentals
  7. Ansible roles and best practices
  8. Packer for machine images
  9. CloudFormation and CDK
  10. Managing drift and compliance

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.

ScenarioBetter tool
Create VPCs, subnets, security groupsTerraform
Install packages, deploy configsAnsible
Manage Kubernetes manifestsTerraform or kubectl
One-off ad hoc commands across serversAnsible
Database schema migrationsAnsible
Cloud resource lifecycleTerraform

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.

Start typing to search across all content
navigate Enter open Esc close