Ansible Playbook — Automate Cisco IOS Configuration
Manually logging into ten switches to configure VLANs takes thirty minutes of repetitive, error-prone work. An Ansible playbook does the same job in under sixty seconds, applies identical configuration to every device simultaneously, and produces a log of exactly what changed — with zero risk of a typo on device seven that you don't catch until the following morning. Ansible is the most widely adopted network automation tool in enterprise environments and the one most likely to appear in both job descriptions and CCNA/DevNet certification requirements. For a broader overview of network automation tools and concepts see Ansible Overview and Network Automation Overview.
Ansible works agentlessly over SSH — there is nothing to install
on the Cisco devices themselves. You write a playbook (a YAML file
describing what you want the network to look like), point it at an
inventory of your devices, and Ansible handles the SSH connections,
command translation, and verification. The
cisco.ios Ansible collection provides purpose-built
modules for IOS: ios_config for sending raw IOS
commands, ios_vlans for declarative VLAN management,
ios_interfaces for interface configuration, and
ios_command for running show commands and capturing
output. For Python-based alternatives see
Python Networking Overview,
Python Netmiko, and
Python NAPALM Multi-Vendor.
For RESTCONF/NETCONF-based automation see
NETCONF & RESTCONF Overview and
Cisco RESTCONF Basics.
This guide builds a complete Ansible project from scratch — from installing Ansible and the Cisco collection, through writing an inventory file, to running a multi-play playbook that configures hostnames, VLANs, and interfaces across an entire lab network. Every file shown here is production-ready and can be used directly on a real network with minor variable changes.
1. Ansible Architecture for Network Automation
How Ansible Communicates with Cisco IOS
Unlike server automation where Ansible pushes a Python agent to the target, network automation works differently — most network operating systems (including IOS) cannot run arbitrary Python. Ansible handles this with network connection plugins that run entirely on the control node (your Linux machine), using SSH to send CLI commands and parse the responses:
┌─────────────────────────────────────────────────────────────────┐
│ CONTROL NODE (Linux/macOS — where you run ansible-playbook) │
│ │
│ ansible-playbook site.yml │
│ │ │
│ ├── Reads inventory/hosts.yml (which devices to target) │
│ ├── Reads group_vars/ (variables per group) │
│ └── Reads playbooks/site.yml (what to configure) │
│ │
│ Connection plugin: network_cli (for IOS) │
│ Runs entirely on control node — NO Python on the IOS device │
└──────────────────┬──────────────────────────────────────────────┘
│ SSH (port 22)
┌──────────┴──────────────────────────────────┐
│ │
NetsTuts_SW1 (192.168.1.10) NetsTuts_SW2 (192.168.1.11)
NetsTuts_R1 (192.168.1.1) NetsTuts_R2 (192.168.1.2)
│ │
Ansible SSHes in, runs Ansible SSHes in, runs
IOS commands, reads output, same commands simultaneously,
parses results, reports changes reports changes
KEY CONCEPTS:
┌─────────────────┬──────────────────────────────────────────────┐
│ Control Node │ The machine running Ansible (your workstation │
│ │ or a jump host). Must have Python + Ansible. │
├─────────────────┼──────────────────────────────────────────────┤
│ Managed Node │ The Cisco device being configured. No agent │
│ │ needed — only SSH + privilege access. │
├─────────────────┼──────────────────────────────────────────────┤
│ Inventory │ List of managed nodes and their groupings. │
│ │ Defines which devices a playbook targets. │
├─────────────────┼──────────────────────────────────────────────┤
│ Playbook │ YAML file describing tasks to run. Tasks map │
│ │ to modules (ios_config, ios_vlans, etc.). │
├─────────────────┼──────────────────────────────────────────────┤
│ Module │ The unit of work — a Python class that knows │
│ │ how to translate YAML into IOS commands. │
├─────────────────┼──────────────────────────────────────────────┤
│ Collection │ A packaged set of modules. cisco.ios is the │
│ │ official Cisco collection for IOS/IOS-XE. │
├─────────────────┼──────────────────────────────────────────────┤
│ Variable │ Reusable values stored in vars files or the │
│ │ inventory. Device-specific (host_vars) or │
│ │ group-wide (group_vars). │
└─────────────────┴──────────────────────────────────────────────┘
Key cisco.ios Modules — Reference
| Module | Purpose | Approach | Best Used For |
|---|---|---|---|
cisco.ios.ios_config |
Send raw IOS configuration commands | Imperative — you specify the exact IOS commands | Complex configurations, one-off commands, any feature not covered by a dedicated module |
cisco.ios.ios_vlans |
Manage VLANs declaratively | Declarative — you describe the desired VLAN state; Ansible figures out the commands | VLAN database management across switches — idempotent by default |
cisco.ios.ios_interfaces |
Configure physical interface properties | Declarative — description, enabled/disabled, speed, duplex, MTU | Enabling/disabling interfaces, setting descriptions and physical parameters |
cisco.ios.ios_l2_interfaces |
Configure Layer 2 interface properties | Declarative — access VLAN, trunk mode, allowed VLANs, native VLAN | VLAN assignment on switchports — access and trunk configuration |
cisco.ios.ios_l3_interfaces |
Configure Layer 3 interface properties | Declarative — IPv4/IPv6 address assignment on routed interfaces | Assigning IP addresses to router interfaces and SVIs |
cisco.ios.ios_command |
Run show commands and capture output | Imperative — runs any IOS command and returns the output as a variable | Gathering facts, verification steps, pre/post-change snapshots |
cisco.ios.ios_facts |
Gather IOS device facts automatically | Read-only — retrieves hostname, interfaces, version, serial number etc. | Pre-change inventory, building dynamic configuration from discovered state |
cisco.ios.ios_banner |
Configure MOTD and login banners | Declarative — sets banner text without the IOS delimiter complexity | Standardising login banners across all devices from one playbook |
ios_vlans are naturally
idempotent. The ios_config module achieves
idempotency through the lines + parents
approach — Ansible checks if the exact lines already exist in the
running-config before pushing them. Building idempotent playbooks
is the key discipline in production network automation: you should
be able to run the same playbook every night as a compliance check
without it causing disruption. See
show running-config
for how to verify applied configuration, and
Saving &
Managing Cisco Configurations for config persistence best practices.
2. Lab Setup — Installing Ansible and the Cisco Collection
Control Node Requirements
Control Node (Linux/macOS/WSL2 on Windows):
Python 3.9 or later
pip (Python package manager)
SSH client (standard on Linux/macOS)
Network access to all managed Cisco devices on port 22
Cisco Managed Nodes (IOS/IOS-XE):
SSH enabled (ip ssh version 2)
Local user with privilege 15 (or AAA with enable fallback)
VTY lines configured to accept SSH
No additional software required
Lab Topology for This Guide:
Control Node: 192.168.1.100 (Ubuntu 22.04 LTS)
NetsTuts_SW1: 192.168.1.10 (Cisco Catalyst — IOS 15.x)
NetsTuts_SW2: 192.168.1.11 (Cisco Catalyst — IOS 15.x)
NetsTuts_R1: 192.168.1.1 (Cisco ISR — IOS-XE 16.x)
NetsTuts_R2: 192.168.1.2 (Cisco ISR — IOS-XE 16.x)
Step 1 — Prepare the Cisco Devices for SSH
Before Ansible can connect, each Cisco device needs SSH enabled with a local user. Run these commands manually once — Ansible will handle everything after this. For a full SSH setup guide see SSH Configuration and Console & VTY Line Configuration:
! ── On every Cisco device — one-time SSH preparation ───────────── NetsTuts_SW1(config)#hostname NetsTuts-SW1 NetsTuts-SW1(config)#ip domain-name netstuts.local NetsTuts-SW1(config)#crypto key generate rsa modulus 2048 ! ── Generates RSA keys — required for SSH v2 ───────────────────── NetsTuts-SW1(config)#ip ssh version 2 NetsTuts-SW1(config)#ip ssh time-out 60 NetsTuts-SW1(config)#ip ssh authentication-retries 3 ! ── Create the Ansible management user ─────────────────────────── NetsTuts-SW1(config)#username ansible privilege 15 secret AnsibleP@ss2026 ! ^^^^^^^^^^^^ priv 15 = no enable needed ! ── Configure VTY lines to accept SSH ──────────────────────────── NetsTuts-SW1(config)#line vty 0 15 NetsTuts-SW1(config-line)#transport input ssh NetsTuts-SW1(config-line)#login local NetsTuts-SW1(config-line)#exec-timeout 5 0 NetsTuts-SW1(config-line)#exit ! ── Verify SSH is working ──────────────────────────────────────── NetsTuts-SW1#show ip ssh SSH Enabled - version 2.0 Authentication timeout: 60 secs; Authentication retries: 3
privilege 15 on the local user gives Ansible
immediate access to global configuration mode without needing
a separate enable password. This is the recommended approach
for automation accounts — Ansible's IOS modules use
network_cli connection type which needs to enter
config mode, and a priv-15 user eliminates the need for
ansible_become and an enable secret in the vars.
If your security policy requires privilege 1 users, configure
ansible_become: yes and
ansible_become_password in your group_vars instead.
For hostname and banner configuration see
Hostname,
Banner & Password Configuration. For login security
hardening see
Login
Security & Brute-Force Protection.
Step 2 — Install Ansible on the Control Node
# Update package index $ sudo apt update && sudo apt install -y python3-pip python3-venv # Create a dedicated virtual environment (recommended — keeps Ansible isolated) $ python3 -m venv ~/ansible-venv $ source ~/ansible-venv/bin/activate (ansible-venv) $ # Install Ansible (includes ansible-core and CLI tools) (ansible-venv) $ pip install ansible # Verify installation (ansible-venv) $ ansible --version ansible [core 2.17.x] python version = 3.11.x jinja version = 3.1.x # Install the Cisco IOS collection from Ansible Galaxy (ansible-venv) $ ansible-galaxy collection install cisco.ios # Install the required Python library for network_cli connections (ansible-venv) $ pip install paramiko # Verify the collection is installed (ansible-venv) $ ansible-galaxy collection list | grep cisco cisco.ios 5.x.x cisco.nxos 8.x.x ← also installed as a dependency
cisco.ios collection is the
official Cisco-maintained collection hosted on Ansible Galaxy
— it is updated frequently and supports IOS, IOS-XE, and
IOS-XR devices through separate module namespaces.
paramiko is the Python SSH library that the
network_cli connection plugin uses to establish
SSH sessions to the Cisco devices.
3. Ansible Project Structure
A well-structured Ansible project uses a standard directory layout that separates inventory, variables, and playbooks. This layout scales from a two-device lab to a thousand-device production network without restructuring:
netstuts-ansible/ ← Project root
├── ansible.cfg ← Project-wide Ansible settings
├── inventory/
│ └── hosts.yml ← Device inventory (groups + IPs)
├── group_vars/
│ ├── all.yml ← Variables for ALL devices
│ ├── switches.yml ← Variables for the 'switches' group
│ └── routers.yml ← Variables for the 'routers' group
├── host_vars/
│ ├── NetsTuts-SW1.yml ← Variables unique to SW1
│ ├── NetsTuts-SW2.yml ← Variables unique to SW2
│ ├── NetsTuts-R1.yml ← Variables unique to R1
│ └── NetsTuts-R2.yml ← Variables unique to R2
└── playbooks/
├── site.yml ← Master playbook (calls others)
├── configure_hostnames.yml
├── configure_vlans.yml
├── configure_interfaces.yml
└── gather_facts.yml
host_vars/NetsTuts-SW1.yml overrides the same
setting in group_vars/switches.yml — device-specific
variables always win. This precedence makes it easy to set
defaults for the whole network in group_vars and override them
for individual devices in host_vars.
Step 1 — Create the Project and ansible.cfg
# Create the project directory structure
(ansible-venv) $ mkdir -p netstuts-ansible/{inventory,group_vars,host_vars,playbooks}
(ansible-venv) $ cd netstuts-ansible
Create ansible.cfg in the project root:
# ansible.cfg [defaults] inventory = inventory/hosts.yml collections_path = ~/.ansible/collections host_key_checking = False # Disable SSH host key check for lab timeout = 30 gathering = explicit # Don't auto-gather facts (slow on network devices) stdout_callback = yaml # Cleaner output format [persistent_connection] connect_timeout = 30 command_timeout = 30
host_key_checking = False is acceptable in a
lab environment where you control all devices. In production,
set this to True and populate the control node's
~/.ssh/known_hosts with the device SSH keys.
gathering = explicit prevents Ansible from
automatically running the facts module on every play — on
network devices, auto-gathering adds 5–10 seconds per device.
We gather facts manually only when needed.
Step 2 — Create the Inventory File
Create inventory/hosts.yml:
--- # inventory/hosts.yml # Defines all managed devices, their groups, and connection parameters all: children: switches: # Group: all Cisco switches hosts: NetsTuts-SW1: ansible_host: 192.168.1.10 NetsTuts-SW2: ansible_host: 192.168.1.11 routers: # Group: all Cisco routers hosts: NetsTuts-R1: ansible_host: 192.168.1.1 NetsTuts-R2: ansible_host: 192.168.1.2 core: # Sub-group: core devices only hosts: NetsTuts-SW1: NetsTuts-R1:
NetsTuts-SW1) is
the name Ansible uses internally — it does not need to match
the device's actual IOS hostname. The ansible_host
variable provides the actual IP address Ansible SSHes to.
Groups can contain other groups (nested groups) — the
core group here references two hosts that also
belong to switches and routers
respectively. Playbooks can target any group, any individual
host, or combinations like switches:!NetsTuts-SW2
(all switches except SW2).
Step 3 — Create Group Variables
Create group_vars/all.yml (applies to every device):
--- # group_vars/all.yml # Connection settings that apply to ALL managed devices ansible_user: ansible ansible_password: "AnsibleP@ss2026" ansible_network_os: cisco.ios.ios ansible_connection: ansible.netcommon.network_cli ansible_become: false # priv 15 user — no enable needed # NTP servers applied to all devices ntp_servers: - 192.168.1.100 - pool.ntp.org # Standard MOTD banner text for all devices motd_banner: | ****************************************** * NetsTuts Campus Network * * Unauthorised access is prohibited. * * All sessions are monitored. * ******************************************
Create group_vars/switches.yml (applies to all switches).
For VLAN concepts see VLANs and
VLAN Creation & Management.
For spanning tree background see STP Overview
and RSTP Configuration:
--- # group_vars/switches.yml # Variables specific to all switches in the 'switches' group # VLANs that should exist on ALL switches common_vlans: - vlan_id: 10 name: Management - vlan_id: 20 name: Voice - vlan_id: 30 name: Engineering - vlan_id: 40 name: IoT-Lab - vlan_id: 99 name: Native-Trunk # Default spanning-tree mode for all switches spanning_tree_mode: rapid-pvst
Create group_vars/routers.yml (applies to all routers).
For OSPF background see OSPF Overview
and OSPF Single-Area Configuration:
--- # group_vars/routers.yml # Variables specific to all routers in the 'routers' group # OSPF settings for all routers ospf_process_id: 1 ospf_area: 0 # IP routing enabled on all routers (should always be on) ip_routing: true
Step 4 — Create Host Variables
Create host_vars/NetsTuts-SW1.yml:
--- # host_vars/NetsTuts-SW1.yml # Variables unique to NetsTuts-SW1 device_hostname: NetsTuts-SW1 # Management interface (SVI for VLAN 10) mgmt_vlan: 10 mgmt_ip: 192.168.1.10 mgmt_prefix: 24 mgmt_gateway: 192.168.1.1 # SW1-specific VLANs (in addition to common_vlans from group_vars) extra_vlans: - vlan_id: 100 name: Servers - vlan_id: 110 name: DMZ # Interface configurations for SW1 interfaces: - name: GigabitEthernet0/1 description: "Uplink to NetsTuts-R1 Gi0/1" mode: trunk native_vlan: 99 allowed_vlans: "10,20,30,40,99,100,110" - name: FastEthernet0/1 description: "PC_MGMT — VLAN 10" mode: access access_vlan: 10 portfast: true - name: FastEthernet0/2 description: "IP Phone — VLAN 20" mode: access access_vlan: 30 voice_vlan: 20 portfast: true - name: FastEthernet0/3 description: "Server — VLAN 100" mode: access access_vlan: 100 portfast: false
Create host_vars/NetsTuts-SW2.yml:
--- # host_vars/NetsTuts-SW2.yml device_hostname: NetsTuts-SW2 mgmt_vlan: 10 mgmt_ip: 192.168.1.11 mgmt_prefix: 24 mgmt_gateway: 192.168.1.1 extra_vlans: [] # No extra VLANs on SW2 beyond common_vlans interfaces: - name: GigabitEthernet0/1 description: "Uplink to NetsTuts-SW3" mode: trunk native_vlan: 99 allowed_vlans: "10,20,30,40,99" - name: FastEthernet0/1 description: "ENG1 — VLAN 30" mode: access access_vlan: 30 portfast: true - name: FastEthernet0/2 description: "ENG2 — VLAN 30" mode: access access_vlan: 30 portfast: true - name: FastEthernet0/3 description: "ENG3 — VLAN 40" mode: access access_vlan: 40 portfast: true
4. Playbook 1 — Configure Hostnames and Banners
Create playbooks/configure_hostnames.yml. This
playbook uses ios_config to set device hostnames
and ios_banner to push the standard MOTD banner
across all devices simultaneously:
--- # playbooks/configure_hostnames.yml # Sets device hostname and MOTD banner on all managed devices - name: Configure Hostnames and Banners hosts: all # Target every device in inventory gather_facts: false # Explicit — don't auto-gather facts tasks: - name: Set device hostname cisco.ios.ios_config: lines: - "hostname {{ device_hostname }}" register: hostname_result # Capture the task result - name: Show hostname change status debug: msg: "Hostname set to {{ device_hostname }} — changed: {{ hostname_result.changed }}" - name: Configure MOTD banner cisco.ios.ios_banner: banner: motd text: "{{ motd_banner }}" state: present - name: Configure NTP servers cisco.ios.ios_config: lines: - "ntp server {{ item }}" loop: "{{ ntp_servers }}" # Loop over each NTP server in the list - name: Disable HTTP server (security hardening) cisco.ios.ios_config: lines: - no ip http server - no ip http secure-server - name: Save configuration to NVRAM cisco.ios.ios_config: save_when: modified # Only write memory if something changed
register keyword captures the task result into
a variable (hostname_result). Every Ansible task
returns a result object with a changed boolean —
true if Ansible made a change, false
if the device was already in the desired state. The
loop keyword iterates the task once per item in
the list — in this case, once per NTP server. For NTP
configuration details see
NTP Configuration.
The save_when: modified option on the final
ios_config task is critically important —
it runs write memory only if any earlier task
made a change, preventing unnecessary flash writes. See
Saving &
Managing Cisco Configurations for more.
Run the Hostname Playbook
# Dry run first — --check mode shows what would change without making changes (ansible-venv) $ ansible-playbook playbooks/configure_hostnames.yml --check PLAY [Configure Hostnames and Banners] ************************************ TASK [Set device hostname] ************************************************ changed: [NetsTuts-SW1] changed: [NetsTuts-SW2] changed: [NetsTuts-R1] changed: [NetsTuts-R2] TASK [Configure MOTD banner] ********************************************** changed: [NetsTuts-SW1] changed: [NetsTuts-SW2] ... PLAY RECAP **************************************************************** NetsTuts-SW1 : ok=3 changed=2 unreachable=0 failed=0 NetsTuts-SW2 : ok=3 changed=2 unreachable=0 failed=0 NetsTuts-R1 : ok=3 changed=2 unreachable=0 failed=0 NetsTuts-R2 : ok=3 changed=2 unreachable=0 failed=0 # Run for real — remove --check to apply the changes (ansible-venv) $ ansible-playbook playbooks/configure_hostnames.yml # Run again — should show ok=N changed=0 (idempotent) (ansible-venv) $ ansible-playbook playbooks/configure_hostnames.yml PLAY RECAP **************************************************************** NetsTuts-SW1 : ok=3 changed=0 unreachable=0 failed=0 ← no changes needed NetsTuts-SW2 : ok=3 changed=0 unreachable=0 failed=0
--check first in production — this
is Ansible's dry-run mode where it connects to the devices and
evaluates what would change without actually making any changes.
The PLAY RECAP at the end summarises results per device:
ok = tasks evaluated, changed
= tasks that made a change, unreachable = SSH
failures, failed = task errors. A second run
showing changed=0 confirms the playbook is
idempotent — the desired state was applied and maintained.
5. Playbook 2 — Configure VLANs Across All Switches
Create playbooks/configure_vlans.yml. This playbook
uses the declarative ios_vlans module to ensure all
required VLANs exist on every switch. The
common_vlans from group_vars are merged with each
switch's extra_vlans from host_vars. For VLAN
fundamentals see VLANs,
VLAN Tagging (802.1Q),
and VLAN Creation &
Management. For trunk configuration see
Trunk Port Configuration
and show vlan:
--- # playbooks/configure_vlans.yml # Creates and names VLANs on all switches — idempotent - name: Configure VLANs on Switches hosts: switches gather_facts: false tasks: - name: Merge common and device-specific VLAN lists set_fact: all_vlans: "{{ common_vlans + (extra_vlans | default([])) }}" # Combines group_vars VLANs with any host_vars extra_vlans # Result: [vlan10, vlan20, vlan30, vlan40, vlan99, ...extras] - name: Display VLANs to be configured on this device debug: msg: "{{ inventory_hostname }}: configuring {{ all_vlans | map(attribute='vlan_id') | list }}" - name: Configure VLANs using ios_vlans module cisco.ios.ios_vlans: config: - vlan_id: "{{ item.vlan_id }}" name: "{{ item.name }}" state: active state: merged # merged = add/update only; never remove existing VLANs loop: "{{ all_vlans }}" loop_control: label: "VLAN {{ item.vlan_id }} — {{ item.name }}" # Clean loop output - name: Verify VLANs exist on device cisco.ios.ios_command: commands: - show vlan brief register: vlan_output - name: Display VLAN verification output debug: var: vlan_output.stdout_lines - name: Save configuration cisco.ios.ios_config: save_when: modified
ios_vlans module with state: merged
is additive — it creates VLANs that don't exist and updates
names of VLANs that do, but never deletes VLANs that exist on
the device but aren't in the playbook. Using
state: replaced instead would remove any VLAN
not in the list — powerful but dangerous in production.
The loop_control.label replaces the full loop
item dump in the output with a human-readable label, making
the playbook output much cleaner when iterating long lists.
Understanding ios_vlans State Options
| State | Behaviour | When to Use | Risk in Production |
|---|---|---|---|
merged |
Adds/updates specified VLANs. Never removes VLANs not in the list | Adding new VLANs to existing switches — safe default | Low — additive only |
replaced |
Replaces the entire VLAN configuration with exactly what is in the playbook. VLANs not in the list are removed | Greenfield deployments, compliance enforcement where unmanaged VLANs are not permitted | High — can remove VLANs in use |
overridden |
Like replaced but applies globally across all VLANs — more aggressive | Full VLAN standardisation across all switches simultaneously | Very high — only use in complete greenfield or with full change control |
deleted |
Removes specified VLANs from the device | Decommissioning specific VLANs | Medium — only removes what you specify |
gathered |
Read-only — retrieves current VLAN configuration as structured data | Auditing, pre-change snapshots, building config from discovered state | None — read-only |
Run the VLAN Playbook
# Target only switches (routers don't have VLANs in this playbook)
(ansible-venv) $ ansible-playbook playbooks/configure_vlans.yml
PLAY [Configure VLANs on Switches] ****************************************
TASK [Merge common and device-specific VLAN lists] ************************
ok: [NetsTuts-SW1]
ok: [NetsTuts-SW2]
TASK [Configure VLANs using ios_vlans module] *****************************
changed: [NetsTuts-SW1] => (item=VLAN 10 — Management)
changed: [NetsTuts-SW1] => (item=VLAN 20 — Voice)
changed: [NetsTuts-SW1] => (item=VLAN 30 — Engineering)
changed: [NetsTuts-SW1] => (item=VLAN 40 — IoT-Lab)
changed: [NetsTuts-SW1] => (item=VLAN 99 — Native-Trunk)
changed: [NetsTuts-SW1] => (item=VLAN 100 — Servers)
changed: [NetsTuts-SW1] => (item=VLAN 110 — DMZ)
changed: [NetsTuts-SW2] => (item=VLAN 10 — Management)
...
TASK [Verify VLANs exist on device] ***************************************
ok: [NetsTuts-SW1]
ok: [NetsTuts-SW2]
TASK [Display VLAN verification output] ***********************************
ok: [NetsTuts-SW1] =>
vlan_output.stdout_lines:
- - 'VLAN Name Status Ports'
- '---- ----------------- --------- ------'
- '1 default active ...'
- '10 Management active'
- '20 Voice active'
- '30 Engineering active Fa0/1, Fa0/2'
- '40 IoT-Lab active Fa0/3'
- '99 Native-Trunk active'
- '100 Servers active Fa0/3'
- '110 DMZ active'
PLAY RECAP ****************************************************************
NetsTuts-SW1 : ok=5 changed=2 unreachable=0 failed=0
NetsTuts-SW2 : ok=5 changed=2 unreachable=0 failed=0
6. Playbook 3 — Configure Interfaces
Create playbooks/configure_interfaces.yml. This
is the most complex playbook — it uses multiple modules together
to configure interface descriptions, access/trunk VLAN
assignments, and PortFast on each interface defined in host_vars.
For interface configuration background see
Basic Interface Configuration,
Trunk Port Configuration,
and Assigning VLANs to Switch Ports.
For PortFast and BPDU Guard see
PortFast & BPDU Guard.
For voice VLAN see Voice VLAN Configuration:
--- # playbooks/configure_interfaces.yml # Configures interface descriptions, VLAN assignments, and PortFast # Uses host_vars/[hostname].yml 'interfaces' list for device-specific config - name: Configure Switch Interfaces hosts: switches gather_facts: false tasks: - name: Configure interface descriptions cisco.ios.ios_interfaces: config: - name: "{{ item.name }}" description: "{{ item.description }}" enabled: true # Ensure interface is not shutdown state: merged loop: "{{ interfaces }}" loop_control: label: "{{ item.name }}: {{ item.description }}" - name: Configure access port VLAN assignments cisco.ios.ios_l2_interfaces: config: - name: "{{ item.name }}" mode: access access: vlan: "{{ item.access_vlan }}" state: merged loop: "{{ interfaces | selectattr('mode', 'equalto', 'access') | list }}" loop_control: label: "{{ item.name }} → access VLAN {{ item.access_vlan }}" # selectattr filter: only loop over interfaces where mode == 'access' - name: Configure voice VLAN on IP phone ports cisco.ios.ios_config: parents: "interface {{ item.name }}" lines: - "switchport voice vlan {{ item.voice_vlan }}" loop: "{{ interfaces | selectattr('voice_vlan', 'defined') | list }}" loop_control: label: "{{ item.name }}: voice VLAN {{ item.voice_vlan }}" # ios_l2_interfaces doesn't support voice VLAN — use ios_config instead - name: Configure trunk port VLAN settings cisco.ios.ios_l2_interfaces: config: - name: "{{ item.name }}" mode: trunk trunk: native_vlan: "{{ item.native_vlan }}" allowed_vlans: "{{ item.allowed_vlans }}" state: merged loop: "{{ interfaces | selectattr('mode', 'equalto', 'trunk') | list }}" loop_control: label: "{{ item.name }}: trunk (native {{ item.native_vlan }})" - name: Enable PortFast on access ports cisco.ios.ios_config: parents: "interface {{ item.name }}" lines: - spanning-tree portfast - spanning-tree bpduguard enable loop: "{{ interfaces | selectattr('portfast', 'equalto', true) | list }}" loop_control: label: "{{ item.name }}: PortFast + BPDU Guard enabled" - name: Save configuration cisco.ios.ios_config: save_when: modified
selectattr Jinja2 filter is essential for
targeted looping — selectattr('mode', 'equalto', 'access')
returns only the interfaces where the mode attribute
equals 'access'. This means the access VLAN task
only runs for access ports, the trunk task only runs for trunk
ports, and voice VLAN is only configured on ports that have
voice_vlan defined in their host_vars. The
parents parameter in ios_config
sets the configuration context — parents: "interface
GigabitEthernet0/1" is equivalent to navigating into
that interface's config mode before running the
lines commands. For Jinja2 templating for
configuration generation see
Jinja2 Config Generation.
Router Interface Configuration
Create playbooks/configure_router_interfaces.yml
to configure IP addresses on router interfaces using the
declarative ios_l3_interfaces module. For OSPF
configuration see OSPF Overview,
OSPF Single-Area Configuration,
and OSPF Multi-Area Configuration:
---
# playbooks/configure_router_interfaces.yml
- name: Configure Router Interfaces
hosts: routers
gather_facts: false
tasks:
- name: Configure WAN interface IP addresses
cisco.ios.ios_l3_interfaces:
config:
- name: GigabitEthernet0/0
ipv4:
- address: "{{ wan_ip }}/{{ wan_prefix }}"
state: merged
- name: Configure Loopback0 (OSPF Router ID)
cisco.ios.ios_l3_interfaces:
config:
- name: Loopback0
ipv4:
- address: "{{ loopback_ip }}/32"
state: merged
- name: Enable all configured interfaces
cisco.ios.ios_interfaces:
config:
- name: GigabitEthernet0/0
enabled: true
- name: Loopback0
enabled: true
state: merged
- name: Configure OSPF process
cisco.ios.ios_config:
lines:
- "router ospf {{ ospf_process_id }}"
- " network {{ loopback_ip }} 0.0.0.0 area {{ ospf_area }}"
- " network {{ wan_network }} {{ wan_wildcard }} area {{ ospf_area }}"
- " passive-interface Loopback0"
- name: Save configuration
cisco.ios.ios_config:
save_when: modified
7. The Master Playbook — Orchestrate Everything
Create playbooks/site.yml — the master playbook
that calls all other playbooks in the correct order. Running
a single command now configures the entire network:
---
# playbooks/site.yml
# Master playbook — runs all configuration playbooks in order
# Usage: ansible-playbook playbooks/site.yml
- name: "Phase 1 — Base Configuration (all devices)"
import_playbook: configure_hostnames.yml
- name: "Phase 2 — VLAN Configuration (switches only)"
import_playbook: configure_vlans.yml
- name: "Phase 3 — Interface Configuration (switches)"
import_playbook: configure_interfaces.yml
- name: "Phase 4 — Router Interface and OSPF Configuration"
import_playbook: configure_router_interfaces.yml
Run the Full Site Deployment
# Full dry run of the entire network configuration (ansible-venv) $ ansible-playbook playbooks/site.yml --check # Apply to the entire network — all devices simultaneously (ansible-venv) $ ansible-playbook playbooks/site.yml # Target only switches (skip router plays) (ansible-venv) $ ansible-playbook playbooks/site.yml --limit switches # Target a single device (ansible-venv) $ ansible-playbook playbooks/site.yml --limit NetsTuts-SW1 # Show verbose output (useful for debugging connection issues) (ansible-venv) $ ansible-playbook playbooks/site.yml -vvv # Run only tasks tagged with 'vlans' (see next section for tags) (ansible-venv) $ ansible-playbook playbooks/site.yml --tags vlans # List all tasks without running them (ansible-venv) $ ansible-playbook playbooks/site.yml --list-tasks
--limit flag is one of the most useful Ansible
options in production — it restricts which inventory hosts a
playbook runs against, without changing the playbook itself.
This lets you test a change on one device first, verify it,
then run again without --limit to apply to all.
The -vvv flag enables three levels of verbosity
— it shows the exact SSH commands being sent and responses
received, essential for diagnosing connection failures or
unexpected command output.
8. Gather Facts and Verify — Post-Change Validation
Create playbooks/gather_facts.yml to run
show commands across all devices and save the output as
structured reports. This is the automated post-change
verification step. For the equivalent manual show commands see
show ip interface brief,
show vlan,
show running-config,
and show ip route:
--- # playbooks/gather_facts.yml # Runs verification show commands and saves output as reports - name: Gather and Verify Network State hosts: all gather_facts: false tasks: - name: Gather IOS facts (hostname, version, interfaces) cisco.ios.ios_facts: gather_subset: - hardware - interfaces - config register: device_facts - name: Display device summary debug: msg: - "Hostname: {{ ansible_net_hostname }}" - "IOS Ver: {{ ansible_net_version }}" - "Serial: {{ ansible_net_serialnum }}" - "Uptime: {{ ansible_net_stacked_models | default('N/A') }}" - name: Run verification show commands cisco.ios.ios_command: commands: - show ip interface brief - show version | include uptime - show running-config | include hostname register: verify_output - name: Run switch-specific verification cisco.ios.ios_command: commands: - show vlan brief - show interfaces trunk - show spanning-tree summary register: switch_verify when: inventory_hostname in groups['switches'] # Only on switches - name: Run router-specific verification cisco.ios.ios_command: commands: - show ip route - show ip ospf neighbor - show ip interface brief register: router_verify when: inventory_hostname in groups['routers'] # Only on routers - name: Save verification output to file copy: content: | ====== {{ inventory_hostname }} Verification Report ====== Generated: {{ ansible_date_time.iso8601 | default('N/A') }} --- show ip interface brief --- {{ verify_output.stdout[0] }} {% if inventory_hostname in groups['switches'] %} --- show vlan brief --- {{ switch_verify.stdout[0] }} --- show interfaces trunk --- {{ switch_verify.stdout[1] }} {% endif %} {% if inventory_hostname in groups['routers'] %} --- show ip route --- {{ router_verify.stdout[0] }} --- show ip ospf neighbor --- {{ router_verify.stdout[1] }} {% endif %} dest: "/tmp/ansible-verify-{{ inventory_hostname }}.txt" delegate_to: localhost # Write file on control node - name: Check for any interfaces that are down cisco.ios.ios_command: commands: - show ip interface brief register: intf_check - name: Alert on any down interfaces debug: msg: "WARNING — down interface detected on {{ inventory_hostname }}" when: "'down' in intf_check.stdout[0] | lower"
when conditional restricts a task to specific
hosts — when: inventory_hostname in groups['switches']
runs the task only on hosts in the switches group. The
delegate_to: localhost directive causes a task to
execute on the control node rather than the managed device —
used here to write the verification report to the local
filesystem. Jinja2 {% if %} blocks inside the
file content generate device-appropriate report sections.
Troubleshooting Common Ansible–IOS Connection Errors
| Error Message | Cause | Fix |
|---|---|---|
UNREACHABLE! — Connection timed out |
SSH not reachable — firewall, wrong IP, or SSH disabled on device | Verify ansible_host IP, confirm SSH is enabled (ip ssh version 2), check VTY line config |
Invalid credentials |
Wrong username or password in group_vars/all.yml | Verify ansible_user and ansible_password match the IOS local user. Test with manual SSH first |
unable to open shell |
ansible_network_os or ansible_connection is missing or wrong |
Ensure ansible_network_os: cisco.ios.ios and ansible_connection: ansible.netcommon.network_cli are set in group_vars/all.yml |
prompt detection failed |
IOS prompt not matching expected pattern — often caused by banner text containing # characters | Remove # from MOTD banner text, or add terminal_prompt_checksum workaround |
no module named 'paramiko' |
paramiko not installed in the Ansible virtual environment | pip install paramiko in the active venv |
command timeout triggered |
A command took longer than command_timeout to return |
Increase command_timeout in ansible.cfg — write memory can be slow on large configs |
Task shows changed every run (not idempotent) |
Using ios_config with lines that IOS reformats — Ansible can't match them |
Use the dedicated declarative module (ios_vlans, ios_l2_interfaces) which handles idempotency correctly, or use check_running_config option |
9. Production Best Practices and Ansible Vault
Protecting Secrets with Ansible Vault
Never store passwords in plain text in group_vars files that are committed to version control. Ansible Vault encrypts sensitive variables so they can be safely stored in Git:
# Create an encrypted vars file for secrets (ansible-venv) $ ansible-vault create group_vars/all/vault.yml New Vault password: (enter a strong vault password) Confirm New Vault password: # This opens a text editor — enter your secrets: vault_ansible_password: "AnsibleP@ss2026" vault_enable_secret: "EnableS3cret!" # In group_vars/all.yml, reference vault variables instead of plain text: ansible_password: "{{ vault_ansible_password }}" # Run playbooks with vault password (ansible-venv) $ ansible-playbook playbooks/site.yml --ask-vault-pass # Or store vault password in a file (permissions 600, gitignored) (ansible-venv) $ echo "MyVaultPassword" > ~/.vault_pass (ansible-venv) $ chmod 600 ~/.vault_pass # In ansible.cfg: vault_password_file = ~/.vault_pass # Encrypt just a single string value (useful for inline secrets) (ansible-venv) $ ansible-vault encrypt_string "AnsibleP@ss2026" --name ansible_password ansible_password: !vault | $ANSIBLE_VAULT;1.1;AES256 62333532353961313033396133626636...
Using Tags for Selective Execution
--- # Adding tags to tasks enables selective execution - name: Configure VLANs using ios_vlans module cisco.ios.ios_vlans: config: ... state: merged loop: "{{ all_vlans }}" tags: - vlans - layer2 - name: Configure trunk port VLAN settings cisco.ios.ios_l2_interfaces: ... tags: - trunks - layer2 # Run only VLAN-related tasks across all devices $ ansible-playbook playbooks/site.yml --tags vlans # Run everything EXCEPT the save task (useful in check mode) $ ansible-playbook playbooks/site.yml --skip-tags save
Production Playbook Best Practices
| Practice | Implementation | Why It Matters |
|---|---|---|
Always --check first |
Run ansible-playbook playbook.yml --check before any production change |
Shows exactly what will change without making changes — prevents surprises |
Use --diff with check |
ansible-playbook playbook.yml --check --diff |
Shows exact config lines that would be added or removed — the network equivalent of a git diff |
| Use Ansible Vault for all passwords | Encrypt all credentials — never commit plain-text passwords to Git | Security compliance — credentials in version control is a critical vulnerability |
Use save_when: modified |
Add to every play's final ios_config task |
Saves only when changes were made — prevents unnecessary NVRAM writes and flash wear |
Use --limit for staged rollouts |
Test on one device → verify → run on all | Reduces blast radius of a misconfiguration — a problem on SW1 doesn't affect SW2–SW10 |
| Tag all tasks | Add meaningful tags (vlans, layer2, ospf) to every task |
Enables surgical re-runs of specific configuration sections without running the whole playbook |
| Store playbooks in Git | Use git commit with meaningful messages for every playbook change | Full audit trail of configuration changes — who changed what, when, and why |
| Gather pre/post snapshots | Run gather_facts.yml before and after every change window |
Enables diff comparison to verify the change had exactly the intended effect |