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.cfg
v
Inventory per environment -> group_vars / host_vars / vault
|
| calls playbooks
v
Roles with defaults, tasks, handlers and templates
|
| set states idempotently
v
Managed 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.

ini
[defaults]
inventory = inventories/lab/hosts.yaml
roles_path = roles
collections_path = .ansible/collections
interpreter_python = auto_silent
host_key_checking = True
retry_files_enabled = False
stdout_callback = yaml
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
pipelining = True
timeout = 30
[privilege_escalation]
become = True
become_method = sudo
bash
ansible-config view
ansible-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.

yaml
all:
children:
linux:
children:
suse:
hosts:
sles16-client01.example.test:
ansible_host: 172.31.52.21
rhel_family:
hosts:
rocky10-client01.example.test:
ansible_host: 172.31.52.31
monitoring_targets:
hosts:
sles16-client01.example.test:
rocky10-client01.example.test:
vars:
ansible_user: labadmin
ansible_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.

yaml
# inventories/lab/group_vars/all.yaml
timezone: Europe/Vienna
baseline_owner: platform-team
# inventories/lab/group_vars/linux.yaml
linux_baseline_packages:
- chrony
- vim
- curl
linux_baseline_services:
- chronyd
# inventories/lab/host_vars/sles16-client01.example.test.yaml
linux_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.

yaml
# playbooks/baseline.yaml
---
- name: Roll out Linux baseline
hosts: linux
become: true
gather_facts: true
roles:
- role: linux_baseline
yaml
# roles/linux_baseline/tasks/main.yaml
---
- name: Install baseline packages
ansible.builtin.package:
name: "{{ linux_baseline_packages }}"
state: present
- name: Enable baseline services
ansible.builtin.service:
name: "{{ item }}"
enabled: true
state: started
loop: "{{ linux_baseline_services }}"
- name: Write operational notice
ansible.builtin.template:
src: issue.j2
dest: /etc/issue.d/forgeone-baseline.issue
owner: root
group: root
mode: '0644'
notify: Baseline notice changed
yaml
# roles/linux_baseline/handlers/main.yaml
---
- name: Baseline notice changed
ansible.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.

bash
ansible-vault create inventories/lab/group_vars/vault.yaml
ansible-vault encrypt_string \
--vault-id lab@prompt \
'ExamplePassword' \
--name 'ansible_become_password'
yaml
# inventories/lab/group_vars/linux.yaml
ansible_become_password: "{{ vault_ansible_become_password }}"
# inventories/lab/group_vars/vault.yaml
vault_ansible_become_password: !vault |
$ANSIBLE_VAULT;1.2;AES256;lab
396661303834...

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.

bash
ansible-inventory -i inventories/lab/hosts.yaml --graph
ansible-inventory -i inventories/lab/hosts.yaml --list
ansible-playbook -i inventories/lab/hosts.yaml playbooks/baseline.yaml --syntax-check
ansible-playbook -i inventories/lab/hosts.yaml playbooks/baseline.yaml --check --diff
ansible-playbook -i inventories/lab/hosts.yaml playbooks/baseline.yaml --limit sles16-client01.example.test --diff
ansible-playbook -i inventories/lab/hosts.yaml playbooks/baseline.yaml --check --diff
Check Expectation
----------------------------- ---------------------------------------------
Inventory --graph Groups and hosts are visible as designed
Inventory --list Variables resolve correctly per host
Syntax check Playbook is syntactically valid
Check mode + diff Planned changes are traceable
Limit to one host Change is rolled out narrowly first
Second 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.