Updated 10 min read

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.

Fig. 1. Ansible runs tasks and exits; anything that needs continuous state or a change plan needs a different tool.

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.

JobAnsibleBetter tool or note
Packages, configuration files, services, accounts, firewall rulesYesThe core use case; modules are idempotent
Operational tasks across many machines: certificate rotation, updates in a set orderYesserial and --limit give control over order and scope
Integrations over APIs: image registry, ticketing system, CI platformYesThe ansible.builtin.uri module or a dedicated collection
Windows Server where Group Policy does not reachYesWinRM and the ansible.windows collection; workstation settings stay in GPO
Maintaining state over time and correcting driftNoA controller that reconciles state continuously
Day-to-day Kubernetes resourcesNoManifests in Git and a GitOps controller
Creating machines, networks and volumesNoTerraform or OpenTofu, with a change plan and state
Complex logic and data transformationsNoA 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:

  1. defaults/main.yml in the role: values sensible for most cases.
  2. group_vars/ in the inventory: differences between environments and server groups.
  3. 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.