Skip to content

Structuring an Ansible project for network automation

The Ansible article on this site covers the layer that changes a device: resource modules and the state key that reconciles your declared config against the box. This article covers the layer beneath it — how Ansible connects to a device and how you organise inventory, variables, and secrets so one playbook can drive dev, staging, and production unchanged. Get this layout right and the playbooks stay small; get it wrong and every play grows a tangle of hard-coded hosts and credentials.

Two variables set the connection

Nothing is installed on a router or switch — Ansible is agentless, so all module logic runs on the control node and reaches the device over a connection plugin. For network work, two inventory variables do the heavy lifting, and they answer different questions:

  • ansible_connection — how Ansible talks to the device. The usual choices are ansible.netcommon.network_cli (SSH, screen-scraping the CLI, the default for IOS XE), ansible.netcommon.netconf (NETCONF/XML for YANG-capable devices), and ansible.netcommon.httpapi (REST over HTTP/JSON, used for NX-API and the Meraki Dashboard).
  • ansible_network_os — which platform it is, so the connection plugin loads the right behaviour. For Cisco IOS XE that is cisco.ios.ios.

It is easy to swap these in your head; keep them straight by remembering that the connection plugin is the transport and the network OS is the dialect. With network_cli, privilege escalation uses the enable secret:

ansible_become=yes
ansible_become_method=enable

This content ships in collections, which you install on the control node, not the device: cisco.ios for the IOS XE modules and ansible.netcommon for the shared connection plumbing. List what you have with ansible-galaxy collection list.

Inventory: hosts and groups

The inventory lists the devices Ansible manages and sorts them into groups. Attaching the connection variables to a group instead of each host is the whole point — you write them once:

# inventory.ini
[ios_xe]
csr1  ansible_host=192.0.2.11
csr2  ansible_host=192.0.2.12

[ios_xe:vars]
ansible_connection=ansible.netcommon.network_cli
ansible_network_os=cisco.ios.ios
ansible_user=automation
ansible_password={{ vault_device_password }}

Note the password is a variable reference, never a literal. Committing a plaintext credential to an inventory file or playbook is one of the most common mistakes in network automation; the real value lives in an encrypted vault file (below). A play then targets a group by name — hosts: ios_xe — and runs against every device in it.

group_vars, host_vars, and precedence

As soon as you have more than one group or a per-device exception, move data out of the inventory file and into two conventional directories that Ansible loads automatically:

project/
├── inventory.ini
├── group_vars/
│   ├── all.yml        # applies to every host
│   └── ios_xe.yml     # applies to the ios_xe group
├── host_vars/
│   └── csr1.yml       # applies only to csr1
└── site.yml           # the playbook

Keeping data apart from task logic is deliberate: the same site.yml drives many environments because the environment-specific values live in these files, not in the play. When the same variable is defined in more than one place, Ansible resolves it by a fixed precedence order. The full list has 22 levels, but a handful of rules cover almost every real case:

  • Host beats group. A value in host_vars/csr1.yml overrides the same key in group_vars/ios_xe.yml, which overrides group_vars/all.yml.
  • role defaults are the weakest. Anything in a role's defaults/main.yml is meant to be overridden, so it loses to inventory and group/host vars.
  • Extra vars always win. A value passed on the command line with -e (--extra-vars) beats everything else — handy for a one-off override, dangerous as a habit.
  • Ties go to the more specific group. When a host is in two groups at the same level, a child group overrides its parent, and otherwise the last group loaded (alphabetical by name) wins unless you set ansible_group_priority to break the tie deliberately.

When a lookup surprises you, run ansible-inventory --host csr1 --vars to see the merged result Ansible actually computed.

Secrets with Ansible Vault

Credentials get their own encrypted file so they can sit in Git next to the playbooks. Ansible Vault encrypts content at rest; you supply the password only at run time, so the ciphertext is safe to commit:

# encrypt a whole variables file
ansible-vault encrypt group_vars/ios_xe/vault.yml

# run a playbook, prompting for the vault password
ansible-playbook site.yml --ask-vault-pass

For a project where most variables are harmless and only a few are secret, encrypt a single value instead of the whole file so the rest stays readable in diffs:

ansible-vault encrypt_string 's3cr3t' --name 'vault_device_password'

Paste the resulting !vault | block straight into a vars file. In CI, replace the interactive prompt with --vault-password-file pointing at a file the runner fetches from its secret store, and use --vault-id when different data is encrypted under different passwords. This is the Ansible-native half of the broader habits in secure coding practices — never commit, log, or echo a secret in clear text.

From static files to a source of truth

A hand-maintained inventory.ini is fine for a lab, but in production the list of devices and their attributes usually already lives in a system of record. Ansible can build its inventory dynamically from that instead of a static file — a NetBox or Nautobot inventory plugin, for example — so you never edit two places. That is the source-of-truth pattern: the inventory and group membership come from the database, and the playbooks stay generic.

Key takeaways

  • ansible_connection is the transport, ansible_network_os is the platform — set them on the group (network_cli + cisco.ios.ios for IOS XE), and install the cisco.ios and ansible.netcommon collections on the control node.
  • Attach connection details to groups, not hosts, and reference secrets as variables rather than hard-coding them in inventory.
  • group_vars/ and host_vars/ keep data apart from logic; remember that host beats group, role defaults are weakest, and -e extra vars win everything.
  • Encrypt secrets with Ansible Vault — a whole file with encrypt, or a single value with encrypt_string — and supply the password at run time so the ciphertext is Git-safe.
  • Graduate from a static inventory file to a dynamic, source-of-truth inventory once more than one person edits the device list.

Sources: Ansible network best practices, Ansible variable precedence, Ansible Vault, cisco.ios collection.