8 - Structure
This 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. Each part will build upon the last. 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.
Ansible Directory Structure
This part locks in the definitive project layout, expands ansible.cfg with every setting explained, establishes naming conventions I’ll follow for the rest of the project, and shows exactly how to separate playbooks by function so the project stays navigable as it grows.
The Project Structure
Here is the complete, final directory structure for the ansible-network project.
- ansible.cfg
- .gitignore
- .ansible-lint
- .yamllint
- .pre-commit-config.yaml
- README.md
- hosts.yml
- netbox.yml
- all.yml
- cisco_ios.yml
- cisco_nxos.yml
- paloalto.yml
- spine_switches.yml
- leaf_switches.yml
- wan.yml
- linux_hosts.yml
- wan-r1.yml
- wan-r2.yml
- fw-01.yml
- spine-01.yml
- spine-02.yml
- leaf-01.yml
- leaf-02.yml
- host-01.yml
- host-02.yml
- site.yml
- deply_base_config.yml
- deploy_vlans.yml
- deploy_bgp.yml
- deploy_ospf.yml
- deploy_acls.yml
- validate_connectivity.yml
- validate_bgp.yml
- validate_vlans.yml
- validate_compliance.yml
- backup_all.yml
- backup_ios.yml
- backup_nxos.yml
- rollback_config.yml
- rollback_bgp.yml
- report_facts.yml
- report_interfaces.yml
- report_bgp_neighbors.yml
- update_packages.yml
- test_connectivity.yml
- requirements.yml
- base_config.j2
- bgp.j2
- acl.j2
- base_config.j2
- bgp.j2
- security+policy.j2
- banner.txt
- ca-bundle.crt
- common_vlans.yml
- bgp_policy.yml
- acl_definitions.yml
- test-connectivity.sh
- setup.sh
- test-connectivity.sh
- wan-r1-base.cfg
- spine-01-base.cfg
- settings.json
- hugo.toml
inventory/- The source of truth for what devices exist and how to reach them.playbooks/- Every playbook I wrike organized into subdirectories by function.roles/- Resuable, self-contained units of automation.collections/- Project level Ansible collections and therequirements.ymlfile. Keeps collection versions tied to the project.templates/- Jinja2 template files, organized by platform.files/- Static files that get deployed to devices unchanged (banner text, certificates, scripts).vars/- Variable files that don’t belong in the inventory because they’re not device specific.backups/- Generated by backup playbooks.scripts/- Shell and Python help scripts.containerlab/- Everything related to the lab environment.
backups/ directory is committed to .gitignore. Usually, configuration backups have their own dedicated storage (either a separate Git repository, an S3 bucket, or dedicated backup platform like Oxidized.)Creating Directory Structure
After creating the structure layout, I create all directories that don’t exist yet.
cd ~/projects/ansible-networkmkdir -p playbooks/{deploy,validate,backup,rollback,report,utils}
mkdir -p roles
mkdir -p templates/{ios,nxos,panos}
mkdir -p files/{ios,ssl}
mkdir -p vars
mkdir -p backups/{cisco_ios,cisco_nxos,paloalto}
mkdir -p scriptsI then added backups/ to .gitignore
backups/Then commit it.
git add .gitignore
git commit -m "chore: exclude backups/ directory from git"Git doesn’t track empty directories, so I add .gitkeep placeholder files so the directory structure is committed.
find . -type d -empty -not -path "./.git/*" -not -path "./backups/*" \
-exec touch {}/.gitkeep \;Then commit the added files.
git add .
git commit -m "chore: add directory structure with .gitkeep placeholders"Ansible Config File
The definitive version for this project.
[defaults]
inventory = inventory/
remote_user = ansible
host_key_checking = False
forks = 9
roles_path = roles/
collections_path = collections/:~/.ansible/collections
retry_files_enabled = False
stdout_callback = yaml
callbacks_enabled = ansible.posix.timer, ansible.posix.profile_tasks
deprecation_warnings = False
interpreter_python = auto_silent
vault_password_file = .vault_pass
gathering = explicit
log_path = /var/log/ansible/ansible.log
any_errors_fatal = False
timeout = 30
[inventory]
enable_plugins = yaml, ini, auto, netbox.netbox.nb_inventory
[ssh_connection]
ssh_args = -o ControlMaster=no -o ControlPersist=no -o StrictHostKeyChecking=no
pipelining = False
retries = 3
[persistent_connection]
connect_timeout = 30
command_timeout = 60
connect_retry_timeout = 15
[colors]
highlight = white
verbose = blue
warn = bright purple
error = red
debug = dark gray
deprecate = purple
skip = cyan
unreachable = red
ok = green
changed = yellow
diff_add = green
diff_remove = red
diff_lines = cyanThe 3 settings that must change before this config going into production:
#Lab Production
host_key_checking = False > host_key_checking = True
ssh_args = ... StrictHostKeyChecking=no > remove that argument
vault_password_file = .vault_pass > make sure this is set and .vault_pass is securedThese settings should be added as well:
log_path = /var/log/ansible/ansible.log
any_errors_fatal = False
fact_checking = jsonfileTo check which ansible.cfg is active
ansible --version | grep "config file"Naming Conventions
Consistent naming helps navigate projects at any given time, whether it be 1 week or 1 year from now.
Playbook Files
Format: <verb>_<noun>[_<qualifier>].yml
<verb> = deploy, validate, backup, rollback, report, test, update
<noun> = what is being acted on
<qualifier> = optional scope or platformGood Name Examples:
deploy_bgp.ymldeploy_vlans_nxos.ymlbackup_all_configs.yml
Not So Good Name Examples:
bgp.ymlfix.ymlFINAL_acls.yml
Role Names
Format: <verb>_<platform>_<function>
<function>Good Name Examples:
cisco_ios_basepanos_security_policycmmon_ntp
Not So Good Name Examples:
my_rolenew_cisco_rolebgp_role_2
Task Names
Good Name Examples:
- name: “Deploy | Configure OSP process and area on WAN interfaces”
- name: “Validate | Confirm BGP neighbors are in Established state”
Not So Good Name Examples:
- name: task1
- name: configure
Variable Names
Format: <scope>_<object>_<attribute>Good Name Examples:
- bgp_as
- bgp_router_id
- ospf_area
Not So Good Name Examples:
- BGP_AS
- temp_var
File & Directory Names
Always use:
- lowercase-with-hyphens for directories and most files
- lowercase_with_underscores for Python files and variable files
Good Name Examples:
- playbooks/deploy/
- cisco_ios_base/
Not So Good Name Examples:
- CamelCase/
- spaces in names/
Playbooks by Function
The playbooks/ directory is organized into subdirectories by function.
Master Playbook
The master playbook calls other playbooks in sequence. Running site.yml deploys the entire environment.
---
- name: "Deploy | Base configuration on all network devices"
import_playbook: deploy_base_config.yml
tags:
- base
- always
- name: "Deploy | VLAN configuration on NX-OS switches"
import_playbook: deploy_vlans.yml
tags:
- vlans
- nxos
- name: "Deploy | OSPF routing on IOS-XE routers"
import_playbook: deploy_ospf.yml
tags:
- ospf
- routing
- ios
- name: "Deploy | BGP configuration on all routing devices"
import_playbook: deploy_bgp.yml
tags:
- bgp
- routing
- name: "Deploy | ACL configuration on IOS-XE"
import_playbook: deploy_acls.yml
tags:
- acls
- security
- iosBase Configuration Playbook
This playbook will configure hostname, domain, NTP, DNS, syslog, SNMP and banners.
---
- name: "Deploy | Base configuration on Cisco IOS-XE devices"
hosts: cisco_ios
gather_facts: false
connection: network_cli
tasks:
- name: "Deploy | Set hostname"
cisco.ios.ios_hostname:
config:
hostname: "{{ device_hostname }}"
state: merged
- name: "Deploy | 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 }}"
- name: "Deploy | Configure DNS servers"
cisco.ios.ios_config:
lines:
- "ip name-server {{ dns_servers | join {' '} }}"
- name: "Deploy | Configure syslog"
cisco.ios.ios_logging_global:
config:
hosts:
- hostname: "{{ syslog_server }}"
severity: informational
state: merged
- name: "Deploy | Save configuration"
cisco.ios.ios_command:
commands:
- write memory
- name: "Deploy | Base configuration on Cisco NX-OS devices"
hosts: cisco_nxos
gather_facts: false
connection: network_cli
tasks:
- name: "Deploy | Set hostname"
cisco.nxos.nxos_hostname:
config:
hostname: "{{ device_hostname }}"
state: merged
- name: "Deploy | Configure NTP servers"
cisco.nxos.nxos_ntp_global:
config:
servers:
- server: "{{ item }}"
state: merged
loop: "{{ ntp_servers }}"
- name: "Deploy | Configure syslog"
cisco.nxos.nxos_logging_global:
config:
hosts:
- name: "{{ syslog_server }}"
severity: info
state: merged
- name: "Deploy | Save configuration"
cisco.nxos.nxos_command:
commands:
- copy running-config startup-configValidate Playbook
This playbook will verify basic device connectivity. It will test ssh reachability, device response, and basic interface state.
---
- name: "Validate | Connectivity to all network devices"
hosts: network_devices
gather_facts: false
connection: network_cli
tasks:
- name: "Validate | Confirm device responds to show version"
cisco.ios.ios_command:
commands:
- show version
register: show_version_output
when: ansible_network_os == 'cisco.ios.ios'
- name: "Validate | Asset show version contains expected platform"
ansible.builtin.asset:
that:
- "'Cisco IOS' in show_version_output.stdout[0] or
'Cisco IOS-XE' in show_version_output.stdout[0]"
fail_msg: >
FAIL: {{ inventory_hostname }} did not return expected IOS output.
Got: {{ show_version_output.stdout[0][:100] }}
success_msg: "PASS: {{ inventory_hostname }} is running Cisco IOS/IOS-XE"
when: ansible_network_os == 'cisco.ios.ios'
- name: "Validate | Confirm NX-OS device responds"
cisco.nxos.nxos_command:
command:
- show version
register: nxos_version_output
when: ansible_network_os == 'cisco.nxos.nxos'
- name: "Validate | Assert NX-OS version output"
ansible.builtin.assert:
that:
- "'Nexus' in nxos_version_output.stdout[0]"
fail_msg: "FAIL: {{ inventory_hostname }} did not return expected NX-OS output"
success_msg: "PASS: {{ inventory_hostname }} os rimmomg NX-OS"
when: ansible_network_os == 'cisco.nxos.nxos'
post_tasks:
- name: "Validate | Print connectivity summary"
ansible.builtin.debug:
msg: "All connectivity checks passed for {{ inventory_hostname }}"Backup Playbook
This playbook will back up running configurations from all devices.
---
- name: "Backup | Running configurations from IOS-XE devices"
hosts: cisco_ios
gather_facts: false
connection: network_cli
vars:
timestamp: "{{ lookup('pipe', 'date +%Y%m%d_%H%M%S') }}"
tasks:
- name: "Backup | Create backup directory for {{ inventory_hostname }}"
ansible.builtin.file:
path: "backups/cisco_ios/{{ inventory_hostname }}"
state: directory
mode: '0755'
delegate_to: localhost
- name: "Backup | Gather running configuration from {{ inventory_hostname }}"
cisco.ios.ios_command:
commands:
- show running-config
register: ios_running_config
- name: "Backup | Write configuration to file"
ansible.builtin.copy:
content: "{{ ios_running_config.stdout[0] }}"
dest: "backups/cisco_ios/{{ inventory_hostname }}/{{ inventory_hostname }}_{{ timestamp }}.cfg"
mode: '0644'
delegate_to: localhost
- name: "Backup | Confirm backup was written"
ansible.builtin.debug:
msg: "Backup saved: backups/cisco_ios/{{ inventory_hostname }}/{{ inventory_hostname }}_{{ timestamp }}.cfg"
- name: "Backup | Running configurations from NX-OS devices"
hosts: cisco_nxos
gather_facts: false
connection: network_cli
vars:
timestamp: "{{ lookup('pipe', 'date +%Y%m%d_%H%M%S') }}"
tasks:
- name: "Backup | Create backup directory for {{ inventory_hostname }}"
ansible.builtin.file:
path: "backups/cisco_nxos/{{ inventory_hostname }}"
state: directory
mode: '0755'
delegate_to: localhost
- name: "Backup | Gather running configuration from {{ inventory_hostname }}"
cisco.nxos.nxos_command:
commands:
- show running-config
register: nxos_running_config
- name: "Backup | Write configuration to file"
ansible.builtin.copy:
content: "{{ nxos_running_config.stdout[0] }}"
dest: "backups/cisco_nxos/{{ inventory_hostname }}/{{ inventory_hostname }}_{{ timestamp }}.cfg"
mode: '0644'
delegate_to: localhostRollback Playbook
This playbook will restore a device configuration from backup.
---
- name: "Rollback | Restore IOS-XE configuration from backup"
hosts: cisco_ios
gather_facts: false
connection: network_cli
pre_tasks:
- name: "Rollback | Verify backup file exists before proceeding"
ansible.builtin.stat:
path: "{{ backup_file }}"
register: backup_stat
delegate_to: localhost
- name: "Rollback | Abort if backup file not found"
ansible.builtin.fail:
msg: >
Backup file not found: {{ backup_file }}
Rollback aborted. Provide a valid backup_file path with -e.
when: not backup_stat.stat.exists
- name: "Rollback | Take pre-rollback snapshot for comparison"
cisco.ios.ios_command:
commands:
- show running-config
register: pre_rollback_config
- name: "Rollback | Save pre-rollback config as safety backup"
ansible.builtin.copy:
content: "{{ pre_rollback_config.stdout[0] }}"
dest: "backups/cisco_ios/{{ inventory_hostname }}/{{ inventory_hostname }}_pre_rollback_{{ lookup('pipe', 'date +%Y%m%d_%H%M%S') }}.cfg"
mode: '0644'
delegate_to: localhost
tasks:
- name: "Rollback | Push backup configuration to {{ inventory_hostname }}"
cisco.ios.ios_config:
src: "{{ backup_file }}"
replace: config # Replace entire config, not merge
register: rollback_result
- name: "Rollback | Save configuration after rollback"
cisco.ios.ios_command:
commands:
- write memory
post_tasks:
- name: "Rollback | Confirm device is responsive after rollback"
cisco.ios.ios_command:
commands:
- show version
register: post_rollback_check
- name: "Rollback | Report rollback result"
ansible.builtin.debug:
msg: >
Rollback complete for {{ inventory_hostname }}.
Changed: {{ rollback_result.changed }}
Device is responsive: {{ post_rollback_check is succeeded }}Report Playbook
This playbook will gather and display facts from all devices.
---
- name: "Report | Gather facts from IOS-XE devices"
hosts: cisco_ios
gather_facts: false
connection: network_cli
tasks:
- name: "Report | Gather IOS device facts"
cisco.ios.ios_facts:
gather_subset:
- hardware
- default
register: ios_facts
- name: "Report | Display IOS device summary"
ansible.builtin.debug:
msg:
- "Hostname: {{ ansible_net_hostname }}"
- "Platform: {{ ansible_net_model | default('unknown') }}"
- "Version: {{ ansible_net_version }}"
- "Uptime: {{ ansible_net_stacked_models | default('N/A') }}"
- "Interfaces:{{ ansible_net_interfaces | length }} total"
- name: "Report | Gather facts from NX-OS devices"
hosts: cisco_nxos
gather_facts: false
connection: network_cli
tasks:
- name: "Report | Gather NX-OS device facts"
cisco.nxos.nxos_facts:
gather_subset:
- hardware
- default
- name: "Report | Display NX-OS device summary"
ansible.builtin.debug:
msg:
- "Hostname: {{ ansible_net_hostname }}"
- "Platform: {{ ansible_net_platform }}"
- "Version: {{ ansible_net_version }}"
- "Interfaces:{{ ansible_net_interfaces | length }} total"Host Vars vs Group Vars vs Play-Level Vars
Here’s a decision guide on when to use each type:
Is this variable the same for every device in the entire inventory?
Yes → group_vars/all.yml
No ↓
Is this variable the same for every device in a speicifc platform group?
Yes → group_vars/<platform>.yml
No ↓
Is this variable the same for a specific device role or logical group?
Yes → group_vars/<group>.yml
No ↓
Is this variable unique to a specific device?
Yes → host_vars/<hostname>.yml
No ↓
Is this variable only relevant to a specific play (no reusable)?
Yes → vars: block inside the playbook
No ↓
Is this variable shared across multiple playbooks but not device-specific?
Yes → vars/ directory fileExamples
Same for every device
ntp_servers:
- 216.239.35.0
- 216.239.35.4
ansible_user: ansibleSame for every IOS device
ansible_network_os: cisco.ios.ios
ansible_become: trueSame for every IOS device
device_role: spine
bgp_as: 65000
fabric_mtu: 9216Unique to Spine-01
ansible_host: 172.16.0.21
loopback0:
ip: 10.255.1.1
bgp_router_id: 10.255.1.1Relevant only to this play
vars: inside a playbook
- name: Deploy BGP
hosts: cisco_ios
vars:
bgp_timer_keepalive: 30
bgp_timer_holdtime: 90
tasks: ...Shared across playbooks
route_map_permit_local:
- prefix: 10.0.0.0/8
- prefix: 172.16.0.0/12Sensitive Data
Sensitive data must never be visible in the directory root since people will be able to see it if they clone the repo or visit the Github page.
Safe for project root:
- ansible.cfg
- .gitignore
- README.md
- .ansible-lint
- .yamllint
- requirements files
Never put in project root:
- passwords or tokens
- private keys
- .vault_pass (put in .gitignore)
- .env files with secrets
- unencrypted credential files
- production IP addressing details