Intermediate
Learn Ansible production patterns through 27 annotated code examples. Each example demonstrates real-world automation techniques used in enterprise environments.
Group 7: Roles & Galaxy
Example 28: Basic Role Structure
Roles organize playbooks into reusable components with standardized directory structure. Each role encapsulates related tasks, variables, files, and templates for a specific function.
Core Role Components:
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Role<br/>webserver"] --> B["tasks/main.yml<br/>Task definitions"]
A --> C["handlers/main.yml<br/>Service handlers"]
A --> D["templates/<br/>Jinja2 templates"]
A --> E["files/<br/>Static files"]
style A fill:#0173B2,color:#fff
style B fill:#DE8F05,color:#fff
style C fill:#029E73,color:#fff
style D fill:#CC78BC,color:#fff
style E fill:#CA9161,color:#fff
Variable Hierarchy:
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Role<br/>webserver"] --> F["vars/main.yml<br/>Role variables"]
A --> G["defaults/main.yml<br/>Default variables"]
style A fill:#0173B2,color:#fff
style F fill:#029E73,color:#fff
style G fill:#DE8F05,color:#fff
Role Metadata:
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Role<br/>webserver"] --> H["meta/main.yml<br/>Dependencies"]
style A fill:#0173B2,color:#fff
style H fill:#CC78BC,color:#fff
Code:
Create role directory structure:
# ansible-galaxy init webserver
# Creates standard role structureroles/webserver/defaults/main.yml:
---
# Default variables (lowest precedence)
http_port: 80 # => Can be overridden by playbook vars
https_port: 443
document_root: /var/www/html
server_name: localhostroles/webserver/vars/main.yml:
---
# Role variables (higher precedence than defaults)
nginx_package: nginx # => Package name
nginx_service: nginx # => Service name
config_file: /etc/nginx/sites-available/defaultroles/webserver/tasks/main.yml:
---
# Main task file for webserver role
- name: Install web server
ansible.builtin.package:
name: "{{ nginx_package }}" # => Uses var from vars/main.yml
state: present
# => changed: [host] (installs nginx package)
- name: Create document root
ansible.builtin.file:
path: "{{ document_root }}" # => Uses var from defaults/main.yml
state: directory
mode: "0755"
# => changed: [host] (creates web root directory)
- name: Deploy configuration
ansible.builtin.template:
src: nginx.conf.j2 # => Template from templates/ directory
dest: "{{ config_file }}"
mode: "0644"
notify: restart nginx # => Trigger handler if config changes
# => changed: [host] (renders and copies template)
- name: Ensure service is running
ansible.builtin.service:
name: "{{ nginx_service }}"
state: started
enabled: true
# => ok: [host] (service already running and enabled)roles/webserver/handlers/main.yml:
---
# Handlers for service management
- name: restart nginx
ansible.builtin.service:
name: "{{ nginx_service }}"
state: restarted
# => Executes only if notified by task changesroles/webserver/templates/nginx.conf.j2:
server {
listen {{ http_port }};
server_name {{ server_name }};
root {{ document_root }};
index index.html;
}Playbook using role:
---
# webserver_playbook.yml
- name: Deploy Web Server
hosts: webservers
become: true
roles:
- webserver # => Apply webserver role
# => Executes all tasks from roles/webserver/tasks/main.ymlRun: ansible-playbook webserver_playbook.yml
Key Takeaway: Roles enable code reuse and logical organization. Use defaults/main.yml for overridable defaults, vars/main.yml for fixed values. The roles keyword automatically includes tasks, handlers, variables, and files from role directories. Role structure is standardized—any Ansible user recognizes the layout.
Why It Matters: Roles are the foundation of reusable Ansible automation at scale. Production environments with hundreds of playbooks rely on role composition to avoid code duplication and enforce standards. The standardized directory structure enables teams to collaborate effectively—any engineer can navigate an unfamiliar role instantly. Galaxy’s 25,000+ community roles demonstrate this pattern’s universal adoption.
Example 29: Role Variables and Precedence
Role variables come from multiple sources with specific precedence rules. Understanding precedence prevents unexpected values in complex playbooks.
Code:
roles/app/defaults/main.yml:
---
# Defaults (precedence: 2 - lowest for roles)
app_port: 8080
app_env: development
debug_enabled: falseroles/app/vars/main.yml:
---
# Role vars (precedence: 18)
app_name: MyApplication # => Fixed role variable
config_dir: /etc/myappPlaybook:
---
# role_precedence.yml
- name: Role Variable Precedence
hosts: localhost
gather_facts: false
# Play vars (precedence: 15)
vars:
app_port: 9090 # => Overrides defaults but not vars
app_env: staging
roles:
- role: app
# Role parameters (precedence: 17)
vars:
debug_enabled: true # => Overrides defaults but not role vars
# => Final values:
# => app_name: MyApplication (from vars/main.yml - precedence 18)
# => app_port: 9090 (from play vars - precedence 15)
# => app_env: staging (from play vars - precedence 15)
# => debug_enabled: true (from role parameters - precedence 17)
# => config_dir: /etc/myapp (from vars/main.yml - precedence 18)
tasks:
- name: Display final configuration
ansible.builtin.debug:
msg: |
App: {{ app_name }}
Port: {{ app_port }}
Environment: {{ app_env }}
Debug: {{ debug_enabled }}
Config Dir: {{ config_dir }}
# => Shows merged configuration from all sourcesRun with extra-vars (precedence: 22 - highest):
ansible-playbook role_precedence.yml -e "app_port=3000"
# => app_port becomes 3000 (extra-vars override everything)Key Takeaway: Variable precedence from lowest to highest: role defaults < inventory vars < play vars < role vars < extra-vars. Use defaults/main.yml for overridable configuration, vars/main.yml for fixed role internals. Pass role-specific overrides using role parameters (vars: under role declaration).
Why It Matters: Variable precedence conflicts cause 40% of Ansible bugs in production. Understanding the 22-level precedence hierarchy prevents unexpected overrides when combining roles, inventories, and playbooks. The extra-vars highest-precedence rule enables CI/CD systems to safely override any configuration without modifying playbook source code.
Example 30: Role Dependencies
Roles can depend on other roles using meta/main.yml. Dependencies install automatically and execute before the dependent role.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Database Role<br/>Applied"] --> B["Check Dependencies<br/>meta/main.yml"]
B --> C["Execute Common<br/>Role First"]
B --> D["Execute Firewall<br/>Role Second"]
C --> E["Database Tasks<br/>Execute Last"]
D --> E
style A fill:#0173B2,color:#fff
style B fill:#DE8F05,color:#fff
style C fill:#029E73,color:#fff
style D fill:#029E73,color:#fff
style E fill:#CC78BC,color:#fff
Code:
roles/database/meta/main.yml:
---
# Database role dependencies
dependencies:
- role: common # => Always execute common role first
vars:
ntp_server: time.example.com # => Pass variables to dependency
- role: firewall # => Execute firewall role second
vars:
allowed_ports:
- 5432 # => Open PostgreSQL portroles/common/tasks/main.yml:
---
# Common role tasks
- name: Update package cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
when: ansible_os_family == "Debian"
# => Updates apt cache if stale
- name: Install common packages
ansible.builtin.package:
name:
- curl
- vim
- git
state: present
# => Installs base utilitiesroles/firewall/tasks/main.yml:
---
# Firewall role tasks
- name: Install UFW
ansible.builtin.package:
name: ufw
state: present
when: ansible_os_family == "Debian"
- name: Allow SSH
community.general.ufw:
rule: allow
port: "22"
proto: tcp
# => Ensure SSH access before enabling firewall
- name: Allow application ports
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: "{{ allowed_ports }}" # => Ports from role dependency vars
# => Opens ports passed from database role
- name: Enable UFW
community.general.ufw:
state: enabled
# => Activates firewall with configured rulesroles/database/tasks/main.yml:
---
# Database role tasks (executes AFTER dependencies)
- name: Install PostgreSQL
ansible.builtin.package:
name: postgresql
state: present
# => Installs database after common packages and firewall config
- name: Ensure PostgreSQL is running
ansible.builtin.service:
name: postgresql
state: started
enabled: true
# => Starts database servicePlaybook:
---
# database_playbook.yml
- name: Deploy Database Server
hosts: dbservers
become: true
roles:
- database # => Applies database role
# => Execution order: common -> firewall -> database
# => Dependencies execute first automaticallyRun: ansible-playbook database_playbook.yml
Key Takeaway: Role dependencies in meta/main.yml ensure prerequisite roles execute first. Dependencies run once even if multiple roles depend on the same role (unless allow_duplicates: true). Pass variables to dependencies using vars: parameter. This pattern enables composable, layered automation.
Why It Matters: Role dependencies automate prerequisite installation in layered architectures. Database roles depend on security hardening, monitoring, and backup roles—dependencies ensure the correct execution order without manual orchestration. The allow_duplicates setting prevents redundant execution when multiple roles share common dependencies, reducing playbook runtime by 30-50% in complex deployments.
Example 31: Ansible Galaxy - Using Community Roles
Ansible Galaxy hosts thousands of community-maintained roles. Use ansible-galaxy command to install, list, and manage roles from Galaxy or private repositories.
Code:
Install role from Galaxy:
# Install specific role
ansible-galaxy install geerlingguy.nginx
# Install with version constraint
ansible-galaxy install geerlingguy.nginx,2.8.0
# Install multiple roles from requirements file
ansible-galaxy install -r requirements.ymlrequirements.yml:
---
# Galaxy roles
- name: geerlingguy.nginx # => Role from Ansible Galaxy
version: 2.8.0 # => Specific version (optional)
- name: geerlingguy.postgresql
version: 3.4.1
# Git repository roles
- src: https://github.com/example/ansible-role-custom.git
name: custom_role # => Local name for role
version: main # => Git branch/tag
# Local roles path override
- src: ../local-roles/internal-role # => Relative path to local role
name: internal_rolePlaybook using Galaxy role:
---
# galaxy_roles.yml
- name: Use Galaxy Roles
hosts: webservers
become: true
roles:
- role: geerlingguy.nginx # => Galaxy role (namespace.rolename)
vars:
nginx_vhosts: # => Variables expected by role
- listen: "80"
server_name: "example.com"
root: "/var/www/html"
# => Role tasks execute with provided configuration
tasks:
- name: Verify nginx is running
ansible.builtin.service:
name: nginx
state: started
# => Check service state after role executionList installed roles:
# List all installed roles
ansible-galaxy list
# Show role information
ansible-galaxy info geerlingguy.nginx
# Remove installed role
ansible-galaxy remove geerlingguy.nginxKey Takeaway: Ansible Galaxy accelerates automation by providing pre-built, tested roles for common software stacks. Use requirements.yml for version-pinned, reproducible role installations. Always review Galaxy role documentation for required variables and dependencies. Popular roles like geerlingguy.* are production-ready and well-maintained.
Why It Matters: Galaxy accelerates development by providing battle-tested roles for common stacks—nginx, PostgreSQL, Docker, Kubernetes. The geerlingguy.* namespace alone saves enterprises thousands of engineering hours annually. Version pinning in requirements.yml ensures reproducible deployments across dev, staging, and production environments.
Example 32: Creating Distributable Roles
Create reusable roles for sharing via Galaxy or private repositories. Follow role structure conventions and include comprehensive metadata.
Code:
Initialize role with Galaxy template:
ansible-galaxy init --init-path roles/ myapp
# Creates: roles/myapp/{tasks,handlers,templates,files,vars,defaults,meta,tests}roles/myapp/meta/main.yml:
---
galaxy_info:
author: Your Name
description: Deploys and configures MyApp
license: MIT
min_ansible_version: 2.14
platforms: # => Supported platforms
- name: Ubuntu
versions:
- focal # => 20.04
- jammy # => 22.04
- name: Debian
versions:
- bullseye
- bookworm
galaxy_tags: # => Galaxy search tags
- web
- application
- deployment
dependencies: [] # => List role dependenciesroles/myapp/README.md:
# Ansible Role: myapp
Deploys and configures MyApp web application.
## Requirements
- Ansible 2.14+
- Ubuntu 20.04+ or Debian 11+
- Python 3.8+
## Role Variables
Available variables with defaults (defaults/main.yml):
```yaml
myapp_version: "1.0.0" # Application version
myapp_port: 8080 # HTTP port
myapp_user: myapp # System user
myapp_install_dir: /opt/myapp # Installation directory
```Dependencies
None.
Example Playbook
- hosts: appservers
roles:
- role: myapp
vars:
myapp_version: "2.0.0"
myapp_port: 9090License
MIT
Author
Your Name
**`roles/myapp/defaults/main.yml`**:
```yaml
---
# Overridable defaults
myapp_version: "1.0.0"
myapp_port: 8080
myapp_user: myapp
myapp_group: myapp
myapp_install_dir: /opt/myapp
myapp_config_file: /etc/myapp/config.ymlroles/myapp/tasks/main.yml:
---
# Main tasks
- name: Include OS-specific variables
ansible.builtin.include_vars: "{{ ansible_os_family }}.yml"
# => Loads vars/Debian.yml or vars/RedHat.yml based on OS
- name: Create application user
ansible.builtin.user:
name: "{{ myapp_user }}"
group: "{{ myapp_group }}"
system: true
create_home: false
# => Creates system user for application
- name: Create installation directory
ansible.builtin.file:
path: "{{ myapp_install_dir }}"
state: directory
owner: "{{ myapp_user }}"
group: "{{ myapp_group }}"
mode: "0755"
# => Creates app installation directory
- name: Deploy application
ansible.builtin.get_url:
url: "https://releases.example.com/myapp-{{ myapp_version }}.tar.gz"
dest: "/tmp/myapp-{{ myapp_version }}.tar.gz"
# => Downloads application release
- name: Extract application
ansible.builtin.unarchive:
src: "/tmp/myapp-{{ myapp_version }}.tar.gz"
dest: "{{ myapp_install_dir }}"
remote_src: true
owner: "{{ myapp_user }}"
group: "{{ myapp_group }}"
# => Extracts to installation directory
- name: Deploy configuration
ansible.builtin.template:
src: config.yml.j2
dest: "{{ myapp_config_file }}"
owner: "{{ myapp_user }}"
group: "{{ myapp_group }}"
mode: "0640"
notify: restart myapp
# => Renders configuration templateTest role locally:
---
# tests/test.yml
- hosts: localhost
become: true
roles:
- myappRun test: ansible-playbook tests/test.yml
Key Takeaway: Distributable roles require comprehensive metadata in meta/main.yml and clear documentation in README.md. Use defaults/main.yml for all configurable parameters with sensible defaults. Include OS-specific variable files for cross-platform compatibility. Test roles thoroughly before publishing to Galaxy.
Why It Matters: Distributable roles enable internal reuse across multiple projects and teams. Platform teams publish standardized roles to private Galaxy servers, enforcing security policies and operational best practices organization-wide. Comprehensive meta/main.yml metadata and platform compatibility matrices prevent runtime failures when roles are deployed to heterogeneous infrastructures.
Example 33: Role Include and Import
Dynamic role inclusion enables conditional role application and runtime role selection. include_role loads at runtime, import_role loads at parse time.
Code:
---
# dynamic_roles.yml
- name: Dynamic Role Inclusion
hosts: localhost
gather_facts: true
vars:
server_type: webserver # => Dynamic role selector
enable_monitoring: true
tasks:
# Static import (parse-time)
- name: Import role statically
ansible.builtin.import_role:
name: common # => Always imported (even if skipped)
# => Tasks parsed immediately, supports tags
# Dynamic include (runtime)
- name: Include role dynamically
ansible.builtin.include_role:
name: "{{ server_type }}" # => Role name from variable
# => Role loaded at runtime based on variable value
# Conditional role inclusion
- name: Include monitoring role if enabled
ansible.builtin.include_role:
name: monitoring
when: enable_monitoring # => Include only if condition true
# => Skipped if enable_monitoring is false
# Include specific tasks from role
- name: Include specific role tasks
ansible.builtin.include_role:
name: webserver
tasks_from: install # => Include tasks/install.yml only
# => Executes roles/webserver/tasks/install.yml (not main.yml)
# Apply role to subset of hosts
- name: Include role for specific hosts
ansible.builtin.include_role:
name: database
when: "'dbservers' in group_names" # => Only on hosts in dbservers group
# => Role applies selectively based on host membership
# Include role with custom variables
- name: Include role with vars
ansible.builtin.include_role:
name: application
vars:
app_version: "2.0.0" # => Override role defaults
app_port: 9090
# => Variables scoped to this role inclusion
# Loop over roles
- name: Include multiple roles dynamically
ansible.builtin.include_role:
name: "{{ item }}"
loop:
- logging
- metrics
- tracing
# => Applies each role in sequenceRun: ansible-playbook dynamic_roles.yml
Key Takeaway: Use include_role for conditional or dynamic role application at runtime. Use import_role for static inclusion with tag support. The tasks_from parameter allows partial role inclusion—useful for breaking large roles into logical task files. Dynamic role selection enables playbooks that adapt to host groups or runtime conditions.
Why It Matters: Dynamic role inclusion enables playbooks that adapt to runtime conditions—different roles for production vs development, or OS-specific roles selected by ansible_os_family. The tasks_from parameter allows partial role execution, essential for large roles where only specific functionality is needed (e.g., certificate renewal without full web server reconfiguration).
Group 8: Handlers & Notifications
Example 34: Handler Basics
Handlers execute once at the end of a play, triggered by task changes. Prevent redundant service restarts and enable efficient orchestration.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Task 1<br/>Change Config"] -->|notify| B["Handler Queue"]
C["Task 2<br/>Change Template"] -->|notify| B
D["Task 3<br/>No Change"] -.->|skip| B
B --> E["Play End"]
E --> F["Execute Handler Once"]
style A fill:#0173B2,color:#fff
style C fill:#0173B2,color:#fff
style D fill:#CA9161,color:#fff
style B fill:#DE8F05,color:#fff
style E fill:#029E73,color:#fff
style F fill:#CC78BC,color:#fff
Code:
---
# handlers_basic.yml
- name: Handler Basics
hosts: localhost
become: true
gather_facts: false
handlers:
# Handler definition
- name: restart nginx # => Handler name (used in notify)
ansible.builtin.service:
name: nginx
state: restarted
# => Executes ONLY if notified and ONLY ONCE per play
tasks:
# Task that notifies handler
- name: Update nginx configuration
ansible.builtin.copy:
dest: /etc/nginx/nginx.conf
content: |
events {}
http {
server {
listen 8080;
}
}
notify: restart nginx # => Queue handler if task changes
# => changed: [localhost] -> handler queued
# => ok: [localhost] -> handler not queued
# Another task notifying same handler
- name: Update site configuration
ansible.builtin.copy:
dest: /etc/nginx/sites-available/default
content: |
server {
listen 80;
root /var/www/html;
}
notify: restart nginx # => Queue same handler again
# => Handler still executes only ONCE at end of play
# Task without notification
- name: Create web root
ansible.builtin.file:
path: /var/www/html
state: directory
mode: "0755"
# => No notify, handler not affected
# Handler execution happens HERE (after all tasks complete)
# => restart nginx executes once if ANY notifying task changedRun: ansible-playbook handlers_basic.yml --ask-become-pass
Key Takeaway: Handlers execute once at play end even if notified multiple times. This prevents redundant service restarts when multiple configuration changes occur. Handlers only execute if notifying tasks report “changed” status—idempotent tasks won’t trigger handlers unnecessarily.
Why It Matters: Handlers prevent cascading service restarts that cause production outages. Without handlers, 10 config changes trigger 10 nginx restarts—handlers consolidate to one restart at play end. This pattern is critical in zero-downtime deployments where service disruptions must be minimized and controlled.
Example 35: Handler Notification Patterns
Multiple handlers can be triggered by a single task. Handlers execute in definition order, not notification order.
Code:
---
# handler_patterns.yml
- name: Handler Notification Patterns
hosts: localhost
become: true
gather_facts: false
handlers:
# Multiple handlers
- name: restart nginx
ansible.builtin.service:
name: nginx
state: restarted
# => Handler 1
- name: reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
# => Handler 2
- name: restart php-fpm
ansible.builtin.service:
name: php-fpm
state: restarted
# => Handler 3
- name: clear cache
ansible.builtin.file:
path: /var/cache/app
state: absent
# => Handler 4
tasks:
# Single notification
- name: Update nginx config
ansible.builtin.copy:
dest: /etc/nginx/nginx.conf
content: "events {}\nhttp {}\n"
notify: restart nginx # => Notify one handler
# => Queues restart nginx only
# Multiple notifications
- name: Update PHP config
ansible.builtin.copy:
dest: /etc/php/php.ini
content: |
[PHP]
memory_limit = 256M
notify:
- restart php-fpm # => Notify first handler
- clear cache # => Notify second handler
# => Queues both handlers if task changes
# Notification with list syntax
- name: Update application code
ansible.builtin.copy:
dest: /var/www/app/index.php
content: "<?php phpinfo(); ?>"
notify:
- reload nginx # => Reload instead of restart
- clear cache
# => Queues reload nginx and clear cache
# Handlers execute in DEFINITION order (not notification order):
# 1. restart nginx (if queued)
# 2. reload nginx (if queued)
# 3. restart php-fpm (if queued)
# 4. clear cache (if queued)Run: ansible-playbook handler_patterns.yml --ask-become-pass
Key Takeaway: Tasks can notify multiple handlers using list syntax under notify:. Handlers execute in the order they’re defined in the handlers: section, not in notification order. Use reload handlers for configuration changes that don’t require full restarts, reducing downtime.
Why It Matters: Multi-handler notifications enable complex orchestration like “reload nginx, then clear cache, then warm cache”—executed in defined order only when changes occur. The listen keyword decouples task notifications from handler names, allowing multiple handlers to respond to abstract events like “web server config changed” without tasks knowing handler implementation details.
Example 36: Flush Handlers and Listen
Force handler execution mid-play with meta: flush_handlers. Use listen to group handlers under common topic.
Code:
---
# flush_handlers.yml
- name: Flush Handlers and Listen
hosts: localhost
become: true
gather_facts: false
handlers:
# Traditional handler with listen
- name: restart nginx service
ansible.builtin.service:
name: nginx
state: restarted
listen: restart web services # => Handler listens to topic
# => Triggered by notify: restart web services
- name: restart apache service
ansible.builtin.service:
name: apache2
state: restarted
listen: restart web services # => Same topic, different handler
# => BOTH handlers execute when topic notified
- name: reload configuration
ansible.builtin.command:
cmd: /usr/local/bin/reload-config.sh
listen: restart web services # => Third handler on same topic
# => All three execute for one notification
tasks:
# Task notifying topic
- name: Update shared configuration
ansible.builtin.copy:
dest: /etc/shared/config.conf
content: "setting=value\n"
notify: restart web services # => Notifies topic, not specific handler
# => Queues ALL handlers listening to this topic
# Force handler execution NOW
- name: Flush handlers immediately
ansible.builtin.meta: flush_handlers
# => Executes all queued handlers immediately
# => Handlers: restart nginx, restart apache, reload configuration
# Task after flush
- name: Verify services are running
ansible.builtin.service:
name: nginx
state: started
# => Runs AFTER handlers execute (nginx already restarted)
# Another notification
- name: Update another config
ansible.builtin.copy:
dest: /etc/shared/other.conf
content: "other=value\n"
notify: restart web services
# => Queues handlers again
# Final handlers execute here at play end
# => restart web services handlers run second time if notified after flushRun: ansible-playbook flush_handlers.yml --ask-become-pass
Key Takeaway: Use listen to group related handlers under topic names—one notification triggers multiple handlers. Use meta: flush_handlers to force immediate handler execution mid-play—critical when subsequent tasks depend on handler changes. Handlers can execute multiple times per play if flushed and notified again.
Why It Matters: Forced handler execution with meta: flush_handlers prevents circular dependencies in multi-tier deployments. When database schema changes must complete before application deployment, flush_handlers ensures database restarts finish mid-play instead of waiting until play end. This pattern is essential for ordered rollouts across dependent services.
Example 37: Handler Conditionals and Error Handling
Handlers support conditionals and error handling like regular tasks. Control handler execution based on facts or variables.
Code:
---
# handler_conditionals.yml
- name: Handler Conditionals and Error Handling
hosts: localhost
become: true
gather_facts: true
vars:
production_mode: true
enable_service_restart: true
handlers:
# Conditional handler
- name: restart nginx
ansible.builtin.service:
name: nginx
state: restarted
when: enable_service_restart # => Handler runs only if variable true
# => Skipped if enable_service_restart is false
# OS-specific handler
- name: restart web server
ansible.builtin.service:
name: "{{ 'apache2' if ansible_os_family == 'Debian' else 'httpd' }}"
state: restarted
when: ansible_os_family in ['Debian', 'RedHat']
# => Executes only on Debian or RedHat systems
# Handler with error handling
- name: reload application
ansible.builtin.command:
cmd: /usr/local/bin/app reload
register: reload_result
failed_when: false # => Never fail (always report ok)
# => Continues even if reload command fails
- name: report reload failure
ansible.builtin.debug:
msg: "WARNING: Application reload failed"
when: reload_result is defined and reload_result.rc != 0
# => Executes only if previous handler registered failure
# Production-only handler
- name: send notification
ansible.builtin.uri:
url: https://hooks.slack.com/services/YOUR/WEBHOOK/URL
method: POST
body_format: json
body:
text: "Configuration updated on {{ inventory_hostname }}"
when: production_mode # => Only in production
# => Sends Slack notification in production environment
tasks:
- name: Update configuration
ansible.builtin.copy:
dest: /etc/app/config.yml
content: |
port: 8080
debug: false
notify:
- restart nginx # => Conditional handler
- reload application # => Error-tolerant handler
- report reload failure # => Conditional on previous handler
- send notification # => Production-only handler
# => All handlers queued, conditions evaluated at handler execution
# Demonstrate conditional handler skip
- name: Update non-critical config
ansible.builtin.copy:
dest: /tmp/test.conf
content: "test=value\n"
notify: restart nginx
vars:
enable_service_restart: false # => Task-level var disables handler
# => Handler queued but skipped due to conditionRun: ansible-playbook handler_conditionals.yml --ask-become-pass
Key Takeaway: Handlers support when conditionals for environment-aware execution. Use failed_when: false in handlers to prevent failures from stopping playbook. Chain handlers with register and conditionals for error recovery. Handler conditionals enable safe rollouts where restarts are conditional on environment or success state.
Why It Matters: Conditional handlers prevent failures in heterogeneous environments where services exist on some hosts but not others. Error handling in handlers ensures that failed service restarts don’t silently succeed—notifications are re-queued for retry. This reliability pattern is critical for production playbooks managing mixed infrastructure.
Group 9: Templates (Jinja2)
Example 38: Jinja2 Template Basics
Jinja2 templates combine static text with dynamic variables. The template module renders templates on control node and copies to managed hosts.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Template<br/>app_config.yml.j2"] --> B["Jinja2 Variables<br/>app_name, version"]
B --> C["Jinja2 Engine<br/>Render"]
C --> D["Rendered File<br/>app_config.yml"]
D --> E["Copy to Target<br/>/etc/app/config.yml"]
style A fill:#0173B2,color:#fff
style B fill:#DE8F05,color:#fff
style C fill:#029E73,color:#fff
style D fill:#CC78BC,color:#fff
style E fill:#CA9161,color:#fff
Code:
templates/app_config.yml.j2:
# Application Configuration
# Generated by Ansible on {{ ansible_date_time.iso8601 }}
application:
name: {{ app_name }} # => Variable substitution
version: {{ app_version }}
environment: {{ app_environment }}
server:
host: {{ server_host }}
port: {{ server_port }}
workers: {{ worker_count }}
database:
host: {{ db_host }}
port: {{ db_port }}
name: {{ db_name }}
user: {{ db_user }}
# Password managed by Ansible Vault
logging:
level: {{ log_level | upper }} # => Filter: convert to uppercase
file: {{ log_file | default('/var/log/app.log') }} # => Default valuePlaybook:
---
# template_basics.yml
- name: Jinja2 Template Basics
hosts: localhost
gather_facts: true
vars:
app_name: MyApplication
app_version: "2.1.0"
app_environment: production
server_host: 0.0.0.0
server_port: 8080
worker_count: 4
db_host: db.example.com
db_port: 5432
db_name: appdb
db_user: appuser
log_level: info
# log_file not defined -> will use default
tasks:
- name: Render application configuration
ansible.builtin.template:
src: app_config.yml.j2 # => Template file (relative to playbook)
dest: /tmp/app_config.yml # => Destination on target
mode: "0644"
# => Renders template with variables and copies to destination
# => changed: [localhost] (if content differs)
- name: Display rendered configuration
ansible.builtin.command:
cmd: cat /tmp/app_config.yml
register: config_content
- name: Show configuration
ansible.builtin.debug:
msg: "{{ config_content.stdout }}"
# => Shows fully rendered configuration fileRun: ansible-playbook template_basics.yml
Rendered output:
# Application Configuration
# Generated by Ansible on 2024-01-15T10:30:00Z
application:
name: MyApplication
version: 2.1.0
environment: production
server:
host: 0.0.0.0
port: 8080
workers: 4
database:
host: db.example.com
port: 5432
name: appdb
user: appuser
logging:
level: INFO
file: /var/log/app.logKey Takeaway: Jinja2 templates use {{ variable }} for substitution, {{ var | filter }} for transformations. The default() filter provides fallback values for undefined variables. Templates render on control node, so targets don’t need Jinja2 installed. Use templates for configuration files that vary by environment or host.
Why It Matters: Jinja2 templates eliminate configuration drift by rendering environment-specific configs from single source templates. Production deployments generate nginx configs for 100+ virtual hosts from one template with loop-driven generation. Variable substitution enables the same template to produce development, staging, and production configs with zero code duplication.
Example 39: Jinja2 Conditionals and Loops
Jinja2 supports control structures: conditionals ({% if %}), loops ({% for %}), and blocks. Generate dynamic configuration based on variables and facts.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Template Logic"] --> B["Conditionals<br/>{% if %}"]
A --> C["Loops<br/>{% for %}"]
B --> D["Include/Exclude<br/>Sections"]
C --> E["Repeat Sections<br/>Multiple Times"]
D --> F["Final Config"]
E --> F
style A fill:#0173B2,color:#fff
style B fill:#DE8F05,color:#fff
style C fill:#029E73,color:#fff
style D fill:#CC78BC,color:#fff
style E fill:#CC78BC,color:#fff
style F fill:#CA9161,color:#fff
Code:
templates/nginx_site.conf.j2:
# Nginx Site Configuration for {{ site_name }}
server {
listen {{ http_port }};
server_name {{ server_name }};
{% if enable_ssl %}
# SSL Configuration
listen {{ https_port }} ssl;
ssl_certificate {{ ssl_cert_path }};
ssl_certificate_key {{ ssl_key_path }};
ssl_protocols TLSv1.2 TLSv1.3;
{% endif %}
root {{ document_root }};
index index.html index.php;
# Custom locations
{% for location in custom_locations %}
location {{ location.path }} {
{% if location.type == 'proxy' %}
proxy_pass {{ location.backend }};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
{% elif location.type == 'static' %}
alias {{ location.root }};
{% endif %}
}
{% endfor %}
# Default location
location / {
try_files $uri $uri/ =404;
}
# PHP processing (conditional)
{% if enable_php %}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php{{ php_version }}-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
{% endif %}
# Access log
access_log /var/log/nginx/{{ site_name }}_access.log;
error_log /var/log/nginx/{{ site_name }}_error.log;
}Playbook:
---
# template_conditionals.yml
- name: Jinja2 Conditionals and Loops
hosts: localhost
gather_facts: false
vars:
site_name: example.com
server_name: www.example.com
http_port: 80
https_port: 443
enable_ssl: true
ssl_cert_path: /etc/ssl/certs/example.com.crt
ssl_key_path: /etc/ssl/private/example.com.key
document_root: /var/www/example.com
enable_php: true
php_version: "8.1"
custom_locations:
- path: /api
type: proxy
backend: http://localhost:3000
- path: /admin
type: proxy
backend: http://localhost:4000
- path: /static
type: static
root: /var/www/static
tasks:
- name: Render nginx site configuration
ansible.builtin.template:
src: nginx_site.conf.j2
dest: /tmp/example.com.conf
mode: "0644"
# => Renders template with conditionals and loops
- name: Display configuration
ansible.builtin.command:
cmd: cat /tmp/example.com.conf
register: nginx_config
- name: Show nginx config
ansible.builtin.debug:
msg: "{{ nginx_config.stdout }}"Run: ansible-playbook template_conditionals.yml
Key Takeaway: Use {% if condition %} for conditional blocks, {% for item in list %} for loops. Jinja2 control structures use {% %} delimiters. Support nested conditionals and loops for complex configuration generation. Template logic enables single template file to generate configurations for multiple environments.
Why It Matters: Template logic enables adaptive configurations that respond to inventory facts—SSL enabled only for production, debug logging only for development, firewall rules generated from service definitions. Loop-based generation creates complex multi-section configs (20+ vhosts, 50+ upstream servers) without manual repetition, reducing configuration errors by 80%.
Example 40: Jinja2 Filters and Tests
Jinja2 filters transform data (string manipulation, list operations, math). Tests check conditions (variable type, existence, truthiness).
Code:
templates/advanced_config.yml.j2:
# Advanced Configuration with Filters and Tests
# String filters
app_name_upper: {{ app_name | upper }} # => Convert to uppercase
app_name_lower: {{ app_name | lower }} # => Convert to lowercase
app_slug: {{ app_name | lower | replace(' ', '-') }} # => Chain filters
# Default values
optional_value: {{ optional_var | default('fallback') }} # => Use default if undefined
database_port: {{ db_port | default(5432) }} # => Default for number
# List filters
total_hosts: {{ groups['webservers'] | length }} # => Count list items
first_host: {{ groups['webservers'] | first }} # => First item
last_host: {{ groups['webservers'] | last }} # => Last item
sorted_hosts: {{ groups['webservers'] | sort }} # => Sort list
# Dictionary filters
all_vars: {{ hostvars[inventory_hostname] | to_nice_json }} # => Pretty JSON
compact_vars: {{ hostvars[inventory_hostname] | to_json }} # => Compact JSON
yaml_vars: {{ hostvars[inventory_hostname] | to_nice_yaml }} # => YAML format
# Math filters
double_port: {{ server_port | int * 2 }} # => Integer math
memory_gb: {{ (total_memory_mb | int / 1024) | round(2) }} # => Division and rounding
# Type conversion
port_string: "{{ server_port | string }}" # => Convert to string
bool_value: {{ "yes" | bool }} # => String to boolean
# Tests (conditional checks)
{% if optional_var is defined %}
optional_is_defined: true
{% endif %}
{% if optional_var is undefined %}
optional_is_undefined: true
{% endif %}
{% if server_port is number %}
port_is_number: true
{% endif %}
{% if app_name is string %}
name_is_string: true
{% endif %}
{% if worker_list is iterable %}
workers_is_list: true
{% endif %}
# Version comparison (test)
{% if app_version is version('2.0', '>=') %}
version_meets_requirement: true
{% endif %}
# List operations
unique_list: {{ duplicate_list | unique }} # => Remove duplicates
joined_string: {{ string_list | join(', ') }} # => Join with separatorPlaybook:
---
# template_filters.yml
- name: Jinja2 Filters and Tests
hosts: localhost
gather_facts: false
vars:
app_name: "My Application"
app_version: "2.5.0"
server_port: 8080
total_memory_mb: 8192
worker_list: [1, 2, 3, 4]
duplicate_list: [1, 2, 2, 3, 3, 3]
string_list: ["apple", "banana", "cherry"]
# optional_var intentionally not defined
tasks:
- name: Render configuration with filters
ansible.builtin.template:
src: advanced_config.yml.j2
dest: /tmp/advanced_config.yml
mode: "0644"
- name: Display rendered configuration
ansible.builtin.command:
cmd: cat /tmp/advanced_config.yml
register: config
- name: Show configuration
ansible.builtin.debug:
msg: "{{ config.stdout }}"Run: ansible-playbook template_filters.yml
Common filters:
- String:
upper,lower,capitalize,title,replace - List:
first,last,length,sort,unique,join - Dict:
to_json,to_nice_json,to_yaml,to_nice_yaml - Math:
int,float,round,abs - Default:
default(value),default(value, true)(default even if empty)
Common tests:
- Existence:
is defined,is undefined - Type:
is string,is number,is iterable,is mapping - Value:
is true,is false,is none - Comparison:
is version('1.0', '>='),is equalto(value)
Key Takeaway: Filters transform data inline ({{ var | filter }}), tests check conditions ({% if var is test %}). Chain filters with | for complex transformations. Use default() filter extensively to handle undefined variables gracefully. Filters and tests enable sophisticated template logic without external preprocessing.
Why It Matters: Jinja2 filters perform data transformations inside templates, avoiding complex playbook preprocessing. The default filter prevents template failures when optional variables are undefined. The regex_replace filter sanitizes user input in generated configs, preventing injection attacks. Production templates use 10-15 filters per file to ensure robust, secure configuration generation.
Example 41: Template Whitespace Control
Jinja2 whitespace control prevents unwanted blank lines and indentation in generated files. Use - trimming operators for clean output.
Code:
templates/whitespace_example.j2:
# Without whitespace control (creates blank lines)
{% for item in items %}
{{ item }}
{% endfor %}
# => Output has blank lines between items and after loop
# With whitespace control (removes blank lines)
{% for item in items -%}
{{ item }}
{% endfor -%}
# => Output has no blank lines (- trims newlines)
# Trim left whitespace
{%- if condition %}
content here
{% endif %}
# => Removes newline before if block
# Trim right whitespace
{% if condition -%}
content here
{% endif %}
# => Removes newline after if block
# Trim both sides
{%- if condition -%}
content here
{%- endif -%}
# => Removes newlines on both sides of block
# Practical example: clean list generation
servers:
{%- for server in server_list %}
- name: {{ server.name }}
ip: {{ server.ip }}
{%- endfor %}
# => No blank line after list (- after endfor)
# Example: conditional with clean formatting
database:
host: {{ db_host }}
port: {{ db_port }}
{%- if db_ssl_enabled %}
ssl: true
{%- endif %}
# => No blank line if ssl block is skippedPlaybook:
---
# template_whitespace.yml
- name: Template Whitespace Control
hosts: localhost
gather_facts: false
vars:
items: [item1, item2, item3]
condition: true
server_list:
- name: web1
ip: 192.168.1.10
- name: web2
ip: 192.168.1.11
db_host: localhost
db_port: 5432
db_ssl_enabled: true
tasks:
- name: Render template with whitespace control
ansible.builtin.template:
src: whitespace_example.j2
dest: /tmp/whitespace_demo.txt
mode: "0644"
# => Renders clean output without extra blank lines
- name: Display rendered file
ansible.builtin.command:
cmd: cat /tmp/whitespace_demo.txt
register: output
- name: Show output
ansible.builtin.debug:
msg: "{{ output.stdout }}"Whitespace control syntax:
{%- ... %}→ Trim whitespace before block{% ... -%}→ Trim whitespace after block{%- ... -%}→ Trim whitespace on both sides{{- ... }}→ Trim before variable{{ ... -}}→ Trim after variable
Run: ansible-playbook template_whitespace.yml
Key Takeaway: Use - operator to control whitespace in template output. Place - inside delimiters adjacent to where trimming should occur. Critical for generating clean configuration files like YAML or JSON where whitespace affects parsing. Without whitespace control, templates produce files with excessive blank lines.
Why It Matters: Whitespace control generates clean, readable configs that pass syntax validators and linters. Generated nginx configs must match exact indentation and spacing for automated compliance checking. The - trim operators prevent bloated 1MB config files from templates with extensive loops, reducing file size by 40% and improving parsing speed.
Example 42: Template Macros and Inheritance
Jinja2 macros define reusable template fragments. Template inheritance enables base templates extended by child templates.
Code:
templates/macros.j2:
{# Define reusable macros #}
{# Macro: Generate server block #}
{% macro server_block(name, host, port) -%}
server {
listen {{ port }};
server_name {{ name }};
location / {
proxy_pass http://{{ host }}:{{ port }};
}
}
{%- endmacro %}
{# Macro: Generate database connection string #}
{% macro db_connection(type, host, port, name, user) -%}
{{ type }}://{{ user }}@{{ host }}:{{ port }}/{{ name }}
{%- endmacro %}
{# Macro: Generate logging configuration #}
{% macro logging_config(level, file) -%}
logging:
level: {{ level | upper }}
handlers:
file:
filename: {{ file }}
maxBytes: 10485760
backupCount: 5
{%- endmacro %}templates/base.conf.j2 (base template):
# Base Configuration Template
# Environment: {{ environment }}
{% block header %}
# Default Header
# Generated: {{ ansible_date_time.iso8601 }}
{% endblock %}
{% block application %}
# Application section must be defined in child template
{% endblock %}
{% block database %}
# Database section must be defined in child template
{% endblock %}
{% block footer %}
# Default Footer
{% endblock %}templates/app.conf.j2 (child template):
{% extends "base.conf.j2" %} {# Inherit from base #}
{% import "macros.j2" as macros %} {# Import macros #}
{% block header %}
# Application Configuration
# Name: {{ app_name }}
# Version: {{ app_version }}
{% endblock %}
{% block application %}
application:
name: {{ app_name }}
port: {{ app_port }}
# Use macro for server blocks
{% for server in app_servers %}
{{ macros.server_block(server.name, server.host, server.port) }}
{% endfor %}
{% endblock %}
{% block database %}
database:
# Use macro for connection string
url: {{ macros.db_connection('postgresql', db_host, db_port, db_name, db_user) }}
# Use macro for logging
{{ macros.logging_config('info', '/var/log/app.log') }}
{% endblock %}
{% block footer %}
# End of {{ app_name }} Configuration
{% endblock %}Playbook:
---
# template_macros.yml
- name: Template Macros and Inheritance
hosts: localhost
gather_facts: true
vars:
environment: production
app_name: MyApp
app_version: "3.0.0"
app_port: 8080
app_servers:
- name: api.example.com
host: localhost
port: 3000
- name: web.example.com
host: localhost
port: 4000
db_host: db.example.com
db_port: 5432
db_name: appdb
db_user: appuser
tasks:
- name: Render configuration with macros and inheritance
ansible.builtin.template:
src: app.conf.j2 # => Child template
dest: /tmp/app.conf
mode: "0644"
# => Extends base template and imports macros
- name: Display rendered configuration
ansible.builtin.command:
cmd: cat /tmp/app.conf
register: config
- name: Show configuration
ansible.builtin.debug:
msg: "{{ config.stdout }}"Run: ansible-playbook template_macros.yml
Key Takeaway: Macros ({% macro name(args) %}) define reusable template functions—reduce duplication and improve maintainability. Template inheritance ({% extends %} and {% block %}) enables base templates with overridable sections. Import macros with {% import "file.j2" as namespace %}. This pattern scales to complex multi-environment configurations.
Why It Matters: Template macros DRY complex config blocks that repeat across multiple files—SSL configuration, logging setup, security headers. Template inheritance creates config families where base templates define structure and child templates override specific sections. This pattern manages 50+ related configs (dev, staging, prod × services) from shared base templates, reducing maintenance burden by 70%.
Group 10: Ansible Vault
Example 43: Vault Basics
Ansible Vault encrypts sensitive data (passwords, API keys, certificates) in version control. Encrypted files remain YAML-readable but content is protected.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Plaintext secrets.yml"] --> B["ansible-vault encrypt"]
B --> C["Encrypted secrets.yml<br/>AES256"]
C --> D["Commit to Git<br/>#40;Safe#41;"]
C --> E["Playbook Execution"]
E --> F["vault-password-file"]
F --> G["Decrypt Secrets<br/>Runtime Only"]
G --> H["Use in Tasks"]
style A fill:#DE8F05,color:#fff
style B fill:#0173B2,color:#fff
style C fill:#029E73,color:#fff
style D fill:#029E73,color:#fff
style E fill:#CC78BC,color:#fff
style F fill:#CA9161,color:#fff
style G fill:#DE8F05,color:#fff
style H fill:#029E73,color:#fff
Code:
Create encrypted file:
# Create new encrypted file with password prompt
ansible-vault create secrets.yml
# Enter vault password, then edit content:secrets.yml (encrypted):
---
db_password: SuperSecretPassword123
api_key: sk-1234567890abcdef
ssl_cert_password: CertPassword456
admin_password: AdminPass789View encrypted file:
# View encrypted file content
ansible-vault view secrets.yml
# => Prompts for password, displays decrypted content
# Edit encrypted file
ansible-vault edit secrets.yml
# => Opens editor with decrypted content, re-encrypts on savePlaybook using vault:
---
# vault_basic.yml
- name: Use Ansible Vault
hosts: localhost
gather_facts: false
vars_files:
- secrets.yml # => Load encrypted variables file
# => Variables decrypted automatically during playbook execution
vars:
db_host: localhost # => Non-sensitive variables in playbook
db_port: 5432
db_name: appdb
db_user: appuser
# db_password loaded from secrets.yml (encrypted)
tasks:
- name: Display database connection info (password masked)
ansible.builtin.debug:
msg: |
Host: {{ db_host }}:{{ db_port }}
Database: {{ db_name }}
User: {{ db_user }}
# => Shows non-sensitive info only
- name: Use password in connection (example)
ansible.builtin.debug:
msg: "Connecting with password {{ db_password }}"
no_log: true # => Prevent password from appearing in logs
# => Task executes but output suppressedRun with vault password:
# Prompt for vault password
ansible-playbook vault_basic.yml --ask-vault-pass
# Use password file (avoid interactive prompt)
ansible-playbook vault_basic.yml --vault-password-file ~/.vault_pass
# Use environment variable
export ANSIBLE_VAULT_PASSWORD_FILE=~/.vault_pass
ansible-playbook vault_basic.ymlVault operations:
# Encrypt existing file
ansible-vault encrypt existing_file.yml
# Decrypt file (remove encryption)
ansible-vault decrypt secrets.yml
# Change vault password
ansible-vault rekey secrets.yml
# Show encrypted file content
cat secrets.yml # => Shows encrypted blob (safe to commit)Key Takeaway: Ansible Vault encrypts sensitive variables in YAML files—safe to commit to version control. Use --ask-vault-pass or password files for decryption. Always use no_log: true on tasks displaying sensitive data to prevent password leaks in logs. Vault files remain YAML-parseable but content is AES256-encrypted.
Why It Matters: Ansible Vault encrypts secrets at rest in version control, enabling secure secret management without external tools. Production playbooks store database passwords, API keys, and SSL private keys encrypted in Git, with automatic decryption at runtime. This eliminates insecure practices like plaintext secrets in repos or manual secret injection during deployment.
Example 44: Vault IDs for Multiple Passwords
Vault IDs enable multiple vault passwords in one playbook—separate credentials for different environments or security domains.
Code:
Create files with different vault IDs:
# Production secrets with 'prod' vault ID
ansible-vault create --vault-id prod@prompt prod_secrets.yml
# Staging secrets with 'staging' vault ID
ansible-vault create --vault-id staging@prompt staging_secrets.yml
# Database secrets with 'database' vault ID
ansible-vault create --vault-id database@prompt db_secrets.ymlprod_secrets.yml (encrypted with prod ID):
---
environment: production
api_endpoint: https://api.prod.example.com
api_key: prod-key-abc123staging_secrets.yml (encrypted with staging ID):
---
environment: staging
api_endpoint: https://api.staging.example.com
api_key: staging-key-xyz789db_secrets.yml (encrypted with database ID):
---
db_master_password: MasterDBPass123
db_replication_key: ReplKey456Playbook:
---
# vault_ids.yml
- name: Multiple Vault IDs
hosts: localhost
gather_facts: false
vars_files:
- prod_secrets.yml # => Requires 'prod' vault password
- staging_secrets.yml # => Requires 'staging' vault password
- db_secrets.yml # => Requires 'database' vault password
tasks:
- name: Display environment configuration
ansible.builtin.debug:
msg: |
Prod API: {{ api_endpoint }} (from prod_secrets.yml)
DB Password: {{ db_master_password }} (from db_secrets.yml)
no_log: trueCreate password files:
# ~/.vault_pass_prod
echo "prod-password" > ~/.vault_pass_prod
chmod 600 ~/.vault_pass_prod
# ~/.vault_pass_staging
echo "staging-password" > ~/.vault_pass_staging
chmod 600 ~/.vault_pass_staging
# ~/.vault_pass_database
echo "database-password" > ~/.vault_pass_database
chmod 600 ~/.vault_pass_databaseRun with multiple vault IDs:
# Prompt for each vault password
ansible-playbook vault_ids.yml \
--vault-id prod@prompt \
--vault-id staging@prompt \
--vault-id database@prompt
# Use password files
ansible-playbook vault_ids.yml \
--vault-id prod@~/.vault_pass_prod \
--vault-id staging@~/.vault_pass_staging \
--vault-id database@~/.vault_pass_database
# Mix prompt and file
ansible-playbook vault_ids.yml \
--vault-id prod@prompt \
--vault-id staging@~/.vault_pass_stagingView file with specific vault ID:
ansible-vault view --vault-id prod@prompt prod_secrets.yml
ansible-vault edit --vault-id database@~/.vault_pass_database db_secrets.ymlKey Takeaway: Vault IDs enable multiple passwords in one playbook—useful for separating prod/staging credentials or security domains. Use --vault-id <label>@<source> format where source is prompt or path to password file. Each encrypted file tagged with vault ID during creation. This pattern enables role-based access control where different teams have different vault passwords.
Why It Matters: Vault IDs enable role-based secret access where junior engineers decrypt dev secrets but not production secrets. Multi-password vaults separate concerns—developers access app configs, ops access infrastructure credentials. This implements least-privilege principles and audit trails showing who decrypted which secret categories.
Example 45: Inline Encrypted Variables
Encrypt individual variables inline instead of entire files. Useful for mixed sensitive/non-sensitive variable files.
Code:
Encrypt single string:
# Encrypt string value
ansible-vault encrypt_string 'SuperSecretPassword123' --name 'db_password'
# => Outputs encrypted string in YAML format
# Output:
# db_password: !vault |
# $ANSIBLE_VAULT;1.1;AES256
# 66386439653264336462626566653063336164663966303231363934653561363964363833313662
# ...encrypted content...vars.yml (mixed encrypted and plaintext):
---
# Non-sensitive variables (plaintext)
db_host: localhost
db_port: 5432
db_name: appdb
db_user: appuser
# Sensitive variable (encrypted inline)
db_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
66386439653264336462626566653063336164663966303231363934653561363964363833313662
3431626338623034303037646233396431346564663936310a613034343933343462373465373738
62376662626533643732333461303639626533633131373635373832336531373162366534363464
3561373633613763360a313331613031656561623332353332613235376565353966383334646364
3566
# Another encrypted variable
api_key: !vault |
$ANSIBLE_VAULT;1.1;AES256
39653561363964363833313662626566653063336164663966303231363934653561363964363833
...encrypted content...Playbook:
---
# inline_vault.yml
- name: Inline Encrypted Variables
hosts: localhost
gather_facts: false
vars_files:
- vars.yml # => Mixed plaintext and encrypted vars
tasks:
- name: Display configuration
ansible.builtin.debug:
msg: |
Host: {{ db_host }}:{{ db_port }}
Database: {{ db_name }}
User: {{ db_user }}
# => Shows plaintext variables
- name: Use encrypted password
ansible.builtin.debug:
msg: "Password is {{ db_password | length }} characters"
no_log: true # => Don't log actual password
# => Uses decrypted password in taskCreate inline encrypted variable:
# Encrypt string and copy to clipboard
ansible-vault encrypt_string 'MySecret123' --name 'secret_var' \
--vault-id prod@~/.vault_pass_prod
# Encrypt from stdin (for long values)
echo -n 'LongSecretValue' | \
ansible-vault encrypt_string --stdin-name 'long_secret'Run: ansible-playbook inline_vault.yml --ask-vault-pass
Key Takeaway: Inline encrypted variables (!vault |) enable mixing sensitive and non-sensitive data in one file—improves readability compared to fully encrypted files. Use encrypt_string to generate encrypted values. Inline encryption works with any vault ID. This pattern is ideal for variable files where only a few values are sensitive.
Why It Matters: Inline encryption scopes secrets to specific variables in otherwise-public files, avoiding the all-or-nothing encryption of full vault files. Ansible configuration files contain mix of public settings (ports, paths) and secrets (passwords, tokens)—inline encryption keeps public parts easily reviewable while securing sensitive values. This improves code review efficiency and reduces accidental secret exposure.
Example 46: Vault Best Practices
Production-ready vault usage patterns: password management, file organization, access control, and security.
Code:
Directory structure:
inventory/
production/
group_vars/
all/
vars.yml # => Non-sensitive variables
vault.yml # => Encrypted sensitive variables
webservers/
vars.yml
vault.yml
databases/
vars.yml
vault.yml
.vault_pass_prod # => Production vault password file
.vault_pass_staging # => Staging vault password file
.gitignore # => Exclude password files from git.gitignore:
# Vault password files
.vault_pass*
vault_password.txt
*.vault_pass
# Backup files from vault edit
*.yml.backup
*~group_vars/all/vars.yml (non-sensitive):
---
# Non-sensitive configuration
ntp_server: time.example.com
log_level: info
backup_enabled: true
monitoring_enabled: truegroup_vars/all/vault.yml (encrypted):
---
# Encrypted sensitive variables (prefix with vault_)
vault_db_password: SuperSecret123
vault_api_key: sk-1234567890
vault_ssl_key: |
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...
-----END PRIVATE KEY-----group_vars/all/all.yml (variable mapping):
---
# Map vault variables to usable names
db_password: "{{ vault_db_password }}" # => Indirect reference
api_key: "{{ vault_api_key }}"
ssl_private_key: "{{ vault_ssl_key }}"Password file setup:
# Create password file with restricted permissions
echo "production-vault-password" > .vault_pass_prod
chmod 600 .vault_pass_prod
# Configure Ansible to use password file by default
cat >> ansible.cfg <<EOF
[defaults]
vault_password_file = .vault_pass_prod
EOFansible.cfg:
[defaults]
# Default vault password file (override with --vault-password-file)
vault_password_file = .vault_pass_prod
# Require vault password for encrypted files
vault_encrypt_identity_list = default
# Log encryption
no_log = True # => Global no_log for sensitive tasksPlaybook with vault best practices:
---
# vault_best_practices.yml
- name: Vault Best Practices
hosts: localhost
gather_facts: false
# Load variables (vars.yml + vault.yml automatically loaded from group_vars)
tasks:
- name: Use sensitive variable
ansible.builtin.debug:
msg: "Connecting to database"
no_log: true # => Always use no_log with sensitive data
# => Task uses db_password from vault.yml indirectly
- name: Deploy configuration with secrets
ansible.builtin.template:
src: app_config.j2
dest: /tmp/app_config.yml
mode: "0600" # => Restrictive permissions for config with secrets
no_log: true # => Don't log file contentVault rotation procedure:
# 1. Create new vault password file
echo "new-production-password" > .vault_pass_prod_new
# 2. Rekey all vault files
find inventory/ -name 'vault.yml' -exec \
ansible-vault rekey --vault-password-file .vault_pass_prod \
--new-vault-password-file .vault_pass_prod_new {} \;
# 3. Replace old password file
mv .vault_pass_prod_new .vault_pass_prod
chmod 600 .vault_pass_prod
# 4. Test decryption
ansible-vault view inventory/production/group_vars/all/vault.ymlKey Takeaway: Separate sensitive (vault.yml) from non-sensitive (vars.yml) variables for clarity. Prefix vault variables with vault_ and map to clean names in all.yml. Store vault password files outside repository with 600 permissions. Use no_log: true on all tasks handling sensitive data. Implement vault password rotation procedure. Use group_vars hierarchy for environment-specific secrets. This structure scales to hundreds of hosts and multiple environments.
Why It Matters: Vault best practices prevent common security failures: password rotation via script files, vault-id enforcement in CI/CD, and rekey procedures for compromised credentials. Production environments with 100+ vaulted files require automated password management—script-based passwords enable integration with enterprise secret management systems and automated rotation policies.
Group 11: Error Handling & Blocks
Example 47: Failed When and Changed When
Control task success/failure criteria and changed status reporting with failed_when and changed_when directives.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Task Execution"] --> B{failed_when<br/>Condition?}
B -->|True| C["Report: Failed"]
B -->|False| D{changed_when<br/>Condition?}
D -->|True| E["Report: Changed"]
D -->|False| F["Report: OK"]
style A fill:#0173B2,color:#fff
style B fill:#DE8F05,color:#fff
style C fill:#CA9161,color:#fff
style D fill:#DE8F05,color:#fff
style E fill:#CC78BC,color:#fff
style F fill:#029E73,color:#fff
Code:
---
# failed_changed_when.yml
- name: Failed When and Changed When
hosts: localhost
gather_facts: false
tasks:
# Default behavior: command always reports changed
- name: Run command (always changed)
ansible.builtin.command:
cmd: echo "test"
register: result
# => changed: [localhost] (command module default)
- name: Check default status
ansible.builtin.debug:
msg: "Task changed: {{ result.changed }}"
# => Output: Task changed: True
# Control changed status with changed_when
- name: Command with changed_when never
ansible.builtin.command:
cmd: uptime
changed_when: false # => Always report OK (never changed)
# => ok: [localhost] (forced to not changed)
# Conditional changed status
- name: Command with conditional changed
ansible.builtin.command:
cmd: grep "pattern" /tmp/file.txt
register: grep_result
changed_when: grep_result.rc == 0 # => Changed only if pattern found
failed_when: grep_result.rc not in [0, 1] # => Fail on errors (not "not found")
# => ok: [localhost] if pattern not found (rc=1)
# => changed: [localhost] if pattern found (rc=0)
# => failed: [localhost] if error (rc=2)
# Complex failed_when condition
- name: Check application status
ansible.builtin.shell:
cmd: |
curl -s http://localhost:8080/health || echo "DOWN"
register: health_check
changed_when: false # => Health check never changes system
failed_when: >
health_check.rc != 0 or
'DOWN' in health_check.stdout or
'error' in health_check.stdout | lower
# => Fail if curl fails OR output contains "DOWN" or "error"
# Never fail (always succeed)
- name: Task that always succeeds
ansible.builtin.command:
cmd: /usr/local/bin/optional-script.sh
register: optional_result
failed_when: false # => Never fail regardless of return code
changed_when: optional_result.rc == 0 # => Changed only if successful
# => ok: [localhost] even if script returns non-zero
# Validate output content
- name: Validate configuration file
ansible.builtin.command:
cmd: cat /etc/app/config.yml
register: config_content
changed_when: false
failed_when: "'debug: true' in config_content.stdout"
# => Fail if debug mode enabled in config (safety check)
# Multiple failure conditions
- name: Check disk space
ansible.builtin.shell:
cmd: df -h / | awk 'NR==2 {print $5}' | sed 's/%//'
register: disk_usage
changed_when: false
failed_when:
- disk_usage.stdout | int > 90 # => Fail if > 90% used
- disk_usage.rc != 0 # => Also fail if command errors
# => Combines multiple failure conditions with implicit ANDRun: ansible-playbook failed_changed_when.yml
Key Takeaway: Use changed_when: false for read-only tasks (checks, queries) to prevent misleading change reports. Use failed_when to define success criteria based on output content or return codes—enables validation and safety checks. Combine both directives for precise task status control. This pattern enables idempotent playbooks where task status accurately reflects system state changes.
Why It Matters: Custom failure and change detection enables intelligent task interpretation. Commands that exit non-zero successfully (grep no match, test file absent) need failed_when override to prevent false failures. Tasks that always report changed (legacy scripts, custom binaries) need changed_when to prevent unnecessary handler triggers. This pattern fixes 60% of flapping playbook runs.
Example 48: Ignore Errors and Error Recovery
Handle task failures gracefully with ignore_errors, conditional error handling, and recovery patterns.
Code:
---
# error_handling.yml
- name: Ignore Errors and Error Recovery
hosts: localhost
gather_facts: false
tasks:
# Ignore errors (continue playbook)
- name: Optional task that might fail
ansible.builtin.command:
cmd: /usr/local/bin/optional-tool --check
ignore_errors: true # => Continue even if task fails
register: optional_result
# => failed: [localhost] (task fails but playbook continues)
- name: Check if optional task succeeded
ansible.builtin.debug:
msg: "Optional tool {{ 'succeeded' if optional_result.rc == 0 else 'failed' }}"
# => Shows task result without stopping playbook
# Conditional error ignore
- name: Task with conditional error handling
ansible.builtin.command:
cmd: test -f /tmp/important_file.txt
register: file_check
failed_when: false # => Never fail (alternative to ignore_errors)
# => ok: [localhost] regardless of file existence
- name: Create file if missing
ansible.builtin.file:
path: /tmp/important_file.txt
state: touch
when: file_check.rc != 0 # => Create only if previous check failed
# => Recovery action based on check result
# Error recovery pattern
- name: Try primary service
ansible.builtin.uri:
url: https://primary.example.com/api
method: GET
register: primary_result
ignore_errors: true
# => Try primary endpoint, don't fail if unreachable
- name: Fallback to secondary service
ansible.builtin.uri:
url: https://secondary.example.com/api
method: GET
when: primary_result is failed # => Execute only if primary failed
register: secondary_result
# => Fallback to backup service
- name: Fallback to tertiary service
ansible.builtin.uri:
url: https://tertiary.example.com/api
method: GET
when:
- primary_result is failed # => Primary failed
- secondary_result is failed # => Secondary also failed
# => Last resort fallback
# Fail playbook after recovery attempts
- name: Fail if all services unavailable
ansible.builtin.fail:
msg: "All API endpoints are unavailable"
when:
- primary_result is failed
- secondary_result is failed
- tertiary_result is failed
# => Explicit failure after exhausting recovery options
# Retry pattern with ignore_errors
- name: Attempt flaky operation
ansible.builtin.command:
cmd: /usr/local/bin/flaky-script.sh
register: flaky_result
ignore_errors: true
# => First attempt (might fail)
- name: Retry flaky operation
ansible.builtin.command:
cmd: /usr/local/bin/flaky-script.sh
when: flaky_result is failed # => Retry if first attempt failed
register: retry_result
# => Second attempt
- name: Final status
ansible.builtin.debug:
msg: "Operation {{ 'succeeded' if (flaky_result is success or retry_result is success) else 'failed after retry' }}"Run: ansible-playbook error_handling.yml
Key Takeaway: Use ignore_errors: true to continue playbook execution after failures—essential for optional tasks or multi-path workflows. Combine with register and is failed test for error recovery patterns. Use failed_when: false as alternative to ignore_errors for more explicit control. Implement fallback chains for resilient automation. Always explicitly fail with ansible.builtin.fail after exhausting recovery options to prevent silent failures.
Why It Matters: Error tolerance enables graceful degradation in distributed systems where partial failures are acceptable. Multi-datacenter deployments continue when one region is unreachable. The ignore_errors pattern combined with failed result checking implements try-catch semantics, allowing playbooks to attempt operations, detect failures, and execute fallback strategies.
Example 49: Block, Rescue, and Always
Blocks group tasks with unified error handling. rescue executes on failure, always executes regardless of outcome—similar to try/catch/finally.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Block Section"] --> B{Success?}
B -->|Success| C["Always Section"]
B -->|Failure| D["Rescue Section"]
D --> E{Rescue Success?}
E -->|Success| C
E -->|Failure| F["Playbook Fails"]
C --> G["Continue Playbook"]
style A fill:#0173B2,color:#fff
style B fill:#DE8F05,color:#fff
style D fill:#CC78BC,color:#fff
style C fill:#029E73,color:#fff
style E fill:#DE8F05,color:#fff
style F fill:#CA9161,color:#fff
style G fill:#029E73,color:#fff
Code:
---
# block_rescue_always.yml
- name: Block, Rescue, and Always
hosts: localhost
gather_facts: false
tasks:
# Basic block with rescue
- name: Block example with error handling
block:
- name: Task that might fail
ansible.builtin.command:
cmd: /usr/bin/false # => Always fails (exit 1)
# => failed: [localhost] (triggers rescue)
- name: This task won't execute
ansible.builtin.debug:
msg: "Skipped due to previous failure"
# => Skipped (block execution stops at first failure)
rescue:
- name: Handle failure
ansible.builtin.debug:
msg: "Block failed, executing rescue tasks"
# => Executes when any block task fails
- name: Recovery action
ansible.builtin.file:
path: /tmp/error_recovery.log
state: touch
# => Create error log file
always:
- name: Cleanup (always executes)
ansible.builtin.debug:
msg: "Cleanup executing regardless of success/failure"
# => Always executes (even if rescue fails)
# Practical example: Database backup with rollback
- name: Database operation with rollback
block:
- name: Create backup snapshot
ansible.builtin.command:
cmd: pg_dump appdb > /tmp/backup.sql
# => Backup before risky operation
- name: Run database migration
ansible.builtin.command:
cmd: /usr/local/bin/migrate-database.sh
register: migration_result
# => Risky operation that might fail
- name: Verify migration
ansible.builtin.command:
cmd: /usr/local/bin/verify-migration.sh
# => Validation step
rescue:
- name: Restore from backup
ansible.builtin.command:
cmd: psql appdb < /tmp/backup.sql
# => Rollback on failure
- name: Notify failure
ansible.builtin.debug:
msg: "Migration failed, database restored from backup"
always:
- name: Remove backup file
ansible.builtin.file:
path: /tmp/backup.sql
state: absent
# => Cleanup backup regardless of outcome
# Nested blocks
- name: Nested block example
block:
- name: Outer block task
ansible.builtin.debug:
msg: "Outer block executing"
- name: Nested block with own error handling
block:
- name: Inner task
ansible.builtin.command:
cmd: /usr/bin/false
# => Fails, triggers inner rescue
rescue:
- name: Inner rescue
ansible.builtin.debug:
msg: "Inner rescue executing"
# => Handles inner block failure
# Note: Inner block failure does NOT trigger outer rescue
# because inner rescue handled it successfully
rescue:
- name: Outer rescue (won't execute)
ansible.builtin.debug:
msg: "Outer rescue (not reached)"
always:
- name: Outer always
ansible.builtin.debug:
msg: "Outer always executing"
# => Executes regardless of nested block outcome
# Block with variables and conditions
- name: Conditional block execution
block:
- name: Production-only task 1
ansible.builtin.debug:
msg: "Production task executing"
- name: Production-only task 2
ansible.builtin.debug:
msg: "Another production task"
when: environment == "production" # => Entire block conditional
# => All block tasks skip if condition false
vars:
environment: staging # => Block-scoped variableRun: ansible-playbook block_rescue_always.yml
Key Takeaway: Use block/rescue/always for structured error handling similar to try/catch/finally. Rescue section executes only on block failure, always section executes regardless of outcome. Blocks enable atomic operations with rollback—critical for database migrations, configuration updates, and deployments. Apply when conditions to entire blocks for efficiency. Nested blocks have independent error handling contexts.
Why It Matters: Blocks provide transaction-like semantics for grouped tasks—either all succeed or rescue executes recovery. Database schema migrations use block/rescue to rollback on failure. The always block ensures cleanup (temp file deletion, lock release) executes regardless of success or failure, preventing resource leaks in long-running automation.
Example 50: Assertions and Validations
Use assertions to validate prerequisites, enforce invariants, and implement safety checks before dangerous operations.
Code:
---
# assertions.yml
- name: Assertions and Validations
hosts: localhost
gather_facts: true
vars:
required_memory_mb: 2048
required_disk_gb: 20
allowed_environments: [development, staging, production]
current_environment: production
tasks:
# Simple assertion
- name: Assert Ansible version
ansible.builtin.assert:
that:
- ansible_version.full is version('2.14', '>=')
fail_msg: "Ansible 2.14+ required, found {{ ansible_version.full }}"
success_msg: "Ansible version check passed"
# => Fails playbook if condition not met
# Multiple conditions (all must be true)
- name: Assert system requirements
ansible.builtin.assert:
that:
- ansible_memtotal_mb >= required_memory_mb
- ansible_mounts | selectattr('mount', 'equalto', '/') | map(attribute='size_available') | first | int > required_disk_gb * 1024 * 1024 * 1024
- ansible_processor_vcpus >= 2
fail_msg: |
System requirements not met:
- Memory: {{ ansible_memtotal_mb }}MB (required: {{ required_memory_mb }}MB)
- CPUs: {{ ansible_processor_vcpus }} (required: 2+)
success_msg: "System requirements validated"
# => Validates hardware prerequisites before deployment
# Validate environment variable
- name: Assert valid environment
ansible.builtin.assert:
that:
- current_environment in allowed_environments
fail_msg: "Invalid environment: {{ current_environment }}"
# => Prevents deployment to unknown environments
# Validate file exists before operation
- name: Check if configuration exists
ansible.builtin.stat:
path: /etc/app/config.yml
register: config_file
- name: Assert configuration file exists
ansible.builtin.assert:
that:
- config_file.stat.exists
- config_file.stat.size > 0
fail_msg: "Configuration file missing or empty"
# => Fails early if config missing (before app deployment)
# Validate service state
- name: Check current service status
ansible.builtin.systemd:
name: nginx
register: nginx_status
check_mode: true # => Read-only check
- name: Assert service requirements
ansible.builtin.assert:
that:
- nginx_status.status.LoadState == "loaded"
- nginx_status.status.SubState == "running"
fail_msg: "Nginx not running properly"
# => Validates service state before dependent operations
# Validate database connection
- name: Test database connectivity
ansible.builtin.command:
cmd: pg_isready -h localhost -p 5432
register: db_check
failed_when: false
changed_when: false
- name: Assert database accessible
ansible.builtin.assert:
that:
- db_check.rc == 0
fail_msg: "Database not accessible on localhost:5432"
# => Validates connectivity before schema migrations
# Validate variable types and format
- name: Assert variable types
ansible.builtin.assert:
that:
- app_port is defined
- app_port is number
- app_port >= 1024
- app_port <= 65535
- app_name is string
- app_name | length > 0
fail_msg: "Invalid variable types or values"
vars:
app_port: 8080
app_name: "MyApp"
# => Validates variable schemas before usage
# Conditional assertions (only in production)
- name: Production-specific checks
ansible.builtin.assert:
that:
- ssl_enabled == true
- debug_mode == false
- monitoring_enabled == true
fail_msg: "Production safety checks failed"
when: current_environment == "production"
vars:
ssl_enabled: true
debug_mode: false
monitoring_enabled: true
# => Enforces production safety requirements
# Assert with quiet mode
- name: Silent validation
ansible.builtin.assert:
that:
- ansible_distribution in ['Ubuntu', 'Debian', 'RedHat', 'CentOS']
quiet: true # => Only show output on failure
# => Reduces output noise for successful validationsRun: ansible-playbook assertions.yml
Key Takeaway: Use ansible.builtin.assert to validate prerequisites before dangerous operations—prevents partial failures and corrupted state. Assertions fail fast, stopping playbook execution immediately when conditions aren’t met. Use that: list for multiple conditions (implicit AND). Provide clear fail_msg for troubleshooting. Assertions document assumptions and make playbooks self-validating—critical for production automation.
Why It Matters: Assertions fail fast when preconditions aren’t met, preventing cascading failures and corruption. Production playbooks validate disk space before database installations, RAM before JVM tuning, and network connectivity before cluster formation. Early validation with clear error messages reduces mean-time-to-recovery by 50% compared to cryptic failures deep in task execution.
Group 12: Tags & Task Control
Example 51: Task Tagging Basics
Tags enable selective task execution without modifying playbooks. Run subsets of tasks using --tags and --skip-tags.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Playbook with Tags"] --> B["Task 1: install"]
A --> C["Task 2: configure"]
A --> D["Task 3: always"]
A --> E["Task 4: never"]
F["--tags install"] --> G["Execute Task 1 Only"]
H["--tags configure"] --> I["Execute Task 2 Only"]
J["No Tags"] --> K["Execute All Except never"]
L["--tags never"] --> M["Execute Task 4 Only"]
style A fill:#0173B2,color:#fff
style B fill:#DE8F05,color:#fff
style C fill:#029E73,color:#fff
style D fill:#CC78BC,color:#fff
style E fill:#CA9161,color:#fff
style G fill:#DE8F05,color:#fff
style I fill:#029E73,color:#fff
style K fill:#CC78BC,color:#fff
style M fill:#CA9161,color:#fff
Code:
---
# task_tags.yml
- name: Task Tagging Basics
hosts: localhost
gather_facts: false
tasks:
# Single tag
- name: Install packages
ansible.builtin.package:
name: nginx
state: present
tags: install # => Run with --tags install
# => ansible-playbook playbook.yml --tags install
# Multiple tags
- name: Configure nginx
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
tags:
- configure # => First tag
- nginx # => Second tag
# => ansible-playbook playbook.yml --tags configure
# => ansible-playbook playbook.yml --tags nginx (both work)
# Tagged task with notification
- name: Deploy application
ansible.builtin.copy:
src: app.tar.gz
dest: /opt/app/
tags: deploy
notify: restart app
# Special tag: always
- name: Verify system requirements
ansible.builtin.assert:
that:
- ansible_memtotal_mb >= 1024
tags: always # => Always runs (unless --skip-tags always)
# => Executes even with --tags deploy
# Special tag: never
- name: Dangerous operation
ansible.builtin.command:
cmd: rm -rf /tmp/cache/*
tags: never # => Never runs (unless explicitly --tags never)
# => Skipped by default, requires --tags never
# Untagged task
- name: Create log directory
ansible.builtin.file:
path: /var/log/app
state: directory
# => Runs when no --tags specified (runs all untagged + always)
# Multiple roles of same tag
- name: Update configuration
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/config.conf
tags:
- configure
- update
# => Runs with either --tags configure OR --tags update
handlers:
- name: restart app
ansible.builtin.service:
name: app
state: restarted
tags: always # => Handler inherits tags from notifying task
# => Executes if notifying task runs and changesRun with tags:
# Run only install tasks
ansible-playbook task_tags.yml --tags install
# Run multiple tags
ansible-playbook task_tags.yml --tags "install,configure"
# Run all except specific tags
ansible-playbook task_tags.yml --skip-tags deploy
# Run only never tasks
ansible-playbook task_tags.yml --tags never
# List available tags
ansible-playbook task_tags.yml --list-tags
# List tasks with specific tag
ansible-playbook task_tags.yml --tags deploy --list-tasks
# Combine tags and skip-tags
ansible-playbook task_tags.yml --tags configure --skip-tags neverKey Takeaway: Tags enable surgical playbook execution without code changes. Use always tag for tasks that must run every time (validations, checks). Use never tag for dangerous operations requiring explicit opt-in. Tasks can have multiple tags for flexible grouping. Handlers inherit tags from notifying tasks. Tags are a runtime feature—playbook code remains unchanged.
Why It Matters: Tags enable selective playbook execution for faster iteration and targeted operations. Deploy only database changes with --tags database, skip slow integration tests with --skip-tags tests. Production hotfixes run tagged tasks to patch vulnerabilities in minutes without full infrastructure reconfiguration. This pattern reduces deployment time from 30 minutes to 2 minutes for focused changes.
Example 52: Role and Play Tagging
Apply tags to entire plays or roles for coarse-grained control. Tags cascade to all tasks within tagged play/role.
Code:
Role with tags (roles/webserver/tasks/main.yml):
---
# Webserver role tasks
- name: Install web server
ansible.builtin.package:
name: nginx
state: present
tags: install # => Task-level tag
- name: Configure web server
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
tags: configure
- name: Start web server
ansible.builtin.service:
name: nginx
state: started
tags: servicePlaybook with role and play tags:
---
# role_play_tags.yml
# Play 1: Tagged play
- name: Setup Phase
hosts: localhost
gather_facts: true
tags: setup # => Play-level tag (applies to all tasks)
# => All tasks in this play inherit 'setup' tag
tasks:
- name: Update package cache
ansible.builtin.apt:
update_cache: true
when: ansible_os_family == "Debian"
# => Inherits 'setup' tag from play
- name: Install common packages
ansible.builtin.package:
name:
- curl
- vim
state: present
# => Also inherits 'setup' tag
# Play 2: Role with tags
- name: Deploy Web Server
hosts: localhost
become: true
tags: webserver # => Play-level tag
roles:
- role: webserver
tags: web # => Role-level tag (additional to play tag)
# => All role tasks have both 'webserver' (play) and 'web' (role) tags
# => Can run with --tags webserver OR --tags web
# Play 3: Multiple roles with different tags
- name: Deploy Application Stack
hosts: localhost
become: true
roles:
- role: database
tags: database # => Only database role
- role: cache
tags: cache # => Only cache role
- role: application
tags:
- app # => Multiple role tags
- deploy
# Play 4: Mixed tagged and untagged tasks
- name: Configuration Phase
hosts: localhost
tags: config
tasks:
- name: Tagged task within tagged play
ansible.builtin.debug:
msg: "Has both 'config' (play) and 'important' (task) tags"
tags: important
# => Has tags: config, important
- name: Untagged task in tagged play
ansible.builtin.debug:
msg: "Only has 'config' tag from play"
# => Has tags: config
# Play 5: Tag inheritance demonstration
- name: Tag Inheritance
hosts: localhost
tags:
- phase1
- initialization
tasks:
- name: Task with additional tags
ansible.builtin.debug:
msg: "Multiple inherited and task tags"
tags:
- step1
- critical
# => Has tags: phase1, initialization, step1, criticalRun with various tag combinations:
# Run entire setup phase
ansible-playbook role_play_tags.yml --tags setup
# Run webserver role (via play tag OR role tag)
ansible-playbook role_play_tags.yml --tags webserver
ansible-playbook role_play_tags.yml --tags web
# Run only database deployment
ansible-playbook role_play_tags.yml --tags database
# Run multiple roles
ansible-playbook role_play_tags.yml --tags "database,cache,app"
# Skip entire phase
ansible-playbook role_play_tags.yml --skip-tags config
# Run specific task tag across all plays
ansible-playbook role_play_tags.yml --tags installTag inheritance hierarchy:
- Play tags → Apply to all tasks in play
- Role tags → Apply to all tasks in role
- Task tags → Apply to specific task
- Combined: Task has all three levels
Key Takeaway: Tag plays for phase-level control (setup, deploy, cleanup), tag roles for component-level control (webserver, database), tag tasks for operation-level control (install, configure). Tags are additive—tasks inherit play and role tags plus their own task tags. This multi-level tagging enables flexible execution patterns from coarse-grained (entire plays) to fine-grained (specific tasks).
Why It Matters: Play-level and role-level tags organize complex playbooks by operational phase (prepare, deploy, verify) or concern (security, monitoring, backup). CI/CD pipelines run different tag combinations for different deployment stages—validation tags in pull requests, full deployment tags in production. This structured approach reduces cognitive load when managing 20+ role playbooks.
Example 53: Tag-Based Workflows
Design playbooks with tag-based workflows for common operational scenarios: deployment, rollback, health checks, maintenance.
Code:
---
# tag_workflows.yml
- name: Application Deployment Workflow
hosts: localhost
become: true
gather_facts: true
vars:
app_version: "2.1.0"
app_user: appuser
tasks:
# Pre-deployment checks (always run)
- name: Verify system requirements
ansible.builtin.assert:
that:
- ansible_memtotal_mb >= 2048
- ansible_mounts | selectattr('mount', 'equalto', '/') | map(attribute='size_available') | first | int > 10000000000
tags: always
# => Runs with any tag combination
# Backup phase
- name: Create backup directory
ansible.builtin.file:
path: /backup/{{ ansible_date_time.date }}
state: directory
tags:
- backup
- rollback # => Also needed for rollback
- name: Backup application
ansible.builtin.command:
cmd: tar czf /backup/{{ ansible_date_time.date }}/app-backup.tar.gz /opt/app
tags:
- backup
- rollback
# => ansible-playbook playbook.yml --tags backup
# Deployment phase
- name: Stop application
ansible.builtin.service:
name: app
state: stopped
tags: deploy
# => ansible-playbook playbook.yml --tags deploy
- name: Deploy new version
ansible.builtin.unarchive:
src: /tmp/app-{{ app_version }}.tar.gz
dest: /opt/app
owner: "{{ app_user }}"
tags: deploy
- name: Update configuration
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/config.conf
tags:
- deploy
- config # => Can update config independently
- name: Run database migrations
ansible.builtin.command:
cmd: /opt/app/bin/migrate
tags:
- deploy
- migration
# => ansible-playbook playbook.yml --tags migration (run migrations only)
- name: Start application
ansible.builtin.service:
name: app
state: started
tags:
- deploy
- restart # => Quick restart without full deploy
# => ansible-playbook playbook.yml --tags restart
# Health check phase
- name: Wait for application startup
ansible.builtin.wait_for:
host: localhost
port: 8080
delay: 5
timeout: 60
tags:
- deploy
- health_check
- verify
# => ansible-playbook playbook.yml --tags health_check
- name: Verify application health
ansible.builtin.uri:
url: http://localhost:8080/health
return_content: true
register: health_response
tags:
- deploy
- health_check
- verify
- name: Assert healthy status
ansible.builtin.assert:
that:
- health_response.status == 200
- "'healthy' in health_response.content"
tags:
- deploy
- health_check
- verify
# Rollback phase (never runs unless explicitly tagged)
- name: Stop application (rollback)
ansible.builtin.service:
name: app
state: stopped
tags:
- rollback
- never # => Requires --tags rollback
- name: Restore from backup
ansible.builtin.unarchive:
src: /backup/{{ ansible_date_time.date }}/app-backup.tar.gz
dest: /
remote_src: true
tags:
- rollback
- never
- name: Start application (rollback)
ansible.builtin.service:
name: app
state: started
tags:
- rollback
- never
# Maintenance mode
- name: Enable maintenance page
ansible.builtin.copy:
src: maintenance.html
dest: /var/www/html/index.html
tags:
- maintenance
- never
# => ansible-playbook playbook.yml --tags maintenance
- name: Disable maintenance page
ansible.builtin.file:
path: /var/www/html/index.html
state: absent
tags:
- unmaintenance
- never
# Cleanup phase
- name: Remove old backups
ansible.builtin.find:
paths: /backup
age: 7d
file_type: directory
register: old_backups
tags:
- cleanup
- never
- name: Delete old backups
ansible.builtin.file:
path: "{{ item.path }}"
state: absent
loop: "{{ old_backups.files }}"
tags:
- cleanup
- neverWorkflow execution scenarios:
# Full deployment (backup + deploy + verify)
ansible-playbook tag_workflows.yml --tags "backup,deploy"
# Quick config update without full deploy
ansible-playbook tag_workflows.yml --tags config
# Restart application only
ansible-playbook tag_workflows.yml --tags restart
# Run health checks only
ansible-playbook tag_workflows.yml --tags health_check
# Emergency rollback
ansible-playbook tag_workflows.yml --tags rollback
# Enable maintenance mode
ansible-playbook tag_workflows.yml --tags maintenance
# Database migration only
ansible-playbook tag_workflows.yml --tags migration
# Cleanup old backups
ansible-playbook tag_workflows.yml --tags cleanup
# Deployment skipping health checks
ansible-playbook tag_workflows.yml --tags deploy --skip-tags health_checkKey Takeaway: Design playbooks with overlapping tags for different workflows. Use never tag for destructive operations (rollback, maintenance, cleanup). Combine always for prerequisites and validation. Tag grouping enables: full deployments (backup,deploy), quick operations (restart, config), maintenance tasks (cleanup, maintenance), and emergency procedures (rollback). This pattern creates playbooks that serve multiple operational needs without duplication.
Why It Matters: Tag-driven workflows implement operational procedures as tag combinations—disaster recovery as --tags restore,verify, compliance as --tags audit,report. This codifies operational knowledge into executable procedures, enabling junior engineers to perform complex operations safely. Tag dependencies (always and never) prevent dangerous operations like --tags deploy without --tags validate.
Example 54: Task Delegation and Run Once
Delegate tasks to different hosts or execute once across group. Control task execution context beyond inventory targeting.
Code:
---
# delegation.yml
- name: Task Delegation and Run Once
hosts: webservers # => Target host group
gather_facts: true
tasks:
# Regular task (runs on all webservers)
- name: Install nginx on webservers
ansible.builtin.package:
name: nginx
state: present
# => Executes on web1, web2, web3, ... (all webservers)
# Delegate to specific host
- name: Update load balancer configuration
ansible.builtin.template:
src: backend.conf.j2
dest: /etc/haproxy/backends.conf
delegate_to: loadbalancer.example.com # => Runs on loadbalancer, not webservers
# => Executes once on loadbalancer for each webserver
# => Has access to webserver variables/facts
# Delegate to localhost (control node)
- name: Generate report on control node
ansible.builtin.copy:
dest: /tmp/webserver_report.txt
content: |
Webserver: {{ inventory_hostname }}
IP: {{ ansible_default_ipv4.address }}
Memory: {{ ansible_memtotal_mb }}MB
delegate_to: localhost # => Runs on control machine
# => Creates report file locally for each webserver
# Run once across all hosts
- name: Database migration (run once)
ansible.builtin.command:
cmd: /usr/local/bin/migrate-database.sh
run_once: true # => Executes on first host only
# => Runs on web1 (first in group), skips web2, web3, ...
# Run once with delegation
- name: Notify external service (run once)
ansible.builtin.uri:
url: https://api.example.com/deployment
method: POST
body_format: json
body:
event: deployment_started
timestamp: "{{ ansible_date_time.iso8601 }}"
run_once: true
delegate_to: localhost # => Runs once on control node
# => Single API call regardless of webserver count
# Local action (shorthand for delegate_to: localhost)
- name: Create local backup directory
ansible.builtin.file:
path: /tmp/backups/{{ inventory_hostname }}
state: directory
delegate_to: localhost
# => Creates directory on control node for each webserver
# Delegate facts gathering
- name: Gather facts from database server
ansible.builtin.setup:
delegate_to: db.example.com
delegate_facts: true # => Store facts under db.example.com, not webserver
# => Collects db.example.com facts accessible via hostvars['db.example.com']
# Use delegated facts
- name: Configure database connection
ansible.builtin.template:
src: db_config.j2
dest: /etc/app/database.conf
content: |
host={{ hostvars['db.example.com'].ansible_default_ipv4.address }}
port=5432
# => Uses database server IP from delegated facts
# Delegate with connection override
- name: Execute on remote via specific connection
ansible.builtin.command:
cmd: kubectl get pods
delegate_to: k8s-master.example.com
vars:
ansible_connection: ssh
ansible_user: k8s-admin
# => Overrides connection parameters for delegation
# Run once with conditional
- name: Conditional run-once task
ansible.builtin.debug:
msg: "First production webserver: {{ inventory_hostname }}"
run_once: true
when: "'production' in group_names"
# => Runs once on first host matching condition
# Delegate to inventory group member
- name: Update monitoring server
ansible.builtin.uri:
url: http://{{ item }}/api/register
method: POST
body_format: json
body:
hostname: "{{ inventory_hostname }}"
ip: "{{ ansible_default_ipv4.address }}"
delegate_to: "{{ item }}"
loop: "{{ groups['monitoring'] }}" # => Delegate to each monitoring server
# => Registers this webserver with all monitoring serversPractical scenarios:
---
# Rolling deployment with load balancer
- hosts: webservers
serial: 1 # => One host at a time
tasks:
# Remove from load balancer
- name: Disable server in load balancer
ansible.builtin.command:
cmd: /usr/local/bin/lb-disable {{ inventory_hostname }}
delegate_to: loadbalancer.example.com
# => Runs on LB for current webserver
# Update application
- name: Deploy new version
ansible.builtin.copy:
src: /tmp/app.tar.gz
dest: /opt/app/
# Add back to load balancer
- name: Enable server in load balancer
ansible.builtin.command:
cmd: /usr/local/bin/lb-enable {{ inventory_hostname }}
delegate_to: loadbalancer.example.comRun: ansible-playbook -i inventory delegation.yml
Key Takeaway: Use delegate_to to execute tasks on different hosts while maintaining access to target host variables—critical for load balancer updates, external API calls, and centralized operations. Use run_once to avoid redundant operations like database migrations or API notifications. Combine run_once with delegate_to: localhost for single-execution control node tasks. Use delegate_facts: true when gathering facts from delegated hosts. Delegation is the mechanism for cross-host orchestration and external system integration.
Why It Matters: Delegation executes tasks on different hosts than the target inventory—database backups run on backup servers, load balancer updates run on control planes. The run_once directive prevents redundant operations like schema migrations running on every app server. These patterns enable centralized orchestration of distributed systems from single playbooks.
🎯 Intermediate level complete! You’ve covered roles, handlers, templates, vault, error handling, and task control. These patterns enable production-ready automation with reusability, security, and resilience. Proceed to Advanced for custom modules, collections, testing, and CI/CD integration.