13 - Roles
This project is me documenting my journey of learning Ansible that is focused on network engineering. It’s not a “how-to guide” per-say, more of a diary. A lot of information on here is so I can come back to and reference later. I also learn best when teaching someone, and this is kind of me teaching.
Part 13: Ansible Roles β Reusable Automation
Parts 8β12 produced playbooks that work. This part produces playbooks that scale. The difference is roles. Without roles, every playbook that configures a Cisco IOS device duplicates the same tasks β the same NTP configuration, the same hostname logic, the same syslog setup. Change one detail and it needs updating in a dozen files. Roles collect all of that into a single, self-contained unit that any playbook can call with one line. This part builds two complete roles for the lab, covers every component of the role directory structure, and establishes the role patterns used for the rest of this guide.
13.1 β What Roles Are and Why They Matter
A role is a self-contained, reusable unit of Ansible automation. It packages tasks, variables, handlers, templates, and files together in a standardized directory structure that any playbook can consume with a single roles: reference.
The Problem Roles Solve
Without roles, the deploy_base_config.yml playbook from Part 8 contains the full IOS base configuration logic. The deploy_ios_config.yml from Part 11 duplicates parts of it. A backup_and_reconfigure.yml playbook duplicates more. When the NTP server addresses change, I update three playbooks. When the SNMP community string format changes, I update five.
With roles:
# Any playbook that needs IOS base config just calls the role
- name: "Deploy | Base configuration"
hosts: cisco_ios
gather_facts: false
roles:
- cisco_ios_base # One line β runs the complete base config roleThe NTP logic, SNMP logic, hostname logic, syslog logic β all of it lives in the role. When it changes, it changes in one place.
Roles vs Playbooks vs Tasks
Playbook β orchestrates what runs and in what order
βββ Role β a self-contained, reusable automation unit
βββ Tasks β the individual module calls
βββ Handlers β triggered by task changes, run once at play endA playbook calls roles. Roles contain tasks. Tasks trigger handlers. These are three distinct layers, each with a clear purpose.
### π’ Real-World Scenario
A network automation team I know built their entire Ansible project without roles for the first year β 60+ playbooks, all standalone. When they needed to add TACACS+ authentication to every device, they discovered the base configuration logic was spread across 23 different playbooks. Adding TACACS+ took three days and introduced inconsistencies because several playbooks were missed. After they refactored to roles β one
cisco_ios_baserole, onecisco_nxos_baserole β the same change took two hours: update the role, run a single playbook. The refactor itself took two weeks, but they never regretted it.
13.2 β The Role Directory Structure
Every role follows an identical directory structure. Ansible knows what each directory contains and loads it automatically:
roles/
βββ cisco_ios_base/ β Role name (matches how it's called from playbooks)
βββ tasks/
β βββ main.yml β Entry point β the task list that runs
βββ handlers/
β βββ main.yml β Handlers triggered by notify: in tasks
βββ templates/
β βββ *.j2 β Jinja2 templates used by this role
βββ files/
β βββ * β Static files copied to devices or control node
βββ vars/
β βββ main.yml β Role variables β HIGH precedence, rarely overridden
βββ defaults/
β βββ main.yml β Role defaults β LOW precedence, easily overridden
βββ meta/
β βββ main.yml β Role metadata: dependencies, author, platform info
βββ README.md β Role documentation (generated by ansible-galaxy)What Each Directory Does
tasks/main.yml β The entry point. When a role is called, Ansible reads this file first and executes the tasks within it. Tasks can include_tasks: other files in tasks/ to break long task lists into sections.
handlers/main.yml β Handlers are tasks that only run when explicitly triggered by notify: in another task, and only when that task reports changed. They run once at the end of the play regardless of how many tasks notified them. This is where “save configuration” and “reload service” logic lives.
templates/ β Jinja2 template files (.j2) used by ansible.builtin.template tasks within this role. Templates in a role’s templates/ directory are found automatically β no path prefix needed.
files/ β Static files deployed by ansible.builtin.copy tasks. Like templates, files in a role’s files/ directory are found automatically.
vars/main.yml β Variables that are internal to the role and should not be overridden. High precedence (level 13 in the 18-level chain). Use for constants the role depends on β things that would break the role if changed from outside.
defaults/main.yml β Default values for variables the role uses. Low precedence (level 1 β lowest of all). These are overridden by group_vars, host_vars, play vars, or -e. Use for configurable parameters with sensible defaults.
meta/main.yml β Role metadata: dependencies on other roles, author info, supported platforms, minimum Ansible version. Dependencies listed here are automatically executed before this role runs.
13.3 β Creating Roles with ansible-galaxy role init
ansible-galaxy role init generates the complete directory skeleton:
cd ~/projects/ansible-network
# Create the IOS base configuration role
ansible-galaxy role init roles/cisco_ios_base
# Create the NX-OS VLAN management role
ansible-galaxy role init roles/cisco_nxos_vlans
# Verify the structure was created correctly
tree roles/cisco_ios_baseroles/cisco_ios_base/
βββ README.md
βββ defaults/
β βββ main.yml
βββ files/
βββ handlers/
β βββ main.yml
βββ meta/
β βββ main.yml
βββ tasks/
β βββ main.yml
βββ templates/
βββ tests/
β βββ inventory
β βββ test.yml
βββ vars/
βββ main.ymlCleaning Up the Skeleton
ansible-galaxy role init generates more than I need for a network automation role. I remove the tests/ directory (I’ll use a separate testing approach covered in Part 30) and the generated boilerplate inside each main.yml:
# Remove the generated tests directory
rm -rf roles/cisco_ios_base/tests roles/cisco_nxos_vlans/tests
# The generated main.yml files have boilerplate comments β I'll replace them
# with actual content as I build each role13.4 β Role defaults vs vars β When to Use Each
This is the most commonly misunderstood aspect of roles. The distinction is simple once I understand what each is for.
defaults/main.yml β Configurable Parameters
Defaults are variables the role user is expected to override. They represent the role’s configurable interface β the knobs someone turning can adjust when calling the role. They have the lowest precedence of any variable, meaning anything in host_vars, group_vars, or -e overrides them.
# roles/cisco_ios_base/defaults/main.yml
---
# =============================================================
# Configurable defaults β override in group_vars or host_vars
# =============================================================
# SSH session timeouts (seconds)
ios_exec_timeout_minutes: 10
ios_exec_timeout_seconds: 0
# Terminal settings
ios_terminal_length: 0
ios_terminal_width: 512
# Service settings
ios_service_password_encryption: true
ios_ip_source_route: false
# Logging settings
ios_logging_buffer_size: 16384
ios_logging_severity: informational
# Archive/configuration archive settings
ios_archive_enabled: false
ios_archive_path: "disk0:/archive"
ios_archive_maximum: 14These are defaults that work fine out of the box for most devices but can be overridden when a specific device or group needs different values. For example, if my WAN routers need a longer exec timeout, I add ios_exec_timeout_minutes: 30 to group_vars/wan.yml and the default is overridden only for that group.
vars/main.yml β Internal Role Constants
Vars are values the role depends on internally and doesn’t expect users to change. They have high precedence (level 13) β higher than host_vars β so they’re very difficult to override accidentally. Use these for constants that define the role’s behavior, not its configuration.
# roles/cisco_ios_base/vars/main.yml
---
# =============================================================
# Internal role constants β not intended to be overridden
# These define how the role operates, not what it configures
# =============================================================
# Supported platforms for this role β used in assert tasks
_ios_base_supported_platforms:
- cisco.ios.ios
# Required variables β checked by the role's pre-flight assertions
_ios_base_required_vars:
- device_hostname
- ntp_servers
- syslog_server
# Internal state tracking (used across task files)
_ios_base_config_changed: falseThe leading _ underscore prefix is a community convention for internal role variables β it signals “this is not part of the role’s public interface.”
The Practical Rule
Question: Should a user of this role be able to change this value?
YES β defaults/main.yml (low precedence, easily overridden)
NO β vars/main.yml (high precedence, internal to role)Concrete examples from the IOS base role:
| Variable | Location | Reason |
|---|---|---|
ios_exec_timeout_minutes | defaults | Different device groups may need different timeouts |
ios_logging_severity | defaults | Some devices may need debug logging temporarily |
ios_terminal_length | defaults | Override to 24 for devices with pagination issues |
_ios_base_supported_platforms | vars | Internal check β never changes based on inventory |
_ios_base_required_vars | vars | Defines the role contract β not user-configurable |
13.5 β Building the cisco_ios_base Role β Complete
This is the most important role in the project. It configures every standard element that every IOS device should have β the foundation that all other roles build on.
roles/cisco_ios_base/defaults/main.yml
cat > ~/projects/ansible-network/roles/cisco_ios_base/defaults/main.yml << 'EOF'
---
# =============================================================
# cisco_ios_base role defaults
# Override any of these in group_vars or host_vars
# =============================================================
# --- Terminal and session settings ---
ios_exec_timeout_minutes: 10
ios_exec_timeout_seconds: 0
ios_terminal_length: 0
ios_terminal_width: 512
# --- Security hardening ---
ios_service_password_encryption: true
ios_ip_source_route: false
ios_ip_proxy_arp: false
ios_cdp_enabled: true # Set false to disable CDP globally
# --- Logging ---
ios_logging_buffer_size: 16384
ios_logging_severity: informational
ios_logging_console: false # Disable console logging (reduces CPU on busy devices)
# --- Archive (configuration versioning on device) ---
ios_archive_enabled: false
ios_archive_path: "disk0:/archive"
ios_archive_maximum: 14
# --- Banner ---
ios_banner_enabled: true
ios_banner_text: |
**************************************************************************
* Authorized access only. All activity is monitored and logged. *
* Disconnect immediately if you are not an authorized user. *
**************************************************************************
# --- Save behavior ---
ios_save_on_change: true # Write memory when configuration changes
EOFroles/cisco_ios_base/vars/main.yml
cat > ~/projects/ansible-network/roles/cisco_ios_base/vars/main.yml << 'EOF'
---
# =============================================================
# cisco_ios_base role internal variables
# Not intended to be overridden from outside the role
# =============================================================
_ios_base_supported_platforms:
- cisco.ios.ios
_ios_base_required_vars:
- device_hostname
- ntp_servers
- syslog_server
- domain_name
EOFroles/cisco_ios_base/tasks/main.yml
Rather than one enormous task file, I break the role into logical task files and include them from main.yml. This keeps each section navigable and makes it easy to run specific sections with tags:
cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/main.yml << 'EOF'
---
# =============================================================
# cisco_ios_base β main task entry point
# Includes task files in configuration order
# =============================================================
- name: "IOS Base | Pre-flight checks"
ansible.builtin.include_tasks: preflight.yml
tags: always # Pre-flight always runs regardless of other tags
- name: "IOS Base | Gather device facts"
ansible.builtin.include_tasks: gather_facts.yml
tags:
- facts
- always
- name: "IOS Base | Configure device identity"
ansible.builtin.include_tasks: identity.yml
tags:
- identity
- base
- name: "IOS Base | Configure global services"
ansible.builtin.include_tasks: services.yml
tags:
- services
- base
- name: "IOS Base | Configure NTP"
ansible.builtin.include_tasks: ntp.yml
tags:
- ntp
- base
- name: "IOS Base | Configure logging"
ansible.builtin.include_tasks: logging.yml
tags:
- logging
- base
- name: "IOS Base | Configure SNMP"
ansible.builtin.include_tasks: snmp.yml
tags:
- snmp
- base
- name: "IOS Base | Configure login banner"
ansible.builtin.include_tasks: banner.yml
tags:
- banner
- base
EOFroles/cisco_ios_base/tasks/preflight.yml
cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/preflight.yml << 'EOF'
---
# Pre-flight: verify required variables exist before any config is pushed
- name: "IOS Base | Pre-flight | Verify required variables are defined"
ansible.builtin.assert:
that:
- device_hostname is defined
- ntp_servers is defined
- ntp_servers | length > 0
- syslog_server is defined
- domain_name is defined
fail_msg: >
Role cisco_ios_base is missing required variables for {{ inventory_hostname }}.
Ensure host_vars/{{ inventory_hostname }}.yml defines:
device_hostname, ntp_servers, syslog_server, domain_name.
success_msg: "Pre-flight passed for {{ inventory_hostname }}"
- name: "IOS Base | Pre-flight | Verify platform is supported"
ansible.builtin.assert:
that:
- ansible_network_os in _ios_base_supported_platforms
fail_msg: >
cisco_ios_base role does not support platform: {{ ansible_network_os }}.
Supported platforms: {{ _ios_base_supported_platforms | join(', ') }}
success_msg: "Platform check passed: {{ ansible_network_os }}"
EOFroles/cisco_ios_base/tasks/gather_facts.yml
cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/gather_facts.yml << 'EOF'
---
- name: "IOS Base | Facts | Gather IOS device facts"
cisco.ios.ios_facts:
gather_subset:
- default
- hardware
- name: "IOS Base | Facts | Display device summary"
ansible.builtin.debug:
msg:
- "Device: {{ inventory_hostname }}"
- "Hostname: {{ ansible_net_hostname }}"
- "Version: {{ ansible_net_version }}"
- "Model: {{ ansible_net_model | default('unknown') }}"
verbosity: 1 # Only shown with -v
EOFroles/cisco_ios_base/tasks/identity.yml
cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/identity.yml << 'EOF'
---
- name: "IOS Base | Identity | Set hostname"
cisco.ios.ios_hostname:
config:
hostname: "{{ device_hostname }}"
state: merged
notify: "Save IOS configuration"
- name: "IOS Base | Identity | Set IP domain name"
cisco.ios.ios_config:
lines:
- "ip domain-name {{ domain_name }}"
notify: "Save IOS configuration"
EOFroles/cisco_ios_base/tasks/services.yml
cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/services.yml << 'EOF'
---
- name: "IOS Base | Services | Enable password encryption"
cisco.ios.ios_config:
lines:
- "service password-encryption"
when: ios_service_password_encryption | bool
notify: "Save IOS configuration"
- name: "IOS Base | Services | Disable IP source routing"
cisco.ios.ios_config:
lines:
- "no ip source-route"
when: not ios_ip_source_route | bool
notify: "Save IOS configuration"
- name: "IOS Base | Services | Set console and VTY exec-timeout"
cisco.ios.ios_config:
lines:
- "exec-timeout {{ ios_exec_timeout_minutes }} {{ ios_exec_timeout_seconds }}"
parents: "line con 0"
notify: "Save IOS configuration"
- name: "IOS Base | Services | Set VTY exec-timeout and transport"
cisco.ios.ios_config:
lines:
- "exec-timeout {{ ios_exec_timeout_minutes }} {{ ios_exec_timeout_seconds }}"
- "transport input ssh"
- "transport output none"
parents: "line vty 0 15"
notify: "Save IOS configuration"
- name: "IOS Base | Services | Set terminal width and length"
cisco.ios.ios_config:
lines:
- "terminal width {{ ios_terminal_width }}"
- "terminal length {{ ios_terminal_length }}"
notify: "Save IOS configuration"
EOFroles/cisco_ios_base/tasks/ntp.yml
cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/ntp.yml << 'EOF'
---
- name: "IOS Base | NTP | Configure NTP servers"
cisco.ios.ios_ntp_global:
config:
servers:
- server: "{{ item }}"
prefer: "{{ true if loop.index == 1 else false }}"
state: merged
loop: "{{ ntp_servers }}"
loop_control:
label: "NTP server: {{ item }}"
notify: "Save IOS configuration"
- name: "IOS Base | NTP | Configure NTP source interface"
cisco.ios.ios_config:
lines:
- "ntp source {{ ntp_source_interface }}"
when: ntp_source_interface is defined
notify: "Save IOS configuration"
EOFroles/cisco_ios_base/tasks/logging.yml
cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/logging.yml << 'EOF'
---
- name: "IOS Base | Logging | Configure syslog host"
cisco.ios.ios_logging_global:
config:
hosts:
- hostname: "{{ syslog_server }}"
severity: "{{ ios_logging_severity }}"
buffered:
size: "{{ ios_logging_buffer_size }}"
severity: "{{ ios_logging_severity }}"
state: merged
notify: "Save IOS configuration"
- name: "IOS Base | Logging | Disable console logging"
cisco.ios.ios_config:
lines:
- "no logging console"
when: not ios_logging_console | bool
notify: "Save IOS configuration"
EOFroles/cisco_ios_base/tasks/snmp.yml
cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/snmp.yml << 'EOF'
---
- name: "IOS Base | SNMP | Configure SNMP community"
cisco.ios.ios_config:
lines:
- "snmp-server community {{ snmp_community_ro }} RO"
no_log: true # snmp_community_ro is sensitive
notify: "Save IOS configuration"
- name: "IOS Base | SNMP | Configure SNMP location"
cisco.ios.ios_config:
lines:
- "snmp-server location {{ snmp_location }}"
when: snmp_location is defined
notify: "Save IOS configuration"
- name: "IOS Base | SNMP | Configure SNMP contact"
cisco.ios.ios_config:
lines:
- "snmp-server contact {{ snmp_contact }}"
when: snmp_contact is defined
notify: "Save IOS configuration"
EOFroles/cisco_ios_base/tasks/banner.yml
cat > ~/projects/ansible-network/roles/cisco_ios_base/tasks/banner.yml << 'EOF'
---
- name: "IOS Base | Banner | Configure login banner"
cisco.ios.ios_banner:
banner: login
text: "{{ ios_banner_text }}"
state: present
when: ios_banner_enabled | bool
notify: "Save IOS configuration"
EOF13.6 β Handlers: The Deep Dive
Handlers are tasks that run in response to notify: calls from regular tasks. They solve a specific and important problem in network automation β the “save configuration” problem.
The Problem Without Handlers
Without handlers, every task that changes configuration needs to save it:
# β Every task saves β if 8 tasks change, 8 write memory commands execute
- name: Set hostname
cisco.ios.ios_hostname:
config:
hostname: "{{ device_hostname }}"
state: merged
register: hostname_result
- name: Save after hostname
cisco.ios.ios_command:
commands: [write memory]
when: hostname_result.changed
- name: Configure NTP
cisco.ios.ios_ntp_global:
...
register: ntp_result
- name: Save after NTP
cisco.ios.ios_command:
commands: [write memory]
when: ntp_result.changedThis creates two problems: multiple write memory commands on one device in a single play (wasteful and potentially disruptive on loaded hardware), and complex dependency tracking between tasks. If I add a new task and forget to add a corresponding save task, the configuration is lost on reload.
The Real Reload Loop Scenario
This problem becomes severe with operations that require a reload β like changing a device’s management VRF or enabling OSPF process configuration. Consider this without handlers:
# β Without handlers β a reload triggers 3 times if 3 tasks all change config
- name: Task 1 β change OSPF router-id
cisco.ios.ios_config:
lines: ["router-id 10.255.0.1"]
parents: "router ospf 1"
when: ospf_changed
# Reload here...
- name: Task 2 β change BGP router-id
...
# Reload here again...
- name: Task 3 β change NTP
...
# Reload here a third time...With handlers, the operation happens exactly once, at the end of all tasks, after it’s clear everything has been configured. The handler doesn’t care how many tasks notified it β it runs once.
How Handlers Work
Task 1 β changed β notify: "Save IOS configuration" β
Task 2 β ok β (no notify) βββ Handler queue
Task 3 β changed β notify: "Save IOS configuration" β builds up
Task 4 β changed β notify: "Save IOS configuration"
...all tasks complete...
β
Handler runs ONCE: "Save IOS configuration"Key rules:
- A handler is triggered only when a task reports
changed(notokorskipped) - Multiple notifies of the same handler result in one handler execution
- Handlers run at the end of the play by default (after all tasks complete)
- If the play fails before reaching the handler execution phase, handlers don’t run
- Handlers run in the order they’re defined in
handlers/main.yml, not the order they’re notified
roles/cisco_ios_base/handlers/main.yml
cat > ~/projects/ansible-network/roles/cisco_ios_base/handlers/main.yml << 'EOF'
---
# =============================================================
# cisco_ios_base handlers
# Triggered by notify: in tasks, run once at end of play
# =============================================================
- name: "Save IOS configuration"
cisco.ios.ios_command:
commands:
- write memory
listen: "Save IOS configuration"
# This handler runs once at play end, regardless of how many
# tasks notified it. Prevents multiple write memory calls.
- name: "Reload IOS device"
cisco.ios.ios_command:
commands:
- reload in 1 # Scheduled reload β gives time to abort if needed
listen: "Reload IOS device"
# Only used when a configuration change requires a reload
# Example: changing the management VRF requires a reload on some platforms
# No tasks in cisco_ios_base currently use this handler β
# it's here for extension when needed
EOFflush_handlers β When I Need Handler to Run Mid-Play
Sometimes I need a handler to run before the play ends β for example, to save config before running a verification task that depends on the saved state:
# In a playbook or role tasks file
- name: "Configure all base settings"
# ... lots of tasks that notify "Save IOS configuration" ...
- name: "Force handler execution now (before verification)"
ansible.builtin.meta: flush_handlers
# Runs all pending handlers at this point
# After this, the "Save IOS configuration" handler has already run
- name: "Verify configuration was saved"
cisco.ios.ios_command:
commands:
- show startup-config | include hostname
register: startup_check
# This task only makes sense AFTER write memory β flush_handlers ensures that### π‘ Tip
Handlers have a subtle failure mode: if a play fails partway through, handlers never run β even if tasks before the failure notified them. If the hostname task succeeded and notified “Save IOS configuration” but a later NTP task failed and stopped the play,
write memorynever executes. The unsaved hostname change is lost on the next reload. The fix isforce_handlers: trueat the play level, which ensures handlers run even when the play fails:- name: Configure IOS devices hosts: cisco_ios force_handlers: true # β Handlers run even if play fails roles: - cisco_ios_baseI use this in all plays that push configuration. A failed play still saves whatever it successfully changed.
13.7 β roles/cisco_ios_base/meta/main.yml
The meta file documents the role and defines dependencies. Role dependencies are automatically executed before the role itself runs β no explicit call needed in the playbook.
cat > ~/projects/ansible-network/roles/cisco_ios_base/meta/main.yml << 'EOF'
---
galaxy_info:
role_name: cisco_ios_base
author: "Your Name"
description: >
Base configuration for Cisco IOS and IOS-XE devices.
Configures hostname, domain, NTP, syslog, SNMP, banner,
and global security settings.
license: MIT
min_ansible_version: "2.15"
platforms:
- name: IOS
versions:
- all
- name: IOS-XE
versions:
- all
# Role dependencies β these roles run BEFORE cisco_ios_base
# Example: if all network devices need a 'common_preflight' role first
# dependencies:
# - role: common_preflight
# - role: cisco_ios_facts
# vars:
# gather_subset: default
# No dependencies for now β cisco_ios_base is self-contained
dependencies: []
EOFUsing Role Dependencies
Dependencies are powerful when I have common setup logic that multiple roles share. For example, if cisco_ios_bgp and cisco_ios_vlans both need facts gathered first, I create a cisco_ios_facts role and list it as a dependency in both:
# roles/cisco_ios_bgp/meta/main.yml
dependencies:
- role: cisco_ios_base # Always run base config first
- role: cisco_ios_facts # Always gather facts first
vars:
gather_subset: defaultWhen a playbook calls cisco_ios_bgp, Ansible automatically runs cisco_ios_base β cisco_ios_facts β cisco_ios_bgp in that order. The playbook only needs to reference the final role.
13.8 β The Role README
A well-written README is what makes a role usable by other engineers:
cat > ~/projects/ansible-network/roles/cisco_ios_base/README.md << 'EOF'
# cisco_ios_base
Base configuration role for Cisco IOS and IOS-XE devices.
## What This Role Does
Configures the foundational settings every IOS device should have:
- Hostname and IP domain name
- Global services (password encryption, SSH-only VTY, exec timeout)
- NTP servers with prefer flag on first server
- Syslog to centralized server
- SNMP read-only community
- Login banner
- Security hardening (no ip source-route, no proxy-arp)
## Requirements
- Cisco IOS or IOS-XE device reachable via SSH
- `ansible_network_os: cisco.ios.ios` set in group_vars
- `ansible_connection: network_cli` set in group_vars
- `cisco.ios` collection installed
## Required Variables
These must be defined in `host_vars/<device>.yml` or `group_vars/`:
| Variable | Description |
|---|---|
| `device_hostname` | Device hostname to configure |
| `ntp_servers` | List of NTP server IPs |
| `syslog_server` | IP address of syslog server |
| `domain_name` | IP domain name |
| `snmp_community_ro` | SNMP read-only community (vault this) |
## Configurable Defaults
Override any of these in `group_vars/` or `host_vars/`:
| Variable | Default | Description |
|---|---|---|
| `ios_exec_timeout_minutes` | `10` | Exec timeout minutes |
| `ios_exec_timeout_seconds` | `0` | Exec timeout seconds |
| `ios_service_password_encryption` | `true` | Enable service password-encryption |
| `ios_ip_source_route` | `false` | Allow IP source routing |
| `ios_logging_severity` | `informational` | Logging severity level |
| `ios_logging_buffer_size` | `16384` | Local log buffer size |
| `ios_banner_enabled` | `true` | Configure login banner |
| `ios_save_on_change` | `true` | Write memory on change |
## Usage
```yaml
- name: Apply IOS base configuration
hosts: cisco_ios
gather_facts: false
connection: network_cli
become: true
become_method: enable
force_handlers: true
roles:
- cisco_ios_baseTags
| Tag | What It Runs |
|---|---|
base | All configuration tasks |
identity | Hostname and domain |
ntp | NTP configuration |
logging | Syslog and buffer |
snmp | SNMP community and location |
banner | Login banner |
facts | Fact gathering only |
always | Pre-flight and facts (always runs) |
| EOF |
---
## 13.9 β Calling Roles from Playbooks
There are three ways to call a role from a playbook. I'll show the practical pattern used throughout this guide and explain the tradeoffs.
### The Primary Pattern: `roles:` Key
```yaml
---
- name: "Deploy | IOS base configuration"
hosts: cisco_ios
gather_facts: false
connection: network_cli
become: true
become_method: enable
force_handlers: true # β Always use with configuration roles
roles:
- cisco_ios_base # Role name matches directory name in roles/This is the primary pattern for this guide. roles: runs before tasks: (if any exist in the same play), which is the desired order β roles set up the device state, then any play-level tasks can run afterward.
Passing Variables to a Role
roles:
- role: cisco_ios_base
vars:
ios_exec_timeout_minutes: 30 # Override the role default for this play
ios_banner_enabled: false # Disable banner for lab devicesMultiple Roles in Sequence
roles:
- cisco_ios_base # Runs first β base configuration
- cisco_ios_interfaces # Runs second β interface configuration
- cisco_ios_routing # Runs third β routing configurationimport_role and include_role β When They’re Needed
# import_role β static, processed at parse time
# Use when: the role always needs to run, tags and --list-tasks work correctly
tasks:
- name: "Apply base config"
ansible.builtin.import_role:
name: cisco_ios_base
# include_role β dynamic, processed at runtime
# Use when: the role is called conditionally or inside a loop
tasks:
- name: "Apply platform-specific role"
ansible.builtin.include_role:
name: "cisco_{{ device_platform }}_base" # Dynamic role name from variable
when: device_platform is defined
- name: "Apply role for each device type"
ansible.builtin.include_role:
name: "{{ item }}"
loop:
- cisco_ios_base
- cisco_ios_interfacesThe Pattern for This Guide
I use the roles: key in play headers for the rest of this guide because:
- It’s the most readable and conventional approach
- Tags work correctly across all roles in the play
--list-tasksshows the complete task listforce_handlers: trueat the play level covers all roles correctly
include_role is only used when I genuinely need dynamic role selection β which happens in Part 19 (multi-platform playbooks) and Part 25 (network-wide compliance enforcement).
13.10 β Building the cisco_nxos_vlans Role
With the full cisco_ios_base pattern established, I build the NX-OS VLAN role using the same structure. This role manages VLAN configuration on all NX-OS switches, using the data model from host_vars and group_vars.
Role Overview
cisco_nxos_vlans/
βββ tasks/main.yml
βββ tasks/preflight.yml
βββ tasks/base_vlans.yml β VLANs from group_vars/leaf_switches.yml
βββ tasks/local_vlans.yml β VLANs from host_vars/<device>.yml
βββ tasks/svi.yml β Layer 3 VLAN interfaces (SVIs)
βββ handlers/main.yml
βββ defaults/main.yml
βββ vars/main.yml
βββ meta/main.ymldefaults/main.yml
cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/defaults/main.yml << 'EOF'
---
# Configurable defaults for cisco_nxos_vlans role
# Whether to configure SVIs (Layer 3 VLAN interfaces)
nxos_vlans_configure_svi: true
# Default SVI state (up/down)
nxos_vlans_svi_state: up
# Whether to remove VLANs not in the data model (use with caution)
nxos_vlans_purge_undefined: false
# VLAN state for all configured VLANs
nxos_vlans_state: active
EOFvars/main.yml
cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/vars/main.yml << 'EOF'
---
_nxos_vlans_supported_platforms:
- cisco.nxos.nxos
_nxos_vlans_required_vars:
- base_vlans # From group_vars/leaf_switches.yml
EOFtasks/main.yml
cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/tasks/main.yml << 'EOF'
---
- name: "NX-OS VLANs | Pre-flight"
ansible.builtin.include_tasks: preflight.yml
tags: always
- name: "NX-OS VLANs | Configure base VLANs (group-wide)"
ansible.builtin.include_tasks: base_vlans.yml
tags:
- vlans
- base_vlans
- name: "NX-OS VLANs | Configure local VLANs (device-specific)"
ansible.builtin.include_tasks: local_vlans.yml
when: local_vlans is defined
tags:
- vlans
- local_vlans
- name: "NX-OS VLANs | Configure SVIs"
ansible.builtin.include_tasks: svi.yml
when: nxos_vlans_configure_svi | bool
tags:
- vlans
- svi
EOFtasks/preflight.yml
cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/tasks/preflight.yml << 'EOF'
---
- name: "NX-OS VLANs | Pre-flight | Verify platform"
ansible.builtin.assert:
that:
- ansible_network_os in _nxos_vlans_supported_platforms
fail_msg: "cisco_nxos_vlans does not support {{ ansible_network_os }}"
- name: "NX-OS VLANs | Pre-flight | Verify required variables"
ansible.builtin.assert:
that:
- base_vlans is defined
- base_vlans | length > 0
fail_msg: "base_vlans must be defined in group_vars/leaf_switches.yml"
EOFtasks/base_vlans.yml
cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/tasks/base_vlans.yml << 'EOF'
---
# Configure VLANs that all leaf switches should have (from group_vars)
# base_vlans is defined in group_vars/leaf_switches.yml
- name: "NX-OS VLANs | Base | Configure standard VLANs"
cisco.nxos.nxos_vlans:
config:
- vlan_id: "{{ item.id }}"
name: "{{ item.name }}"
state: "{{ nxos_vlans_state }}"
state: merged
loop: "{{ base_vlans }}"
loop_control:
label: "VLAN {{ item.id }} β {{ item.name }}"
notify: "Save NX-OS configuration"
EOFtasks/local_vlans.yml
cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/tasks/local_vlans.yml << 'EOF'
---
# Configure VLANs specific to this device (from host_vars)
# local_vlans is defined in host_vars/<device>.yml
# This task file is only included when local_vlans is defined
- name: "NX-OS VLANs | Local | Configure device-specific VLANs"
cisco.nxos.nxos_vlans:
config:
- vlan_id: "{{ item.id }}"
name: "{{ item.name }}"
state: "{{ nxos_vlans_state }}"
state: merged
loop: "{{ local_vlans }}"
loop_control:
label: "VLAN {{ item.id }} β {{ item.name }}"
notify: "Save NX-OS configuration"
EOFtasks/svi.yml
cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/tasks/svi.yml << 'EOF'
---
# Configure SVIs (Layer 3 VLAN interfaces) for local_vlans
# Only VLANs with svi_ip defined get an SVI configured
- name: "NX-OS VLANs | SVI | Enable interface-vlan feature"
cisco.nxos.nxos_feature:
feature: interface-vlan
state: enabled
notify: "Save NX-OS configuration"
- name: "NX-OS VLANs | SVI | Configure SVI interfaces"
cisco.nxos.nxos_l3_interfaces:
config:
- name: "Vlan{{ item.id }}"
ipv4:
- address: "{{ item.svi_ip }}/{{ item.svi_prefix }}"
state: merged
loop: "{{ local_vlans | selectattr('svi_ip', 'defined') | list }}"
loop_control:
label: "Vlan{{ item.id }} β {{ item.svi_ip }}/{{ item.svi_prefix }}"
notify: "Save NX-OS configuration"
- name: "NX-OS VLANs | SVI | Bring SVI interfaces up"
cisco.nxos.nxos_interfaces:
config:
- name: "Vlan{{ item.id }}"
enabled: "{{ true if nxos_vlans_svi_state == 'up' else false }}"
state: merged
loop: "{{ local_vlans | selectattr('svi_ip', 'defined') | list }}"
loop_control:
label: "Vlan{{ item.id }}"
notify: "Save NX-OS configuration"
EOFhandlers/main.yml
cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/handlers/main.yml << 'EOF'
---
- name: "Save NX-OS configuration"
cisco.nxos.nxos_command:
commands:
- copy running-config startup-config
listen: "Save NX-OS configuration"
EOFmeta/main.yml
cat > ~/projects/ansible-network/roles/cisco_nxos_vlans/meta/main.yml << 'EOF'
---
galaxy_info:
role_name: cisco_nxos_vlans
author: "Your Name"
description: "VLAN and SVI management for Cisco NX-OS switches"
license: MIT
min_ansible_version: "2.15"
platforms:
- name: NX-OS
versions:
- all
dependencies: []
EOF13.11 β The Juniper and PAN-OS Role Pattern
Juniper and Palo Alto follow the identical role structure β the same directories, the same defaults/vars/tasks/handlers/meta layout. The only differences are the module names and connection types.
Juniper junos_interfaces Role Skeleton
ansible-galaxy role init roles/juniper_junos_interfaces# roles/juniper_junos_interfaces/defaults/main.yml
---
junos_interface_state: enabled
junos_commit_confirm: 60 # Commit confirm timeout β auto-rollback if not confirmed
# roles/juniper_junos_interfaces/tasks/main.yml
---
- name: "Junos Interfaces | Pre-flight"
include_tasks: preflight.yml
tags: always
- name: "Junos Interfaces | Configure interfaces"
include_tasks: configure.yml
tags: interfaces# Key difference: Juniper uses junos_config with candidate config + commit
# roles/juniper_junos_interfaces/tasks/configure.yml
- name: "Junos | Configure interface descriptions"
junipernetworks.junos.junos_interfaces:
config:
- name: "{{ item.key }}"
description: "{{ item.value.description }}"
enabled: "{{ not item.value.shutdown | default(false) }}"
state: merged
loop: "{{ interfaces | dict2items }}"
notify: "Commit Junos configuration"
# handlers/main.yml
- name: "Commit Junos configuration"
junipernetworks.junos.junos_command:
commands:
- "commit confirmed {{ junos_commit_confirm }}"
listen: "Commit Junos configuration"The key Juniper difference: commit confirmed <seconds> applies the configuration with an automatic rollback timer. I must run a commit to confirm within the timeout window, or the device automatically reverts. This is a safety feature β covered fully in the Juniper section (Part 24).
Palo Alto panos_security_policy Role Skeleton
ansible-galaxy role init roles/panos_security_policy# Key difference: PAN-OS requires an explicit commit after all changes
# This is the critical handler difference from IOS/NX-OS
# roles/panos_security_policy/handlers/main.yml
- name: "Commit PAN-OS configuration"
paloaltonetworks.panos.panos_commit_firewall:
description: "Committed by Ansible β {{ lookup('pipe', 'date') }}"
listen: "Commit PAN-OS configuration"
# PAN-OS changes are staged in candidate config until committed
# Without this handler, configuration changes are visible but not active# roles/panos_security_policy/tasks/configure.yml
- name: "PAN-OS | Configure security policy rules"
paloaltonetworks.panos.panos_security_rule:
rule_name: "{{ item.name }}"
source_zone: "{{ item.source_zone }}"
destination_zone: "{{ item.destination_zone }}"
action: "{{ item.action }}"
state: present
loop: "{{ panos_security_rules }}"
notify: "Commit PAN-OS configuration" # Triggers commit once, at play endThe critical PAN-OS difference: changes are staged in candidate configuration until a panos_commit_firewall runs. Without the commit handler, every task would change things visibly but they’d never take effect. Using the handler ensures exactly one commit per play, after all rule changes are staged.
13.12 β Downloading Roles from Ansible Galaxy
Ansible Galaxy hosts community-contributed roles. For common tasks like managing ACLs, configuring BGP, or auditing device compliance, a community role may already exist and be better tested than one I’d write myself.
# Search for network-related roles
ansible-galaxy role search cisco ios --author
# View role information before installing
ansible-galaxy role info geerlingguy.ntp
# Install a role from Galaxy
ansible-galaxy role install geerlingguy.ntp
# Install to the project roles/ directory (recommended β keeps it with the project)
ansible-galaxy role install geerlingguy.ntp -p roles/
# Install roles from a requirements file (the correct production approach)
cat > roles/requirements.yml << 'EOF'
---
roles:
- name: geerlingguy.ntp
version: "2.3.0" # Always pin versions in production
- name: some_vendor.network_base
src: https://github.com/vendor/ansible-role-network-base
version: v1.2.0
EOF
ansible-galaxy role install -r roles/requirements.yml -p roles/### βΉοΈ Info
Community roles from Ansible Galaxy vary wildly in quality and maintenance. Before installing any Galaxy role, I check: when was it last updated (stale roles may not support current collection APIs), how many downloads and GitHub stars it has, whether it has CI tests, and whether the author maintains it actively. For critical network configuration, I generally prefer writing and owning the role rather than taking a dependency on a community role I can’t control. Galaxy roles are most useful for well-understood infrastructure tasks (NTP, syslog, package management on Linux hosts) where the risk of a bad role is low.
13.13 β Running the Roles from a Playbook
nano ~/projects/ansible-network/playbooks/deploy/deploy_base_config.yml---
# =============================================================
# deploy_base_config.yml β Role-based base configuration
# Replaces the task-based version from Part 8
# =============================================================
- name: "Deploy | IOS-XE base configuration"
hosts: cisco_ios
gather_facts: false
connection: network_cli
become: true
become_method: enable
force_handlers: true # Save config even if play fails partway through
roles:
- cisco_ios_base
- name: "Deploy | NX-OS VLAN configuration (leaf switches only)"
hosts: leaf_switches
gather_facts: false
connection: network_cli
force_handlers: true
roles:
- cisco_nxos_vlansRunning with Tags
# Run everything
ansible-playbook playbooks/deploy/deploy_base_config.yml
# Run only NTP configuration
ansible-playbook playbooks/deploy/deploy_base_config.yml --tags ntp
# Run only banner configuration on a single device
ansible-playbook playbooks/deploy/deploy_base_config.yml \
--tags banner --limit wan-r1
# Run everything except SNMP
ansible-playbook playbooks/deploy/deploy_base_config.yml \
--skip-tags snmp
# List all tasks that would run (dry run view)
ansible-playbook playbooks/deploy/deploy_base_config.yml \
--list-tasks13.14 β Common Gotchas in This Section
### πͺ² Gotcha β Handler name must match exactly across role files
The string in notify: must be exactly identical to the handler name: in handlers/main.yml β including capitalization and spacing:
# tasks/ntp.yml
notify: "Save IOS configuration" # β exact string
# handlers/main.yml
- name: "Save IOS configuration" # β must match exactly
cisco.ios.ios_command:
commands: [write memory]If there’s a mismatch, the handler silently fails to register β no error, no save. I test by adding a debug task after a change and checking whether the handler ran.
### πͺ² Gotcha β include_tasks with tags doesn’t work the same as import_tasks
# With include_tasks β tags on the include don't propagate to tasks inside
- name: "Include NTP tasks"
ansible.builtin.include_tasks: ntp.yml
tags: ntp # β This tag applies to the INCLUDE task, not to tasks inside ntp.yml
# The tasks inside ntp.yml need their OWN tags for --tags ntp to filter them
# OR use import_tasks instead (static β tags propagate through)
- name: "Import NTP tasks"
ansible.builtin.import_tasks: ntp.yml
tags: ntp # β With import_tasks, this tag DOES propagate to tasks insideFor this guide, I use include_tasks in role main.yml and add tags to the individual task files themselves. This requires tasks in each file to have their own tags, but makes dynamic inclusion possible (the option to conditionally skip sections based on when:).
### πͺ² Gotcha β Role defaults in group_vars with the same name causes silent override
If group_vars/all.yml defines ios_exec_timeout_minutes: 30 and the role’s defaults/main.yml also defines ios_exec_timeout_minutes: 10, the group_vars value wins β silently. This is correct behavior (group_vars has higher precedence than role defaults), but it’s confusing when I can’t figure out why a role default isn’t taking effect.
# Debug: check what value a variable has for a specific host
ansible-inventory --host wan-r1 | grep ios_exec_timeout
# If it shows a value, it's coming from inventory (group_vars/host_vars)
# If it doesn't appear, the role default will be used13.15 β Committing the Roles to Git
cd ~/projects/ansible-network
git add roles/cisco_ios_base/
git add roles/cisco_nxos_vlans/
git add playbooks/deploy/deploy_base_config.yml
git commit -m "feat(roles): implement cisco_ios_base and cisco_nxos_vlans roles
cisco_ios_base role:
- Configures hostname, domain, services, NTP, logging, SNMP, banner
- Split into task files per configuration domain
- Handler: Save IOS configuration (write memory once at play end)
- force_handlers: true on playbook prevents unsaved config on failure
- Preflight assertions verify required variables before pushing config
cisco_nxos_vlans role:
- Manages base VLANs (from group_vars) and local VLANs (from host_vars)
- Configures SVIs for VLANs with svi_ip defined
- Handler: Save NX-OS configuration
Both roles:
- defaults/main.yml for user-overridable parameters
- vars/main.yml for internal constants with _ prefix
- meta/main.yml with platform info and empty dependencies list
- README.md with required vars, defaults table, and usage example"Two production-quality roles are built and running. The pattern is clear β every future platform role follows the same structure. Part 14 takes the automation further into real network configuration use cases: deploying VLANs, interfaces, and routing protocols using resource modules.