Ansible in a small team: what to use it for and how to structure the repository
A playbook written by one person grows to several thousand lines and takes on jobs Ansible was never meant for. Where to use it, where not to, and how to lay out the repository so that a successor understands it within an hour.
A typical story of automation in a small team: one person writes a playbook to configure web servers, then adds virtual machine creation, deploying manifests to the cluster through shell: kubectl apply, and a few dozen command tasks with logic in Jinja2. Two years later the file has three thousand lines and the author changes jobs. The successor runs it in --check mode, gets forty “changed” tasks on servers that work correctly, and starts from scratch.
Both problems share one cause: two decisions were missing at the start. The first is what the team uses Ansible for and what it does not. The second is a repository structure that lets someone understand the code without talking to its author.
What Ansible is for, and what needs another tool
Ansible connects to a machine over SSH or WinRM, runs a list of tasks and exits. That model determines where it works well and where it starts to get in the way.
| Job | Ansible | Better tool or note |
|---|---|---|
| Packages, configuration files, services, accounts, firewall rules | Yes | The core use case; modules are idempotent |
| Operational tasks across many machines: certificate rotation, updates in a set order | Yes | serial and --limit give control over order and scope |
| Integrations over APIs: image registry, ticketing system, CI platform | Yes | The ansible.builtin.uri module or a dedicated collection |
| Windows Server where Group Policy does not reach | Yes | WinRM and the ansible.windows collection; workstation settings stay in GPO |
| Maintaining state over time and correcting drift | No | A controller that reconciles state continuously |
| Day-to-day Kubernetes resources | No | Manifests in Git and a GitOps controller |
| Creating machines, networks and volumes | No | Terraform or OpenTofu, with a change plan and state |
| Complex logic and data transformations | No | A Python script called as a single task |
Ansible can bootstrap a cluster or install a GitOps controller in it, but it should not run the cluster day to day: the kubernetes.core.k8s module does not detect drift and leaves no change history in a repository. I cover that split in more detail in GitOps vs a deployment script.
Repository layout
The directory structure is the first piece of documentation. A layout that works in teams of a few people:
ansible.cfg
collections/requirements.yml collections with pinned versions
inventories/
prod/
hosts.yml machines and groups
group_vars/
all/vars.yml shared variables
all/vault.yml secrets, encrypted
web.yml differences for the web group
host_vars/
srv-web-02.corp.example.com.yml
test/
hosts.yml
group_vars/
playbooks/
web.yml thin playbook: group and roles
roles/
nginx/
defaults/main.yml every variable of the role
tasks/main.yml
handlers/main.yml
templates/nginx.conf.j2
README.md what it does, what it needs, example
.ansible-lint
README.md how to run, where the secrets are
A playbook contains no tasks, only the target group and a list of roles. The logic lives in the roles:
- name: Web servers
hosts: web
become: true
roles:
- chrony
- nginx
[defaults]
# Test environment by default; production requires an explicit -i
inventory = inventories/test/hosts.yml
roles_path = roles
A test inventory as the default is a cheap safety catch: a command run without -i hits test machines, and production requires a deliberate -i inventories/prod/hosts.yml. The roles_path entry is needed because the playbooks sit in a subdirectory, and without it Ansible looks for roles in a roles/ directory next to the playbook file and in the default paths.
collections:
- name: ansible.windows
version: "2.5.0"
- name: community.general
version: "9.4.0"
Collections are installed with ansible-galaxy collection install -r collections/requirements.yml. Raising a version is a separate change in the repository, with its own review, not a side effect of installing on a new workstation.
A role: one responsibility, prefixed variables
A role installs and configures one thing: a web server, a monitoring agent, time synchronisation. An “application server” role that does twenty things cannot be reused or tested. Every variable the role uses has a default value and a comment in defaults/main.yml. That is the role’s contract: whoever wants to use it reads one file.
# Number of worker processes; auto means the number of cores
nginx_worker_processes: auto
# Maximum request size, e.g. of an uploaded file
nginx_client_max_body_size: 10m
# Sites to configure; the list is overridden in group_vars
nginx_sites: []
# Removes the default site shipped with the package
nginx_remove_default_site: true
- name: Install nginx
ansible.builtin.package:
name: nginx
state: present
- name: Deploy the main configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Start the service and enable it at boot
ansible.builtin.service:
name: nginx
state: started
enabled: true
The Reload nginx handler in handlers/main.yml reloads the service only when the template has actually changed. Fully qualified module names (ansible.builtin.template rather than template) remove any doubt about which collection a module comes from.
Variables: three homes
Ansible has more than twenty levels of variable precedence. In practice three are enough, plus a rule not to use the rest:
defaults/main.ymlin the role: values sensible for most cases.group_vars/in the inventory: differences between environments and server groups.host_vars/: exceptions for individual machines, each with a comment explaining why it exists.
nginx_client_max_body_size: 50m
nginx_sites:
- name: intranet
server_name: intranet.corp.example.com
# This server also runs the reporting service; nginx gets fewer processes
nginx_worker_processes: 2
Variables in a playbook’s vars:, in a task, or passed with -e are acceptable only temporarily, while diagnosing a problem. A task looks the same in every environment, and the inventory describes the differences.
Secrets: Vault or a secrets manager
Passwords and keys never sit in the repository in plain text. There are two acceptable routes.
Ansible Vault. The group_vars/all/vault.yml file is encrypted as a whole, and the variables in it carry the vault_ prefix. The plain vars.yml next to it only references them, so searching by variable name works without decrypting anything:
# The value is in the encrypted vault.yml
app_db_password: "{{ vault_app_db_password }}"
The file is created and edited with ansible-vault create and ansible-vault edit, and the playbook runs with --ask-vault-pass or with --vault-password-file pointing to a file outside the repository.
An external secrets manager queried at run time through a lookup plugin from the relevant collection. This route is better when more than one tool uses the same secrets, because the value changes in one place. I discuss the choice in more detail in Secrets in automation and CI/CD.
Whichever you choose, tasks that handle a secret get no_log: true:
- name: Deploy the application environment file with the database password
ansible.builtin.template:
src: app.env.j2
dest: /etc/app/app.env
owner: root
group: app
mode: "0640"
no_log: true
This matters especially with --diff: without no_log, the template diff would show the password on screen and in the job log. no_log also hides the error message, so for diagnosis it is switched off temporarily on a test machine, never in production.
Idempotence: a second run with no changes
A playbook run twice in a row must report zero changes the second time. Modules such as package, template or service take care of that themselves. The trouble comes from command and shell, which Ansible always treats as a change unless told otherwise. In the example, app-init and app-migrate are the application’s own commands:
# No module for this operation; creates skips the task once the file exists
- name: Initialise the application data directory
ansible.builtin.command:
cmd: /opt/app/bin/app-init --data-dir /var/lib/app
creates: /var/lib/app/.initialized
# Read-only; also runs in --check mode
- name: Check the schema migration status
ansible.builtin.command: /opt/app/bin/app-migrate --status
register: app_migrate_status
changed_when: false
check_mode: false
- name: Apply pending migrations
ansible.builtin.command: /opt/app/bin/app-migrate --apply
when: "'pending' in app_migrate_status.stdout"
PLAY RECAP *********************************************************************
srv-web-01.corp.example.com : ok=12 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
Every changed in the second run is a task to fix. It is also worth running the playbook against a clean machine from time to time: a playbook that only works on a server that is already half-configured is not idempotent, merely accidentally convergent.
Dry run and inventory preview
--check runs the playbook without making changes, and --diff shows exactly what would change in files. It is the basic review tool before a rollout, ideally limited to one server:
ansible-playbook -i inventories/prod/hosts.yml playbooks/web.yml \
--check --diff --limit srv-web-01.corp.example.com
ansible-inventory shows how Ansible sees the inventory, without connecting to any machine:
ansible-inventory -i inventories/prod/hosts.yml --graph
@all:
|--@ungrouped:
|--@web:
| |--srv-web-01.corp.example.com
| |--srv-web-02.corp.example.com
|--@windows:
| |--srv-app-01.corp.example.com
With --host srv-web-02.corp.example.com, the same command prints the variables from group_vars and host_vars after merging, which answers the question of which value won. Role defaults are not there, because roles are not part of the inventory. If the inventory contains Vault files, --ask-vault-pass is needed as well.
ansible-lint and syntax-check in CI
The minimum that can be maintained without a separate test environment: every change in the repository runs ansible-playbook --syntax-check and ansible-lint. An example GitLab CI job:
ansible-lint:
stage: test
image: python:3.12
before_script:
# Example versions; pin the ones your team uses
- pip install --no-cache-dir ansible-core==2.17.5 ansible-lint==24.9.2
- ansible-galaxy collection install -r collections/requirements.yml
script:
- ansible-playbook -i inventories/test/hosts.yml --syntax-check playbooks/*.yml
- ansible-lint
# Start with the basic profile, then moderate once the code is tidy, then production
profile: basic
The basic profile catches errors and the most common bad practices without burying an older repository under hundreds of findings. Once the code is tidied up, it is raised to moderate and then to production, each time as a separate change.
Check your own environment
Whether the repository contains unencrypted Vault files or plain-text passwords in variables:
# vault.yml files without the encryption header; empty output is good
grep -L '^\$ANSIBLE_VAULT' inventories/*/group_vars/*/vault.yml
# Variables that look like secrets and do not reference vault_
grep -rniE '(password|passwd|secret|token)[a-z_]*\s*:' inventories/ roles/ | grep -v 'vault_'
Every file name from the first command and every line from the second is a secret to move, and if it has reached Git history, also one to replace with a new value.
Where the roles contain tasks that can break idempotence or dry-run mode:
grep -rnE '(ansible\.builtin\.)?(command|shell):' roles/ playbooks/
grep -rn 'check_mode: false' roles/ playbooks/
Every command and shell should have creates, removes or changed_when. Every check_mode: false must belong to a task that only reads.
Whether the playbook reports no changes on a configured server. --check changes nothing except tasks with check_mode: false, so do the previous step first:
ansible-playbook -i inventories/prod/hosts.yml playbooks/web.yml \
--check --diff --limit srv-web-01.corp.example.com
A good result is changed=0 and no diff sections. Every changed means either a manual change on the server or a task that is not idempotent.
How many findings ansible-lint reports for the current profile:
ansible-lint
The goal is zero findings. In an older repository, start with the rules broken by the most files and fix them in batches.