Guide: Infrastructure as Code

Ansible Essentials

Ansible makes machines match a description you write in YAML, without an agent on the target. This guide covers inventories, playbooks, idempotency, handlers, a minimal role, and safe runs. The examples were run with ansible-core and their output is shown.

What Ansible is and what it needs

Ansible describes the state you want in YAML, connects to your machines and makes them match it. It is agentless: the documentation says it avoids installing additional software on your infrastructure, and describes it as using SSH with existing operating system credentials.

  • Control node: the machine where you run Ansible. It can be nearly any UNIX-like machine with Python installed. Windows without WSL is not natively supported as a control node.
  • Managed node: a machine Ansible configures. It needs a user account that can connect over SSH to an interactive POSIX shell, and Python to run the code Ansible generates (some modules, such as network modules, are exceptions). Ansible itself does not need to be installed there.
  • Inventory: the list of managed nodes, organised in groups.
  • Module: a unit of work such as ansible.builtin.file or ansible.builtin.template. A task calls one module.
  • Play: maps a group of hosts to a list of tasks. A playbook is an ordered list of plays. By default, tasks run in order.
  • Role: a standard directory layout that packages tasks, defaults, templates and handlers for reuse.
  • Idempotent: running the same thing again leaves the system unchanged when it already matches the description.

Which Python versions are supported on each side depends on the release. Check the support matrix in the documentation for your version. This guide uses the fully qualified module names (ansible.builtin.file), which avoids ambiguity when other collections are installed.

$ ansible --version | head -n 1
ansible [core 2.19.13]

Every output below was captured from real runs with ansible-core 2.19.13 on Linux, in a throwaway directory, against hosts that use ansible_connection=local so that everything runs on one machine. Blank lines and the PLAY header are removed, one warning is omitted from the become run (Ansible could not use a temporary directory in the home of the nobody user and fell back to the system default), PLAY RECAP lines are cut after the changed count, and long paths are shortened. Messages and defaults can change between versions, so check the documentation for yours.

Inventories, groups and variables

An inventory lists hosts and groups. INI and YAML are the two formats with built-in support. Even if you define no groups, Ansible creates two: all (every host) and ungrouped (hosts in no other group). A group can contain other groups, written [name:children] in INI and children: in YAML.

inventory/hosts.ini
[web]
web1 ansible_connection=local
web2 ansible_connection=local

[db]
db1 ansible_connection=local

[prod:children]
web
db
inventory/hosts.yml
all:
  children:
    web:
      hosts:
        web1:
          ansible_connection: local
        web2:
          ansible_connection: local
    db:
      hosts:
        db1:
          ansible_connection: local
    prod:
      children:
        web:
        db:

Here ansible_connection=local is only a demo trick: it makes each host run on this machine. On real hosts you leave it out and Ansible connects over SSH. To make the inventory the default, point ansible.cfg at it. Ansible reads the first of these it finds: ANSIBLE_CONFIG, ./ansible.cfg, ~/.ansible.cfg, /etc/ansible/ansible.cfg. It does not load a config file from a world-writable current directory.

ansible.cfg
[defaults]
inventory = inventory/hosts.ini
interpreter_python = auto_silent

interpreter_python = auto_silent keeps the demo output clean: it is automatic Python discovery without the warning. Remove it if you want the warning.

$ ansible-inventory --graph
@all:
  |--@ungrouped:
  |--@prod:
  |  |--@web:
  |  |  |--web1
  |  |  |--web2
  |  |--@db:
  |  |  |--db1

The same command with -i inventory/hosts.yml shows the same groups and hosts (the order of groups differs).

group_vars and host_vars

Put variables in files named after a group or a host, in group_vars/ and host_vars/ next to the inventory. Ansible sources them relative to the inventory, and also relative to the playbook directory. If both exist, the ones relative to the playbook win. Inside the inventory, precedence goes from the all group, to a parent group, to a child group, to the host itself.

inventory/group_vars/all.yml
demo_root: "{{ playbook_dir }}/out/{{ inventory_hostname }}"
inventory/group_vars/web.yml
app_port: 8080
inventory/host_vars/web2.yml
app_port: 9090
$ ansible-inventory --host web2
{
    "ansible_connection": "local",
    "app_port": 9090,
    "demo_root": "{{ playbook_dir }}/out/{{ inventory_hostname }}"
}

web2 gets 9090 from its host file, while web1 keeps 8080 from the web group. Note that demo_root is printed as written, not yet rendered.

Your first playbook

