Back to Blog
โ˜…โ˜…โ˜†Intermediate๐Ÿค– Network Automation
AnsibleAutomationCiscoIOSNX-OSPython

Ansible for Network Automation: Playbooks, Roles, and IOS/NX-OS Configuration Management

March 13, 2026ยท10 min read

Overview

Ansible removes the manual effort of logging into 40 switches to push the same VLAN change. A single playbook connects to every device via SSH, pushes the config, and reports what changed โ€” idempotently. If a device already has the correct config, Ansible skips it. If it fails, the run stops and reports exactly which host failed and why.


Ansible Network Architecture

// ANSIBLE NETWORK AUTOMATION โ€” SSH PUSH MODEL Ansible Control Node Playbooks ยท Inventory ยท Roles Git Repository Playbooks ยท Templates ยท Vars IOS Switch SSH / network_cli NX-OS Switch SSH / network_cli IOS-XE Router SSH / network_cli git pull SSH (TCP 22) โ€” no agent needed on devices

Part 1 โ€” Project Structure and Inventory

Directory Layout

bash
network-automation/โ”œโ”€โ”€ ansible.cfgโ”œโ”€โ”€ inventory/โ”‚   โ”œโ”€โ”€ hosts.yml          # Device inventoryโ”‚   โ””โ”€โ”€ group_vars/โ”‚       โ”œโ”€โ”€ all.yml        # Common vars (credentials, NTP, etc.)โ”‚       โ”œโ”€โ”€ ios.yml        # IOS-specific varsโ”‚       โ””โ”€โ”€ nxos.yml       # NX-OS-specific varsโ”œโ”€โ”€ playbooks/โ”‚   โ”œโ”€โ”€ deploy-vlans.ymlโ”‚   โ”œโ”€โ”€ configure-ospf.ymlโ”‚   โ””โ”€โ”€ backup-configs.ymlโ”œโ”€โ”€ roles/โ”‚   โ””โ”€โ”€ vlans/โ”‚       โ”œโ”€โ”€ tasks/main.ymlโ”‚       โ”œโ”€โ”€ templates/vlans.j2โ”‚       โ””โ”€โ”€ vars/main.ymlโ””โ”€โ”€ templates/    โ””โ”€โ”€ ios-vlan-config.j2

ansible.cfg

ini
[defaults]inventory = inventory/hosts.ymlhost_key_checking = Falsetimeout = 30gathering = explicitstdout_callback = yaml[persistent_connection]connect_timeout = 30command_timeout = 30

hosts.yml Inventory

yaml
all:  children:    ios:      hosts:        sw-access-01:          ansible_host: 192.0.2.10        sw-access-02:          ansible_host: 192.0.2.11      vars:        ansible_network_os: ios        ansible_connection: network_cli        ansible_become: yes        ansible_become_method: enable    nxos:      hosts:        nexus-core-01:          ansible_host: 192.0.2.20      vars:        ansible_network_os: nxos        ansible_connection: network_cli  vars:    ansible_user: netadmin    ansible_password: "{{ vault_ssh_password }}"    ansible_become_password: "{{ vault_enable_password }}"

group_vars/all.yml

yaml
# Store credentials in ansible-vault โ€” never in plaintextvault_ssh_password: !vault |  $ANSIBLE_VAULT;1.1;AES256  ...ntp_servers:  - 192.0.2.100  - 192.0.2.101syslog_server: 192.0.2.200domain_name: corp.example.com

Part 2 โ€” Core Modules

ios_config vs cli_command

ModuleUse WhenIdempotent?Returns
ios_configPushing config lines โ€” show running-config styleYes โ€” diffs before applyingchanged: true/false + diff
ios_commandRunning show commands, capturing outputNo โ€” always runsstdout list per command
ios_factsGathering device info (OS version, interfaces, etc.)Yesansible_facts dict
nxos_configNX-OS config push (same pattern as ios_config)Yeschanged: true/false + diff
cli_configPlatform-agnostic config pushYeschanged: true/false

