Skip to content

16. Cheat sheet

One page. Everything from the fifteen chapters before it.

INVENTORY which machines exist, and their groups
↓
PLAYBOOK a list of PLAYS
↓
PLAY hosts + tasks + handlers
↓
TASK a MODULE plus arguments
↓
MODULE copied to the target, run, deleted.
Prints ONE LINE OF JSON.
Idempotence is ITS job, not Ansible's.
Terminal window
ansible-playbook site.yml
ansible-playbook site.yml -i inventory/production
ansible-playbook site.yml --limit web1 # SAFETY FLAG
ansible-playbook site.yml --check --diff # dry run
ansible-playbook site.yml --tags nginx
ansible-playbook site.yml --skip-tags packages
ansible-playbook site.yml --start-at-task "Deploy config"
ansible-playbook site.yml --step
ansible-playbook site.yml --list-hosts --list-tasks --list-tags
ansible-playbook site.yml --syntax-check
ansible-playbook site.yml -e "version=2.4.0" # always wins
ansible-playbook site.yml -f 50 -vv
ansible-playbook site.yml --force-handlers
ansible-playbook site.yml --flush-cache
ansible all -m ansible.builtin.ping
ansible all -m ansible.builtin.shell -a "df -h /" -o
ansible web1 -m ansible.builtin.setup
ansible-inventory --graph
ansible-inventory --host web1
ansible-config dump --only-changed
ansible-doc ansible.builtin.copy
ansible-galaxy collection install -r requirements.yml
ansible-lint
ansible-vault edit group_vars/production/vault.yml
0 success
1 an error
2 one or more hosts FAILED
4 one or more hosts UNREACHABLE
99 interrupted

2 and 4 mean different things. Treat them differently in CI.

all / * everything
webservers a group
webservers:databases union (OR)
webservers:&london intersection (AND)
webservers:!web3 exclusion
web*.example.com glob
~web\d+ regex
webservers[0] first host — the canary
webservers[0:2] a slice
1. -e ALWAYS wins (level 22)
2. role defaults are WEAKEST (level 2)
role vars are near-STRONGEST (level 15)
3. more specific beats more general
(host_vars > child group > parent > all)
4. among siblings: later alphabetically wins
5. set_fact beats almost everything (level 19)
→ defaults/ is the role's API.
vars/ is its private state.
- hosts: webservers
become: true
gather_facts: true
serial: [1, 5, '25%'] # canary → batches
max_fail_percentage: 0 # per BATCH, not per play
any_errors_fatal: false
strategy: linear # free | host_pinned
order: inventory # sorted | shuffle | ...
force_handlers: true
environment: {http_proxy: "..."}
vars_files: [vars/main.yml]

Run order: facts → pre_tasks → handlers → roles → tasks → post_tasks → handlers. Roles run before tasks regardless of file order.

- name: Ensure X is present # ALWAYS write a name
ansible.builtin.module: # ALWAYS use the FQCN
arg: value
become: true
when: condition # bare — no {{ }}
loop: "{{ items }}"
loop_control: {label: "{{ item.name }}"}
register: result
changed_when: false
failed_when: result.rc not in [0, 2]
check_mode: false
no_log: true
notify: Handler Name
tags: [x]
until: result.status == 200
retries: 30
delay: 5
delegate_to: localhost
run_once: true # per BATCH, not per play
throttle: 2
async: 3600
poll: 0
1. notification requires `changed`
2. they run at the END of the play
3. in DEFINITION order, not notify order
4. NOT AT ALL if the host failed
→ --force-handlers / force_handlers: true
5. per play — notifications do not cross plays
meta: flush_handlers runs them now
listen: "name" many handlers, one notification
command / shell ALWAYS report changed. Fix, in order:
1. use the real module ← nearly always possible
2. creates: / removes: ← skip if path exists
3. changed_when: false ← for read-only commands
ignore_errors = "failure does not matter" (rarely true)
failed_when = "here is what failure means"

Test it: run twice, expect changed=0.