A playbook is a YAML list of plays. This one has one play that targets the web group and runs two tasks. gather_facts: false skips the automatic fact-gathering step, which these examples do not need. Modules take a desired state (state: directory), not a list of steps.

first.yml
- name: First playbook
  hosts: web
  gather_facts: false
  tasks:
    - name: Create the app directory
      ansible.builtin.file:
        path: "{{ demo_root }}/app"
        state: directory
        mode: "0755"

    - name: Write a greeting file
      ansible.builtin.copy:
        dest: "{{ demo_root }}/app/hello.txt"
        content: "hello from {{ inventory_hostname }}\n"
        mode: "0644"

Check it before running. --syntax-check parses the playbook without executing it, and --list-tasks prints the tasks that would run.

$ ansible-playbook first.yml --syntax-check
playbook: first.yml
$ ansible-playbook first.yml --limit web1
TASK [Create the app directory] ************************************************
changed: [web1]
TASK [Write a greeting file] ***************************************************
changed: [web1]
PLAY RECAP *********************************************************************
web1 : ok=2 changed=2 ...
$ ansible-playbook first.yml --limit web1
TASK [Create the app directory] ************************************************
ok: [web1]
TASK [Write a greeting file] ***************************************************
ok: [web1]
PLAY RECAP *********************************************************************
web1 : ok=2 changed=0 ...

--limit web1 narrows the run to one host of the play. The second run is the important one: nothing needed to change, so nothing changed.

Idempotency: ok versus changed

Most modules compare the current state with the desired state and act only if they differ. The task then reports ok (already correct) or changed (it modified something). The recap counts them. A healthy, converged system gives changed=0 on a second run. A common practice is to treat that number as a cheap drift check and as a test that a playbook is safe to re-run.

ansible.builtin.command and ansible.builtin.shell know nothing about your desired state. They simply run what you give them. The command documentation offers creates (do not run if the file exists) and removes (run only if the file exists) as guards. Without a guard, the task runs every time. Run this playbook twice:

idem.yml
- name: Idempotency with command and shell
  hosts: web1
  gather_facts: false
  tasks:
    - name: Ensure the data directory exists
      ansible.builtin.file:
        path: "{{ demo_root }}/data"
        state: directory
        mode: "0755"

    - name: Append with shell (breaks idempotency)
      ansible.builtin.shell: echo "max_conn=100" >> {{ demo_root }}/data/naive.conf

    - name: Ensure the setting with lineinfile
      ansible.builtin.lineinfile:
        path: "{{ demo_root }}/data/managed.conf"
        line: "max_conn=100"
        create: true
        mode: "0644"

    - name: One-time init, guarded by creates
      ansible.builtin.shell: echo initialised > {{ demo_root }}/data/init.done
      args:
        creates: "{{ demo_root }}/data/init.done"

    - name: Read-only command, never a change
      ansible.builtin.command: cat {{ demo_root }}/data/init.done
      changed_when: false
$ ansible-playbook idem.yml
TASK [Ensure the data directory exists] ****************************************
changed: [web1]
TASK [Append with shell (breaks idempotency)] **********************************
changed: [web1]
TASK [Ensure the setting with lineinfile] **************************************
changed: [web1]
TASK [One-time init, guarded by creates] ***************************************
changed: [web1]
TASK [Read-only command, never a change] ***************************************
ok: [web1]
PLAY RECAP *********************************************************************
web1 : ok=5 changed=4 ...
$ ansible-playbook idem.yml
TASK [Ensure the data directory exists] ****************************************
ok: [web1]
TASK [Append with shell (breaks idempotency)] **********************************
changed: [web1]
TASK [Ensure the setting with lineinfile] **************************************
ok: [web1]
TASK [One-time init, guarded by creates] ***************************************
ok: [web1]
TASK [Read-only command, never a change] ***************************************
ok: [web1]
PLAY RECAP *********************************************************************
web1 : ok=5 changed=1 ...
$ cat out/web1/data/naive.conf out/web1/data/managed.conf
max_conn=100
max_conn=100
max_conn=100

Read the result task by task:

  • The shell append reported changed on both runs and wrote the line twice. The task describes a step, so it repeats the step.
  • lineinfile describes a state (this line exists in this file), so the second run was ok and the file holds one line.
  • creates makes the init step run once. On the second run it was not executed and reported ok. The parameter is documented for both command and shell.
  • changed_when: false is for read-only commands. It stops a harmless cat from inflating the changed count.

When a command tells you whether it acted, use register and a changed_when condition on its result. This one reports changed only when the script prints created. It gave changed on the first run and ok on the second.

