What this part is really about
The first Ansible article introduced installation, inventories and a first playbook. This part turns that into an operational structure: a repository that separates hosts, groups, variables, roles, secrets, tests and execution so changes remain reviewable and repeatable.
The goal is not a large demo, but a pattern that can start in a lab and later continue in test, production, AWX or Red Hat Ansible Automation Platform. After this article, an engineer should know where each file belongs, why it belongs there and how to check a change before it touches production.
Target model: the repository as an operations contract
A useful Ansible repository is more than a pile of playbooks. It is the contract between architecture, operations and review: which systems exist, which grouping logic applies, which values are global, environment-specific or host-specific, which role sets which state, which secrets are encrypted and which commands count as technical acceptance.
Control node, CI or AWX|| reads ansible.cfgvInventory per environment -> group_vars / host_vars / vault|| calls playbooksvRoles with defaults, tasks, handlers and templates|| set states idempotentlyvManaged nodes: SLES, Rocky, Alma, Debian, Ubuntu, network services
- Inventories answer: which systems does a run target?
- Variables answer: which values are used to calculate the desired state?
- Roles answer: which reusable operational logic is applied?
- Vault answers: which sensitive values may live in the repository without being stored as clear text?
- Check mode, diff mode and idempotence answer: is the change traceable and repeatable?
YAML or YAML: what is the difference?
Technically, .yaml and .yaml describe the same file format. .yaml is a historical short extension from environments where file extensions were kept short. Today .yaml is often clearer because it spells out the format name. Ansible accepts both variants.
For teams, the important point is not which suffix is more correct, but that the repository is consistent. ForgeOne examples intentionally use .yaml or language: yaml in code blocks, although many Ansible defaults and existing roles still use main.yaml, site.yaml or requirements.yaml. In existing Ansible conventions .yaml remains completely legitimate; new ForgeOne examples prefer .yaml unless an established tool expects .yaml.
Repository structure with clear boundaries
The structure should separate environments, playbooks, roles, collections and secrets. That allows roles to run unchanged against lab and production while host lists, group values and Vault files remain environment-specific.
ansible-platform/├── ansible.cfg├── inventories/│ ├── lab/│ │ ├── hosts.yaml│ │ ├── group_vars/│ │ │ ├── all.yaml│ │ │ ├── linux.yaml│ │ │ └── vault.yaml│ │ └── host_vars/│ │ └── sles16-client01.example.test.yaml│ └── prod/│ ├── hosts.yaml│ ├── group_vars/│ └── host_vars/├── playbooks/│ ├── baseline.yaml│ └── patch-window.yaml├── roles/│ └── linux_baseline/│ ├── defaults/main.yaml│ ├── tasks/main.yaml│ ├── handlers/main.yaml│ └── templates/issue.j2└── collections/└── requirements.yaml
The dependency direction matters: playbooks call roles. Roles know defaults and templates. Inventories provide target systems and values. Secrets are encrypted. Roles should not have to know whether they are currently running in lab or production; that decision comes from inventory and variables.
ansible.cfg: fix project behavior
Without a project-local ansible.cfg, a run depends too much on user profiles, system configuration and shell environment. For team work and CI, relevant behavior belongs in the repository.
[defaults]inventory = inventories/lab/hosts.yamlroles_path = rolescollections_path = .ansible/collectionsinterpreter_python = auto_silenthost_key_checking = Trueretry_files_enabled = Falsestdout_callback = yaml[ssh_connection]ssh_args = -o ControlMaster=auto -o ControlPersist=60spipelining = Truetimeout = 30[privilege_escalation]become = Truebecome_method = sudo
ansible-config viewansible-config dump --only-changed
ansible-config view shows which configuration Ansible actually loads. ansible-config dump --only-changed shows which values differ from the default. This helps when a run behaves differently on a workstation, a CI runner or in AWX.
Inventory by environment, distribution and function
An inventory should not merely collect server names. It describes which systems belong to which environment, distribution and operational function. A host can be in multiple groups: for example linux, suse and monitoring_targets.
all:children:linux:children:suse:hosts:sles16-client01.example.test:ansible_host: 172.31.52.21rhel_family:hosts:rocky10-client01.example.test:ansible_host: 172.31.52.31monitoring_targets:hosts:sles16-client01.example.test:rocky10-client01.example.test:vars:ansible_user: labadminansible_python_interpreter: /usr/bin/python3
The groups suse and rhel_family describe technical differences. monitoring_targets describes an operational function. The same role can later run against SLES systems only, all Linux systems or all monitoring targets.
Variable model: few places, clear rules
Ansible knows many variable sources. In practice, use only a few deliberately: role defaults for overridable defaults, group_vars/all for global conventions, group_vars/<group> for group logic, host_vars for real exceptions and Vault for secrets.
# inventories/lab/group_vars/all.yamltimezone: Europe/Viennabaseline_owner: platform-team# inventories/lab/group_vars/linux.yamllinux_baseline_packages:- chrony- vim- curllinux_baseline_services:- chronyd# inventories/lab/host_vars/sles16-client01.example.test.yamllinux_baseline_packages:- chrony- vim- curl- supportutils
Host variables should remain exceptions. If many hosts need the same exception, a new group is usually better. That keeps reviews readable and prevents configuration from being hidden in many host_vars files.
Roles instead of ever-growing single playbooks
A playbook should orchestrate, not contain every detail. Roles encapsulate reusable operational logic. The playbook then only decides which role is applied to which target group.
# playbooks/baseline.yaml---- name: Roll out Linux baselinehosts: linuxbecome: truegather_facts: trueroles:- role: linux_baseline
# roles/linux_baseline/tasks/main.yaml---- name: Install baseline packagesansible.builtin.package:name: "{{ linux_baseline_packages }}"state: present- name: Enable baseline servicesansible.builtin.service:name: "{{ item }}"enabled: truestate: startedloop: "{{ linux_baseline_services }}"- name: Write operational noticeansible.builtin.template:src: issue.j2dest: /etc/issue.d/forgeone-baseline.issueowner: rootgroup: rootmode: '0644'notify: Baseline notice changed
# roles/linux_baseline/handlers/main.yaml---- name: Baseline notice changedansible.builtin.debug:msg: "Baseline notice was updated on {{ inventory_hostname }}."
This looks small, but it is the decisive step: the same role can now run in multiple environments, receive different variables and later be checked with Molecule, ansible-lint or CI.
Vault: do not hide secrets, encrypt them deliberately
Ansible Vault protects sensitive values in the repository from clear-text storage. It does not replace access control or a central secrets platform, but it prevents passwords, tokens or private parameters from landing directly in Git history.
ansible-vault create inventories/lab/group_vars/vault.yamlansible-vault encrypt_string \--vault-id lab@prompt \'ExamplePassword' \--name 'ansible_become_password'
# inventories/lab/group_vars/linux.yamlansible_become_password: "{{ vault_ansible_become_password }}"# inventories/lab/group_vars/vault.yamlvault_ansible_become_password: !vault |$ANSIBLE_VAULT;1.2;AES256;lab396661303834...
The team should know which Vault IDs exist, who owns the passphrase, whether CI uses a secret store and how Vault files are rotated. Unclear Vault operations are harder to debug later than a normal playbook.
Check mode, diff mode and technical acceptance
Before production changes, a run should at least be syntax-checked, simulated, reviewed with diff and then checked for idempotence. The second run matters: it shows whether the role really describes states or produces changes every time.
ansible-inventory -i inventories/lab/hosts.yaml --graphansible-inventory -i inventories/lab/hosts.yaml --listansible-playbook -i inventories/lab/hosts.yaml playbooks/baseline.yaml --syntax-checkansible-playbook -i inventories/lab/hosts.yaml playbooks/baseline.yaml --check --diffansible-playbook -i inventories/lab/hosts.yaml playbooks/baseline.yaml --limit sles16-client01.example.test --diffansible-playbook -i inventories/lab/hosts.yaml playbooks/baseline.yaml --check --diff
Check Expectation----------------------------- ---------------------------------------------Inventory --graph Groups and hosts are visible as designedInventory --list Variables resolve correctly per hostSyntax check Playbook is syntactically validCheck mode + diff Planned changes are traceableLimit to one host Change is rolled out narrowly firstSecond check mode changed=0 or documented exceptions
If check mode cannot provide a useful statement, document that. Not every module can fully simulate check mode. That is exactly why a limited first real run on a single host is important.
Typical mistakes at this stage
- Variables are hard-coded in playbooks although they belong in group_vars or defaults.
- host_vars become a dumping ground for group logic.
- Secrets live in normal YAML files or accidentally land in Git history.
- Roles have no defaults and work with exactly one inventory only.
- Playbooks are executed in production without --check, --diff and a second idempotence run.
- Inventory groups mix environment, distribution and function in unclear names.
What comes next
The next useful part is a real Linux baseline: packages, services, SSH, time synchronization, firewall and simple compliance checks. The same structure can then be reused for updates, reboots, role quality, CI and AWX.