network-automation โ€” VS Code Python 3.11 | ansible-core 2.16
Explorer
▼ NETWORK-AUTOMATION
▸ .git
▼ inventory
hosts.yml
▼ group_vars
all.yml
ios.yml
▼ playbooks
deploy-vlans.yml
configure-ospf.yml
backup-configs.yml
▼ roles/vlans
▸ tasks
▸ templates
ansible.cfg
deploy-vlans.yml hosts.yml ios-vlan-config.j2
--- # deploy-vlans.yml
- name: Deploy VLANs to access switches
  hosts: ios
  gather_facts: false
  vars:
    vlans:
      - { id: 10, name: CORP-WORKSTATIONS }
      - { id: 20, name: CORP-VOIP }
      - { id: 110, name: HR-NEW } # ← new VLAN being pushed
  tasks:
    - name: Template VLAN config for each switch
      template: src=ios-vlan-config.j2 dest=/tmp/{{ inventory_hostname }}-vlans.cfg
    - name: Push VLAN config โ€” ios_config checks idempotency
      ios_config:
        src: /tmp/{{ inventory_hostname }}-vlans.cfg
        save_when: modified
    - name: Verify VLAN 110 exists
      ios_command: commands: show vlan id 110
      register: vlan_result
    - assert: that: "'active' in vlan_result.stdout[0]"
$ ansible-playbook playbooks/deploy-vlans.yml --diff
PLAY [Deploy VLANs to access switches] ****************************
TASK [Push VLAN config โ€” ios_config checks idempotency]
ok: [sw-access-01] => no changes needed (VLAN 10,20 already exist)
changed: [sw-access-02] => {"commands": ["vlan 110", " name HR-NEW"]}
changed: [sw-access-03] => {"commands": ["vlan 110", " name HR-NEW"]}
ok: [sw-access-04] => no changes needed
TASK [Verify VLAN 110 exists]
ok: [sw-access-01] ok: [sw-access-02] ok: [sw-access-03] ok: [sw-access-04]
PLAY RECAP ******************************************************
sw-access-01  : ok=3 changed=0 unreachable=0 failed=0 skipped=0
sw-access-02  : ok=3 changed=1 unreachable=0 failed=0 skipped=0
sw-access-03  : ok=3 changed=1 unreachable=0 failed=0 skipped=0
sw-access-04  : ok=3 changed=0 unreachable=0 failed=0 skipped=0

Part 3 โ€” VLAN Deployment Playbook

Jinja2 Template: templates/ios-vlan-config.j2

