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
Part 1 โ Project Structure and Inventory
Directory Layout
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.j2ansible.cfg
[defaults]inventory = inventory/hosts.ymlhost_key_checking = Falsetimeout = 30gathering = explicitstdout_callback = yaml[persistent_connection]connect_timeout = 30command_timeout = 30hosts.yml Inventory
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
# 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.comPart 2 โ Core Modules
ios_config vs cli_command
Part 3 โ VLAN Deployment Playbook
Jinja2 Template: templates/ios-vlan-config.j2
{# 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
---- 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
# 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_passPart 4 โ Config Backup Playbook
---- 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: localhostReal-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:
# 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:
# 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 localios_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:
# 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 existingVault 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:
# 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_passAnsible Network Automation Checklist
- Credentials in Ansible Vault โ never in plaintext inventory or group_vars
host_key_checking = Falsein ansible.cfg for unmanaged lab devices; enable in production- Always run
--check --diffbefore applying changes to production devices - Use
save_when: modifiedin ios_config โ saves running-config to startup only when changes were made - Limit playbook runs with
--limitduring 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: 10in playbook for rolling deployments โ limits blast radius if something fails - Add a
verifytask after every config change โ assert the expected output appears in show commands