- name: Create a marker only if missing, and say so
  ansible.builtin.shell: |
    test -f "{{ demo_root }}/data/marker" || { touch "{{ demo_root }}/data/marker"; echo created; }
  register: marker
  changed_when: "'created' in marker.stdout"

Prefer the purpose-built module

A module knows how to check state, so you usually get idempotency, accurate changed reporting and check-mode support without extra work. Reach for command or shell only when no module fits, and then add creates, removes or changed_when.

Instead of a shell step likeUse
mkdir -p, chmodansible.builtin.file with state: directory and mode
echo ... >> fileansible.builtin.lineinfile for one line, ansible.builtin.template or ansible.builtin.copy for a whole file
apt install, dnf installansible.builtin.package (package names can differ between distributions)
systemctl startansible.builtin.service with state: started
useraddansible.builtin.user

Not every state is idempotent. For ansible.builtin.service, started and stopped act only when needed, but restarted and reloaded always act. Also, the template documentation warns that a date inside a template makes the task report changed on every run.

Handlers and a minimal role

A handler is a task that runs only when another task notifies it. A task notifies its handlers only when it reports changed. That is how you reload a service after a configuration change, and only then.

  • By default, handlers run after all the tasks of a play section (pre_tasks, then roles and tasks, then post_tasks).
  • A handler notified by several tasks runs once. Handlers must be named so that tasks can notify them.
  • To run pending handlers earlier, use a meta: flush_handlers task.
  • If a later task fails on a host, the handler does not run on that host by default. force_handlers changes that. This was also confirmed with a small test playbook.

A minimal role

A role is a directory with a fixed layout. Ansible loads main.yml from the role’s tasks, handlers, defaults, vars and meta directories automatically. A template task finds its src in the role’s templates directory. Ansible looks for roles in a roles/ directory next to the playbook, among other places.

$ find roles -type f | sort
roles/app_config/defaults/main.yml
roles/app_config/handlers/main.yml
roles/app_config/tasks/main.yml
roles/app_config/templates/app.conf.j2
roles/app_config/tasks/main.yml
- name: Create the config directory
  ansible.builtin.file:
    path: "{{ demo_root }}/etc"
    state: directory
    mode: "0755"

- name: Render app.conf
  ansible.builtin.template:
    src: app.conf.j2
    dest: "{{ demo_root }}/etc/app.conf"
    mode: "0644"
  notify: Reload app
roles/app_config/defaults/main.yml
app_port: 8000
app_log_level: info
roles/app_config/templates/app.conf.j2
# {{ ansible_managed }}
port = {{ app_port }}
log_level = {{ app_log_level }}
roles/app_config/handlers/main.yml
- name: Reload app
  ansible.builtin.debug:
    msg: "Reload app on {{ inventory_hostname }}"
site.yml
- name: Configure web hosts
  hosts: web
  gather_facts: false
  roles:
    - app_config

defaults/main.yml holds very low precedence values: they are the role’s public knobs, and almost any other variable source overrides them. Put values the caller should not override in vars/, which has high precedence. The handler here prints a message because this sandbox has no service to reload. A real role would call ansible.builtin.service.

$ ansible-playbook site.yml
TASK [app_config : Create the config directory] ********************************
changed: [web1]
changed: [web2]
TASK [app_config : Render app.conf] ********************************************
changed: [web1]
changed: [web2]
RUNNING HANDLER [app_config : Reload app] **************************************
ok: [web1] => {
    "msg": "Reload app on web1"
}
ok: [web2] => {
    "msg": "Reload app on web2"
}
PLAY RECAP *********************************************************************
web1 : ok=3 changed=2 ...
web2 : ok=3 changed=2 ...
$ ansible-playbook site.yml
TASK [app_config : Create the config directory] ********************************
ok: [web2]
ok: [web1]
TASK [app_config : Render app.conf] ********************************************
ok: [web2]
ok: [web1]
PLAY RECAP *********************************************************************
web1 : ok=2 changed=0 ...
web2 : ok=2 changed=0 ...
$ cat out/web2/etc/app.conf
# Ansible managed
port = 9090
log_level = info

The first run rendered the file and triggered the handler once per host. The second run changed nothing, so the handler did not run. Port 9090 comes from host_vars, which beat the role default of 8000. Host order in the output can vary from run to run, because hosts run in parallel.

Safe runs: check mode, become and Vault

Preview with --check --diff

--check makes no changes and tries to predict some of the changes that would occur. --diff shows the differences when small files and templates change. Use them together before a risky run. Here the log level is overridden on the command line, and nothing is written:

$ ansible-playbook site.yml --check --diff --limit web1 -e app_log_level=debug
TASK [app_config : Create the config directory] ********************************
ok: [web1]
TASK [app_config : Render app.conf] ********************************************
--- before: .../out/web1/etc/app.conf
+++ after: .../app.conf.j2
@@ -1,3 +1,3 @@
 # Ansible managed
 port = 8080
-log_level = info
+log_level = debug
changed: [web1]
RUNNING HANDLER [app_config : Reload app] **************************************
ok: [web1] => {
    "msg": "Reload app on web1"
}
PLAY RECAP *********************************************************************
web1 : ok=3 changed=1 ...

Check mode has limits. A module that does not support it reports nothing. Tasks with conditionals based on registered variables generate no output. command and shell support it only through creates and removes: in a test run, the changed_when task shown earlier printed skipping under --check. Notified handlers still ran in the check run above, so keep handler logic safe for that. A task can opt in or out with check_mode: true or check_mode: false. Use diff: false on a task whose file contents are sensitive.

Privilege escalation with become

A common practice is to connect as an ordinary user and escalate only where needed. become: true turns escalation on, at play or task level. become_method defaults to sudo and become_user defaults to root. Setting become_user alone does not enable escalation. On the command line, -b enables it and -K asks for the escalation password. This sandbox connects as root with sudo available, so the example below only shows the mechanics. On real hosts you connect as an ordinary user that is allowed to escalate.

become.yml
- name: Configure web hosts
  hosts: web1
  become: true
  gather_facts: false
  tasks:
    - name: Run one task as another user
      ansible.builtin.command: id -un
      become_user: nobody
      register: who
      changed_when: false

    - ansible.builtin.debug:
        var: who.stdout
$ ansible-playbook become.yml
TASK [Run one task as another user] ********************************************
ok: [web1]
TASK [ansible.builtin.debug] ***************************************************
ok: [web1] => {
    "who.stdout": "nobody"
}

Secrets with Vault

Ansible Vault encrypts sensitive data such as passwords, as whole files or single values. ansible-vault encrypt encrypts a file, and you supply the password with --ask-vault-pass, --vault-password-file or --vault-id. Encrypting a whole file also hides the variable names. Never type a secret directly on a command line outside testing, because it stays in your shell history. A common convention is to keep the password file outside the repository.

$ echo 'demo-only-password' > .vault_pass && chmod 600 .vault_pass
$ printf 'db_password: s3cret-demo-value\n' > secrets.yml
$ ansible-vault encrypt --vault-password-file .vault_pass secrets.yml
$ head -n 1 secrets.yml
$ANSIBLE_VAULT;1.1;AES256
$ ansible-playbook p.yml --vault-password-file .vault_pass

A playbook loads the encrypted file like any variables file. This one ran with the password file, and its assert task passed without printing the secret.

p.yml
- hosts: localhost
  connection: local
  gather_facts: false
  vars_files: [secrets.yml]
  tasks:
    - ansible.builtin.assert:
        that: db_password | length > 0
        quiet: true

Cheat sheet and checklist

TaskCommand or keyword
Show groups and hostsansible-inventory --graph
Show merged variables of a hostansible-inventory --host web2
Use another inventory-i inventory/hosts.yml
Parse without runningansible-playbook site.yml --syntax-check
List tasks or target hosts--list-tasks, --list-hosts
Preview changes--check --diff
Run on a subset of hosts--limit web1
Set a variable for one run-e app_log_level=debug
Escalate privilegesbecome: true, or -b, and -K to ask for the password
Supply the Vault password--ask-vault-pass or --vault-password-file FILE
Silence a harmless “changed”changed_when: false
Run a command oncecreates: /path/to/file
Notify a handlernotify: Handler name on the task

Common mistakes

  • Using command or shell where a module exists, so every run reports changed and the recap stops meaning anything.
  • Restarting a service in a normal task with state: restarted instead of notifying a handler. It restarts on every run.
  • Never running the playbook a second time. Make changed=0 on the second run a habit and a CI check.
  • Editing a live file with lineinfile in many small tasks when one template would describe the whole file. The lineinfile documentation says it is primarily for changing a single line, and points to copy or template for other cases.
  • Putting a secret in plain text in group_vars, or a Vault password file in the repository.

Checklist before a production run

  • --syntax-check passes.
  • --check --diff --limit on one host shows only the changes you expect.
  • A second real run reports changed=0.
  • Escalation is limited to the tasks that need it, and secrets are in Vault.

Official documentation

Keep going

benmabrouk.fr: free DevOps and SRE learning resources, written from production experience.

Scroll to Top