import_* include_*
──────────────────────────────────────────────────
when processed parse time run time
variable filename NO YES
in a loop NO YES
`when:` applies to every task in the include only
the file
tags apply to every task in the include only
the file → need `apply:`
--list-tasks shows yes no
→ import by default. include for runtime behaviour.
{{ x | default('y') }} undefined → y
{{ x | default('y', true) }} undefined OR empty → y
{{ x | mandatory }} fail loudly if undefined
{{ x | default(omit) }} drop the argument entirely
{{ x | bool }} "false" string → False
{{ a | combine(b, recursive=true) }}
{{ l | selectattr('k','equalto','v') | map(attribute='n') | list }}
{{ d | to_nice_yaml(indent=2) }}
{{ s | quote }} shell-safe
when: x is defined / is not defined / is skipped / is failed
when: v is version('2.4', '>=') ← the ONLY correct
version comparison
{# at the top of a template #}
#jinja2: trim_blocks: True, lstrip_blocks: True

Always finish map/select with | list — they return generators. Lookups run on the CONTROL node, never the target.

[defaults]
forks = 50 # from 5 — biggest single win
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_fact_cache
fact_caching_timeout = 7200
stdout_callback = ansible.builtin.yaml
callbacks_enabled = ansible.posix.profile_tasks
interpreter_python = auto_silent
roles_path = ./roles:./galaxy_roles
[ssh_connection]
pipelining = true # 30-50%, one setting
ssh_args = -C -o ControlMaster=auto -o ControlPersist=600s \
-o PreferredAuthentications=publickey

Also: pass lists to package/apt/pip instead of looping.

ansible.cfg order — first found wins entirely, no merging: $ANSIBLE_CONFIG → ./ansible.cfg → ~/.ansible.cfg → /etc/ansible/ansible.cfg A world-writable directory is skipped silently.

ansible.cfg
requirements.yml ← pin versions, commit it
inventory/
production/{hosts.yml,group_vars/,host_vars/}
staging/{hosts.yml,group_vars/}
roles/ ← yours
galaxy_roles/ ← installed, GITIGNORED
playbooks/site.yml
group_vars/production/
main.yml db_password: "{{ vault_db_password }}"
vault.yml vault_db_password: ... ← encrypted

One inventory directory per environment makes the wrong-environment mistake impossible rather than merely discouraged.

Terminal window
ansible-vault create / edit / view / rekey file.yml
ansible-vault encrypt_string 'x' --name 'vault_x'
ansible-playbook site.yml --vault-id prod@~/.vault_prod
✓ prefix vault variables with vault_
✓ reference indirectly from a plain file
✓ vault IDs per environment
✓ no_log: true on anything touching a secret
✓ loop_control: label on loops over credentials
✓ mode: '0600' on rendered files with secrets
✗ NEVER ansible-vault decrypt — it writes plaintext
in place, under the same name
✗ never a secret on a command line (process list)
✗ never commit .vault_pass
succeeded, did nothing
→ host pattern matched nothing. Exit code 0.
ansible-inventory --graph
variable will not change
→ it is in role vars/ not defaults/ (or set_fact,
or -e, or a group_vars file named for a group
that does not exist)
handler never runs
→ the task reported `ok`, not `changed` (80%)
or the host failed → --force-handlers
second run reports changes
→ command without changed_when, self-matching
lineinfile regex, or mode: 0644 UNQUOTED
"dict object has no attribute stdout"
→ the task was SKIPPED. `is not skipped`
or check_mode: false
`when` is always true
→ it is the STRING "false". Use | bool
tags run too much / too little
→ tags do not reach inside include_*. Use apply:
--list-tasks --tags to check
Failed to connect via ssh
→ -vvvv, find "SSH: EXEC", run that command by hand
/usr/bin/python not found
→ bootstrap with `raw`, then pin
ansible_python_interpreter
sudo: a password is required
→ -K, or passwordless sudo. And ALWAYS
validate: 'visudo -cf %s' on sudoers files
twenty minutes for nothing
→ forks=5, pipelining off, uncached facts, or a
loop that should be a list. profile_tasks.
Terminal window
1. --list-hosts --list-tasks what will run?
2. --check --diff --limit web1 what would change?
3. ansible-inventory --host web1 what does it believe?
4. -vvv --limit web1 what is it doing?
5. --step one task at a time
6. --start-at-task "..." resume from the break
7. --limit @site.retry re-run only failures
1. Always name your tasks. Phrase as desired state.
2. Always write the FQCN.
3. changed_when: false on every read-only command.
4. Use the real module before command; creates: before
changed_when.
5. Quote file modes: '0644', never 0644.
6. defaults/ for anything a caller might change.
Never vars/.
7. Prefix role variables with the role name.
8. Never begin a variable with ansible_.
9. | bool on every boolean from outside a YAML file.
10. | list after every map or select.
11. Pass lists to modules; do not loop them.
12. loop_control: label on any loop over dictionaries.
13. import_* by default; include_* only for runtime needs.
14. validate: on template/copy. Mandatory for sudoers.
15. no_log: true on anything touching a secret.
16. --limit on every ad-hoc command. There is no --check.
17. force_handlers where a half-applied config is worse
than a failure.
18. Assert preconditions at the top of a role, tags: always.
19. tags: [never] on anything destructive.
20. Run twice. The second run must report changed=0.

A script describes an action; a playbook describes a destination. Every failure of the for-loop-over-SSH approach comes from that one distinction, and every feature in Ansible follows from choosing the second.

Idempotence belongs to the module, not to Ansible. package checks before it acts; command cannot. Almost all bad Ansible is command doing something a real module already does, and the cost is not slowness — it is that changed stops meaning anything.

Convergence is not reproducibility. Ansible reduces drift; only rebuilding eliminates it. If you can throw the machine away, do that — and use Ansible to build the image. What is left over is machines that must persist, the network they sit on, and every place a container platform is not on offer. That is still a great deal of the world.


That is Ansible, and the twenty-fourth guide on this site.

Written by Sepuri Sai Krishna

© 2026CC BY 4.0Source