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.fileoransible.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.
[web]
web1 ansible_connection=local
web2 ansible_connection=local
[db]
db1 ansible_connection=local
[prod:children]
web
dball:
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.
[defaults]
inventory = inventory/hosts.ini
interpreter_python = auto_silentinterpreter_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:
| | |--db1The 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.
demo_root: "{{ playbook_dir }}/out/{{ inventory_hostname }}"app_port: 8080app_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.
- 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:
- 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=100Read the result task by task:
- The
shellappend reportedchangedon both runs and wrote the line twice. The task describes a step, so it repeats the step. lineinfiledescribes a state (this line exists in this file), so the second run wasokand the file holds one line.createsmakes the init step run once. On the second run it was not executed and reportedok. The parameter is documented for bothcommandandshell.changed_when: falseis for read-only commands. It stops a harmlesscatfrom 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 like | Use |
|---|---|
mkdir -p, chmod | ansible.builtin.file with state: directory and mode |
echo ... >> file | ansible.builtin.lineinfile for one line, ansible.builtin.template or ansible.builtin.copy for a whole file |
apt install, dnf install | ansible.builtin.package (package names can differ between distributions) |
systemctl start | ansible.builtin.service with state: started |
useradd | ansible.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, thenrolesandtasks, thenpost_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_handlerstask. - If a later task fails on a host, the handler does not run on that host by default.
force_handlerschanges 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- 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 appapp_port: 8000
app_log_level: info# {{ ansible_managed }}
port = {{ app_port }}
log_level = {{ app_log_level }}- name: Reload app
ansible.builtin.debug:
msg: "Reload app on {{ inventory_hostname }}"- name: Configure web hosts
hosts: web
gather_facts: false
roles:
- app_configdefaults/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 = infoThe 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.
- 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_passA 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.
- hosts: localhost
connection: local
gather_facts: false
vars_files: [secrets.yml]
tasks:
- ansible.builtin.assert:
that: db_password | length > 0
quiet: trueCheat sheet and checklist
| Task | Command or keyword |
|---|---|
| Show groups and hosts | ansible-inventory --graph |
| Show merged variables of a host | ansible-inventory --host web2 |
| Use another inventory | -i inventory/hosts.yml |
| Parse without running | ansible-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 privileges | become: 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 once | creates: /path/to/file |
| Notify a handler | notify: Handler name on the task |
Common mistakes
- Using
commandorshellwhere a module exists, so every run reportschangedand the recap stops meaning anything. - Restarting a service in a normal task with
state: restartedinstead of notifying a handler. It restarts on every run. - Never running the playbook a second time. Make
changed=0on the second run a habit and a CI check. - Editing a live file with
lineinfilein many small tasks when one template would describe the whole file. Thelineinfiledocumentation says it is primarily for changing a single line, and points tocopyortemplatefor 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-checkpasses.--check --diff --limiton 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
- Introduction to Ansible
- Installing Ansible (node requirements)
- How to build your inventory
- Ansible playbooks
- Handlers: running operations on change
- Error handling in playbooks (handlers and failure, changed_when)
- Roles
- Validating tasks: check mode and diff mode
- Understanding privilege escalation: become
- Protecting sensitive data with Ansible vault
- ansible.builtin.command module
- ansible.builtin.shell module
- ansible.builtin.lineinfile module
- ansible.builtin.service module
- ansible.builtin.package module
- ansible.builtin.template module
- Ansible configuration settings
- ansible-playbook command line reference
- ansible-inventory command line reference
Keep going
benmabrouk.fr: free DevOps and SRE learning resources, written from production experience.