jinja2
{# ios-vlan-config.j2 โ€” generates VLAN config for IOS switches #}{% for vlan in vlans %}vlan {{ vlan.id }} name {{ vlan.name }}!{% if vlan.svi_ip is defined %}interface Vlan{{ vlan.id }} description {{ vlan.name }} ip address {{ vlan.svi_ip }} {{ vlan.svi_mask }} no shutdown!{% endif %}{% endfor %}

playbooks/deploy-vlans.yml

yaml
---- name: Deploy VLANs to access switches  hosts: ios  gather_facts: no  vars:    vlans:      - id: 10        name: CORP-WORKSTATIONS        svi_ip: 10.10.10.1        svi_mask: 255.255.255.0      - id: 20        name: CORP-VOIP        svi_ip: 10.10.20.1        svi_mask: 255.255.255.0      - id: 50        name: GUEST-WIFI      - id: 99        name: NATIVE-VLAN-UNUSED  tasks:    - name: Generate VLAN config from template      template:        src: ../templates/ios-vlan-config.j2        dest: /tmp/{{ inventory_hostname }}-vlans.cfg      delegate_to: localhost    - name: Push VLAN config to switch      ios_config:        src: /tmp/{{ inventory_hostname }}-vlans.cfg        save_when: modified      register: vlan_result    - name: Verify VLANs exist      ios_command:        commands: show vlan brief      register: vlan_output    - name: Assert all VLANs are active      assert:        that:          - "'10' in vlan_output.stdout[0]"          - "'20' in vlan_output.stdout[0]"        fail_msg: "VLAN deployment verification failed on {{ inventory_hostname }}"

Run and check diff before applying

bash
# Dry run โ€” shows what would change without applyingansible-playbook playbooks/deploy-vlans.yml --check --diff# Apply with verbose outputansible-playbook playbooks/deploy-vlans.yml -v# Limit to a single hostansible-playbook playbooks/deploy-vlans.yml --limit sw-access-01# Run with vault password fileansible-playbook playbooks/deploy-vlans.yml --vault-password-file ~/.vault_pass

Part 4 โ€” Config Backup Playbook

yaml
---- name: Backup running configs  hosts: all  gather_facts: no  tasks:    - name: Collect running config      ios_command:        commands: show running-config      register: running_cfg      when: ansible_network_os == 'ios'    - name: Collect NX-OS running config      nxos_command:        commands: show running-config      register: running_cfg      when: ansible_network_os == 'nxos'    - name: Save config to file      copy:        content: "{{ running_cfg.stdout[0] }}"        dest: "backups/{{ inventory_hostname }}_{{ ansible_date_time.date }}.cfg"      delegate_to: localhost    - name: Keep only last 7 backups per device      shell: |        ls -t backups/{{ inventory_hostname }}_*.cfg | tail -n +8 | xargs rm -f      delegate_to: localhost

Real-World Scenario

The situation: A network team needs to add VLAN 110 (new HR subnet) to 32 access switches across 4 floors. Manual method: 45 minutes of SSH sessions and copy-paste. The engineer also needs to confirm every switch has it active before telling the server team to proceed.

With Ansible:

bash
# Add VLAN 110 to the vars in deploy-vlans.yml, then:ansible-playbook playbooks/deploy-vlans.yml --check --diff# Review: shows exactly which switches are missing VLAN 110ansible-playbook playbooks/deploy-vlans.yml# PLAY RECAP:# sw-access-01  : ok=3  changed=1  unreachable=0  failed=0# sw-access-02  : ok=3  changed=0  unreachable=0  failed=0# (changed=0 means it was already correct โ€” idempotent)# Verify all 32 switches now have VLAN 110ansible ios -m ios_command -a "commands='show vlan id 110'" | grep -A2 "110"

Total time: 4 minutes. The changed=0 output proves idempotency โ€” switches that already had the VLAN weren't touched.


Troubleshooting

Connection timeout โ€” UNREACHABLE for all hosts

Symptom: fatal: [sw-access-01]: UNREACHABLE! => {"msg": "timed out"} on all devices.

Cause: ansible_connection: network_cli requires SSH access. Either SSH not enabled on device, firewall blocking port 22, or wrong credentials.

Fix:

bash
# Test SSH manually firstssh netadmin@192.0.2.10# On Cisco IOS โ€” ensure SSH v2 is enabledcrypto key generate rsa modulus 2048ip ssh version 2line vty 0 15 transport input ssh login local

ios_config shows changed every run (not idempotent)

Symptom: Running the same playbook twice both show changed: true.

Cause: Config lines have trailing whitespace, or the module is matching against running-config formatting that differs slightly (e.g., ip address 10.0.0.1 255.255.255.0 vs ip address 10.0.0.1/24).

Fix:

yaml
# Use match: exact for strict comparison, or match: line for line-by-line- name: Push interface config  ios_config:    lines:      - ip address 10.0.0.1 255.255.255.0    parents: interface GigabitEthernet0/1    match: line    # "line" = only adds missing lines, doesn't replace existing

Vault password prompt breaks CI/CD pipeline

Symptom: Playbook works manually but fails in Jenkins/GitLab with ERROR! Decryption failed.

Cause: --vault-password-file not specified in automated run.

Fix:

bash
# Store vault password in CI/CD secret variable, write to temp file at runtimeecho "$VAULT_PASSWORD" > /tmp/.vault_passansible-playbook deploy-vlans.yml --vault-password-file /tmp/.vault_passrm -f /tmp/.vault_pass

Ansible Network Automation Checklist

  • Credentials in Ansible Vault โ€” never in plaintext inventory or group_vars
  • host_key_checking = False in ansible.cfg for unmanaged lab devices; enable in production
  • Always run --check --diff before applying changes to production devices
  • Use save_when: modified in ios_config โ€” saves running-config to startup only when changes were made
  • Limit playbook runs with --limit during rollout โ€” don't push to all 200 switches at once
  • Version-control all playbooks, templates, and vars in Git โ€” treat network config as code
  • Use roles for reusable config tasks (VLAN, NTP, AAA) โ€” one role, many playbooks
  • Test against a lab switch before running in production โ€” Ansible errors on live switches are real
  • Set serial: 10 in playbook for rolling deployments โ€” limits blast radius if something fails
  • Add a verify task after every config change โ€” assert the expected output appears in show commands