Ansible
Write idempotent Ansible playbooks, resolve inventory and variable precedence, dry-run with check mode, and debug plays that fail or change the wrong thing.
On this page
Cheatsheet#
| Task | Command |
|---|---|
| Dry run with file diffs | ansible-playbook site.yml --check --diff |
| Limit to one host or group | ansible-playbook site.yml --limit web-01 |
| Syntax check only | ansible-playbook site.yml --syntax-check |
| Hosts the play would target | ansible-playbook site.yml --list-hosts |
| Tasks and tags the play would run | ansible-playbook site.yml --list-tasks |
| Start at a named task | ansible-playbook site.yml --start-at-task='Install nginx' |
| Confirm each task interactively | ansible-playbook site.yml --step |
| Ad-hoc command | ansible web -m ansible.builtin.command -a 'uptime' |
| Connectivity and Python check | ansible all -m ansible.builtin.ping |
| Selected facts | ansible web -m ansible.builtin.setup -a 'filter=ansible_distribution*' |
| Connection debugging | ansible-playbook site.yml -vvvv |
| Extra variables | ansible-playbook site.yml -e env=prod -e @vars.yml |
| Inventory as Ansible resolves it | ansible-inventory --list --yaml |
| Group tree | ansible-inventory --graph |
| Edit an encrypted file | ansible-vault edit group_vars/prod/vault.yml |
| Install collections | ansible-galaxy collection install -r requirements.yml |
| Lint | ansible-lint |
Versions: ansible-core 2.21 (May 2026) and 2.20 are current; 2.19 is supported until November 2026. ansible-core 2.20 and later need Python 3.12 or newer on the control node. The ansible package bundles ansible-core with a curated set of collections. See release and maintenance.
How a play runs#
Ansible connects to each host over SSH (or another connection plugin), copies a Python module to a temporary directory, runs it with the target’s Python and reads a JSON result. Nothing needs to be installed on the target except Python and, for become, sudo or an equivalent.
By default the linear strategy runs each task on every host (up to forks, default 5) before moving to the next task. A host that fails is removed from the rest of the play. Modules report ok (already in the desired state), changed, failed or skipped, and idempotent modules make a second run report only ok.
Dry run first#
--check asks each module to predict whether it would change anything, without changing it. --diff prints the file content differences. Use both before any run against shared hosts.
ansible-playbook site.yml --check --diff --limit staging
ansible-playbook site.yml --check --diff --tags nginxcommand and shell cannot predict their effect, so they are skipped in check mode unless creates or removes decides the outcome. A task that only reads state can set check_mode: false to run anyway. Check mode is a prediction: a task that depends on a file or package an earlier skipped task would have created can fail or report differently than the real run.
Inventory#
# inventory/prod.ini
[web]
web-[01:04].example.com
[db]
db-01.example.com ansible_host=192.0.2.11
[prod:children]
web
db
[prod:vars]
ansible_user=deploy# inventory/prod.yml: the YAML form, clearer for nested group vars
all:
children:
web:
hosts:
web-01.example.com:
web-02.example.com:
vars:
nginx_workers: 4ansible-inventory -i inventory/prod.yml --graph
ansible-inventory -i inventory/prod.yml --host web-01.example.com # merged variables for one host
ansible all -i inventory/prod.yml -m ansible.builtin.ping --limit webgroup_vars/ and host_vars/ directories next to the inventory file or the playbook are loaded automatically. Dynamic inventory plugins (amazon.aws.aws_ec2, kubernetes.core.k8s, azure.azcollection.azure_rm) replace static files for cloud fleets; the config file name must end with the plugin’s suffix, such as prod.aws_ec2.yml.
ansible_python_interpreter defaults to auto, which discovers the platform Python. Set it only when discovery picks the wrong one.
Dynamic inventory#
An inventory plugin queries an API at run time and builds hosts and groups from the result. The file is a plugin configuration, not a host list, and the plugin must be enabled (amazon.aws.aws_ec2 and the other common ones are in the default enable_plugins list; check with ansible-config dump | grep INVENTORY_ENABLED).
# inventory/prod.aws_ec2.yml: file name must end in .aws_ec2.yml or .aws_ec2.yaml
plugin: amazon.aws.aws_ec2
regions: [ap-southeast-2]
filters:
instance-state-name: running
tag:Environment: prod
hostnames: [tag:Name, private-ip-address] # first that exists becomes inventory_hostname
compose:
ansible_host: private_ip_address # connect on the private address
ansible_user: "'ec2-user'" # compose values are Jinja; quote literals twice
keyed_groups:
- key: tags.Role # group "role_web" from tag Role=web
prefix: role
- key: placement.availability_zone
prefix: az
groups:
db: "'db' in tags.Role" # conditional group from an expression
cache: true # reuse results between runs
cache_plugin: ansible.builtin.jsonfile
cache_connection: ~/.cache/ansible-inventory
cache_timeout: 600ansible-inventory -i inventory/prod.aws_ec2.yml --graph proves the plugin works before a play depends on it; --list --yaml shows every host variable the plugin exposed. The ansible.builtin.constructed plugin adds compose, groups and keyed_groups on top of any other inventory, so a static file can gain fact-derived groups. Several inventory sources combine when -i is a directory or repeated: hosts merge and later sources override earlier variables. Dynamic sources that fail return no hosts rather than an error unless any_unparsed_is_failed = True is set in ansible.cfg, which is the setting that stops an expired cloud token from turning --limit prod into an empty run.
Variable precedence#
Later in the list wins. The documented order has 22 levels; these are the ones that usually matter, lowest first:
| Source | Notes |
|---|---|
Role defaults (defaults/main.yml) | Lowest; intended to be overridden |
| Inventory file group vars | [group:vars] or vars: in the inventory |
group_vars/all, then group_vars/<group> | Inventory directory first, then playbook directory; a child group beats its parent |
Inventory host vars, then host_vars/<host> | Beat every group variable |
Facts and cached set_fact | |
Play vars, vars_prompt, vars_files | |
Role vars (vars/main.yml) | Beat play vars; hard to override from inventory |
| Block vars, then task vars | Scoped to the block or task |
include_vars | |
set_fact and register | |
Role and include_role params | |
Extra vars (-e) | Always win |
Put tunables in defaults/, environment values in group_vars/, and reserve role vars/ for constants the role’s own tasks depend on. See variable precedence.
Playbook structure and rolling updates#
- name: Configure web tier
hosts: web
become: true
serial: "25%" # a quarter of the group at a time
max_fail_percentage: 0 # any failure in a batch stops the play
pre_tasks:
- name: Drain from the load balancer
ansible.builtin.uri:
url: "https://lb.example.com/drain/{{ inventory_hostname }}"
method: POST
delegate_to: localhost
roles:
- role: nginx
tags: [nginx]
tasks:
- name: Deploy configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
mode: "0644"
validate: nginx -t -c %s # refuse to install a config that fails its own check
notify: Reload nginx
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloadedserial with max_fail_percentage: 0 limits the damage of a bad change: the first failing batch stops the play before it reaches the rest of the fleet.
Handlers run only when a task that notified them reported changed, once per play section, after pre_tasks, after roles and tasks, and after post_tasks. ansible.builtin.meta: flush_handlers runs pending handlers immediately when a later task depends on the reload. If a later task fails, pending handlers are lost unless force_handlers: true is set, so the next run finds the config already in place and never reloads.
Handlers can subscribe to a topic with listen, so several tasks notify one name and several handlers react without the tasks knowing which. A handler runs once per play no matter how many tasks notified it, and handlers in one role can be notified from another role by name, which is how a shared restart nginx in a base role gets triggered from an application role.
handlers:
- name: Reload nginx
ansible.builtin.service: { name: nginx, state: reloaded }
listen: nginx config changed
- name: Purge nginx cache
ansible.builtin.file: { path: /var/cache/nginx, state: absent }
listen: nginx config changed
tasks:
- name: Install site
ansible.builtin.template: { src: site.conf.j2, dest: /etc/nginx/conf.d/site.conf }
notify: nginx config changedRoles#
A role is a directory whose subdirectories are loaded by convention; only tasks/main.yml is required. ansible-galaxy role init my_role scaffolds the tree.
roles/nginx/
defaults/main.yml # tunables, lowest precedence
vars/main.yml # constants the role relies on; hard to override
tasks/main.yml # entry point; include_tasks/import_tasks for the rest
handlers/main.yml
templates/ # referenced by bare name from template:
files/ # referenced by bare name from copy:
meta/main.yml # dependencies, platforms, galaxy_info
meta/argument_specs.yml # validated role parameters (2.11+)
molecule/default/ # tests
README.mdargument_specs.yml turns undocumented variables into a validated interface: a role invoked with a missing required variable or a wrong type fails before any task runs, with a message naming the variable.
# roles/nginx/meta/argument_specs.yml
argument_specs:
main:
short_description: Install and configure nginx
options:
nginx_workers:
type: int
default: 4
description: Worker processes
nginx_sites:
type: list
elements: dict
required: true
options:
name: { type: str, required: true }
port: { type: int, default: 80 }import_role is static: tasks are inlined at parse time, so --list-tasks, tags and --start-at-task see them, and a when on the import applies to every task inside. include_role is dynamic: resolved at run time, so it can loop and take a variable role name, but tags on the include do not propagate to the tasks inside unless apply: { tags: [...] } is used. meta/main.yml dependencies run before the role and are deduplicated per play unless allow_duplicates: true. Task files are found by name in tasks/, templates in templates/, files in files/, and a role’s vars/ and defaults/ are visible to every play that uses it once loaded.
ansible-galaxy role init roles/nginx # scaffold
ansible-doc -t role -l # roles with argument_specs on the roles path
ansible-doc -t role roles/nginx # render the spec as documentation
ansible-galaxy collection install -r requirements.yml -p collections/ # vendor collections into the projectWriting idempotent tasks#
Use the module that describes the desired state, not the command that changes it. Use fully qualified collection names (ansible.builtin.copy), which ansible-lint enforces.
- name: Package present
ansible.builtin.package:
name: nginx
state: present
- name: Line in a config file
ansible.builtin.lineinfile:
path: /etc/security/limits.conf
regexp: '^\*\s+soft\s+nofile'
line: '* soft nofile 65535'
- name: Run a migration exactly once
ansible.builtin.command: /opt/my-app/bin/migrate
args:
creates: /var/lib/my-app/.migrated # skipped when this path exists
- name: Script with an explicit change test
ansible.builtin.command: /usr/local/bin/sync-data
register: sync
changed_when: "'updated' in sync.stdout"
failed_when: sync.rc not in [0, 2]| Keyword | Effect |
|---|---|
creates / removes | Skip the command when a path exists / does not exist |
changed_when | Define what counts as a change (false for read-only commands) |
failed_when | Define what counts as a failure |
check_mode: false | Run even under --check (read-only tasks only) |
run_once: true | Run on the first host in the batch; the result applies to all |
delegate_to | Run the task on another host (load balancer, localhost) |
until / retries / delay | Poll until a condition holds |
throttle | Cap concurrency for this task, for rate-limited APIs |
no_log: true | Hide arguments and results, for tasks that handle secrets |
Prefer ansible.builtin.command over shell unless pipes, redirects or globbing are needed; shell passes arguments through /bin/sh and invites quoting and injection bugs.
Templates, facts and conditionals#
{# nginx.conf.j2 #}
worker_processes {{ ansible_facts['processor_vcpus'] }};
upstream app {
{% for host in groups['app'] %}
server {{ hostvars[host]['ansible_facts']['default_ipv4']['address'] }}:8080;
{% endfor %}
}Reading hostvars for another host’s facts requires that facts for that host were gathered in this run or a fact cache.
ansible web-01 -m ansible.builtin.setup | less # every fact
ansible web-01 -m ansible.builtin.setup -a 'filter=ansible_mounts'- name: Show a value at the point of use
ansible.builtin.debug:
var: nginx_workers
verbosity: 1 # printed only with -v or moreFact gathering costs a round trip and a module run per host. On large fleets use gather_facts: false and call ansible.builtin.setup with gather_subset where facts are needed, or enable a fact cache.
Filters and tests#
Filters transform a value (value | filter), tests return a boolean (value is test). Jinja2 built-ins are available plus Ansible’s own; ansible-doc -t filter -l and ansible-doc -t test -l list what is installed, and ansible-doc -t filter ansible.builtin.combine documents one.
- name: Filters that come up in every playbook
ansible.builtin.debug:
msg:
- "{{ nginx_port | default(80) }}" # default when undefined (add true to also cover empty/false)
- "{{ my_dict | combine({'extra': 1}, recursive=true) }}" # merge dicts
- "{{ groups['web'] | map('extract', hostvars, ['ansible_facts', 'default_ipv4', 'address']) | list }}" # facts across hosts
- "{{ services | selectattr('enabled', 'equalto', true) | map(attribute='name') | list }}"
- "{{ items | rejectattr('state', 'defined') | list }}"
- "{{ users | dict2items | selectattr('value.shell', 'search', 'bash') | items2dict }}"
- "{{ '192.0.2.10/24' | ansible.utils.ipaddr('address') }}" # ansible.utils collection
- "{{ lookup('ansible.builtin.env', 'HOME') }}" # lookups run on the control node
- "{{ '/etc/nginx/nginx.conf' | basename }} {{ path | dirname }} {{ path | realpath }}"
- "{{ text | regex_search('version (\\d+\\.\\d+)', '\\1') | first }}"
- "{{ data | to_nice_yaml(indent=2) }} {{ data | to_json }} {{ raw | from_yaml }}"
- "{{ secret | password_hash('sha512', 65534 | random(seed=inventory_hostname) | string) }}" # stable salt per host
- "{{ 'abc' | b64encode }} {{ list_a | difference(list_b) }} {{ list_a | intersect(list_b) | unique }}"
- "{{ ansible_date_time.epoch | int | strftime('%Y-%m-%d') }}"
- "{{ my_var | type_debug }}" # str, int, list, dict, AnsibleUnsafeText- name: Tests in conditionals
ansible.builtin.assert:
that:
- result is succeeded # also failed, changed, skipped
- job is finished # async job
- version is version('2.19', '>=') # semantic comparison, not string
- path is file # exists on the control node; also directory, exists, link
- my_var is defined and my_var is not none
- hostname is match('^web-\d+$') # match anchors at start; search does not; regex takes flags
- value is truthy # 'yes', 1, [x]; falsy for '', 0, []
- item is subset(['a', 'b', 'c']) # also superset, contains
- "'web' in group_names"
fail_msg: "Preconditions failed on {{ inventory_hostname }}"
quiet: trueA filter plugin is a Python file in filter_plugins/ next to the playbook (or plugins/filter/ in a collection) exposing a FilterModule class whose filters() returns a dict of name to callable; tests use TestModule and tests(). Reach for one when a Jinja expression stops being readable.
Async and long-running tasks#
A task longer than the SSH or command_timeout budget (default 10 seconds for the connection, none for the module) should run asynchronously. async is the seconds the job may run, poll how often Ansible checks; poll: 0 returns immediately and hands you a job id.
- name: Start a long upgrade and move on
ansible.builtin.command: /opt/my-app/bin/upgrade --all
async: 3600 # kill the job after an hour
poll: 0 # do not wait
register: upgrade_job
- name: Do other work while it runs
ansible.builtin.include_tasks: prepare.yml
- name: Wait for the upgrade to finish
ansible.builtin.async_status:
jid: "{{ upgrade_job.ansible_job_id }}"
register: upgrade_result
until: upgrade_result is finished
retries: 360 # retries x delay must cover the expected runtime
delay: 10
- name: Remove the job status file
ansible.builtin.async_status:
jid: "{{ upgrade_job.ansible_job_id }}"
mode: cleanup
- name: Run on every host at once, each with a 30 minute budget, waiting in-line
ansible.builtin.command: /usr/bin/dnf -y upgrade
async: 1800
poll: 15 # Ansible reconnects every 15 s; the SSH session can drop in betweenWith poll: 0 the status file under ~/.ansible_async/ on the target is not removed automatically; mode: cleanup does that. Async does not lift serial: a fire-and-forget task on a serial: 1 play still runs host by host. Avoid poll: 0 for package managers that hold a lock, because the next task on the same host will fight it. async with poll greater than zero is also the fix for a command that outlives an SSH connection or a become timeout, since each poll opens a fresh connection.
ansible-core 2.19 templating changes
Since 2.19, conditionals (when, changed_when, failed_when, until) must evaluate to a boolean. when: my_list or when: "{{ x }}" that relied on truthiness now fails. Write when: my_list | length > 0. Only templates from trusted sources (playbooks, roles, vars files) are rendered; strings from module results or external data are no longer templated. ALLOW_BROKEN_CONDITIONALS downgrades the error to a warning during migration. See the 2.19 porting guide.
Secrets with Ansible Vault#
ansible-vault create group_vars/prod/vault.yml
ansible-vault edit group_vars/prod/vault.yml
ansible-vault encrypt group_vars/prod/vault.yml # encrypt an existing plaintext file in place
ansible-vault encrypt_string --stdin-name db_password # prompts on stdin; keeps the value out of shell history
ansible-playbook site.yml --vault-password-file ~/.config/ansible/vault-passA common layout keeps encrypted values in vault.yml as vault_db_password and references them from a plaintext vars.yml (db_password: "{{ vault_db_password }}"), so grep still finds where a variable is defined.
Decrypted values live in memory during the run and can leak through debug, -vvv output and failed-task results. Set no_log: true on tasks that handle them. For secrets fetched at runtime rather than stored in the repository, see Vault.
Vault IDs label secrets so one run can use several passwords, and a password script keeps the password out of files entirely:
ansible-vault encrypt --vault-id prod@prompt group_vars/prod/vault.yml # header records the id: $ANSIBLE_VAULT;1.2;AES256;prod
ansible-vault encrypt --vault-id dev@~/.config/ansible/dev-pass group_vars/dev/vault.yml
ansible-playbook site.yml --vault-id prod@prompt --vault-id dev@~/.config/ansible/dev-pass
ansible-playbook site.yml --vault-id prod@./get-vault-pass.sh # executable: prints the password on stdout
ansible-vault rekey --vault-id prod@prompt --new-vault-id prod@prompt group_vars/prod/vault.yml # rotate the password
ansible-vault view group_vars/prod/vault.yml # decrypt to stdout without editing
ansible-vault decrypt --output - group_vars/prod/vault.yml | yq '.vault_db_password' # one value, nothing written to diskvault_identity_list in ansible.cfg supplies the ids automatically. Without vault_id_match = True, Ansible tries every supplied password against every vaulted value, which works but hides which id a file actually needs. Encrypted strings inside otherwise plain YAML (!vault | blocks from encrypt_string) keep diffs reviewable because only the changed value is opaque, but they cannot be rekeyed in place; re-encrypt the value and paste it.
Configuration and performance#
# ansible.cfg in the project directory
[defaults]
inventory = inventory/prod.yml
forks = 25
host_key_checking = True
callback_result_format = yaml
callbacks_enabled = ansible.posix.profile_tasks
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60sansible.cfg is read from ANSIBLE_CONFIG, then ./ansible.cfg, then ~/.ansible.cfg, then /etc/ansible/ansible.cfg; the first found is used and the others are ignored. Ansible ignores ./ansible.cfg in a world-writable directory. ansible-config dump --only-changed shows the effective non-default settings.
pipelining = True sends the module over the existing SSH session instead of copying a file per task, which is the largest single speed-up. It requires requiretty to be disabled in sudoers, which is the default on current distributions.
Note
The community.general.yaml callback (stdout_callback = yaml) was removed in community.general 12.0.0. Use callback_result_format = yaml with the default callback, available since ansible-core 2.13.
Testing with Molecule#
Molecule creates disposable instances, applies the role, applies it again to prove idempotence, runs a verifier and destroys the instances. Since Molecule 6 there is no molecule init role; scaffold the role with ansible-galaxy role init and add a scenario with molecule init scenario (creates molecule/default/). Drivers other than the built-in default (delegated) come from molecule-plugins; for Podman install molecule-plugins[podman] and the containers.podman collection.
# roles/nginx/molecule/default/molecule.yml
dependency:
name: galaxy
options: { requirements-file: requirements.yml }
driver:
name: podman
platforms:
- name: fedora
image: registry.fedoraproject.org/fedora:44
command: /sbin/init # systemd inside the container so service: works
privileged: true
pre_build_image: true
- name: rhel9
image: registry.access.redhat.com/ubi9/ubi-init
pre_build_image: true
provisioner:
name: ansible
inventory:
group_vars:
all: { nginx_workers: 2 }
config_options:
defaults: { callback_result_format: yaml }
verifier:
name: ansible # runs verify.yml; testinfra is the alternative
scenario:
test_sequence: [dependency, syntax, create, converge, idempotence, verify, destroy]# molecule/default/converge.yml
- name: Converge
hosts: all
become: true
roles: [{ role: nginx }]
---
# molecule/default/verify.yml
- name: Verify
hosts: all
gather_facts: false
tasks:
- name: nginx answers
ansible.builtin.uri: { url: http://localhost/, status_code: 200 }molecule test # whole sequence, destroys at the end even on failure
molecule create && molecule converge # iterate: converge re-applies to the running instance
molecule verify
molecule login -h fedora # shell into an instance
molecule idempotence # fails if a second converge reports changed
molecule destroy
molecule test -s ha # another scenario in molecule/ha/
molecule listThe idempotence step is the one that finds real bugs: a command without a guard, a template with a timestamp, or state: latest on a package each make the second run changed. Container instances lack a real init and kernel, so tasks touching sysctl, mounts, SELinux booleans or firewalld need when: ansible_virtualization_type != 'podman' or a VM driver.
ansible-lint#
ansible-lint checks playbooks, roles and collections against rules grouped into profiles of increasing strictness: min, basic, moderate, safety, shared, production. It also runs yamllint, and understands roles well enough to resolve variables and FQCNs.
# .ansible-lint at the project root
profile: production
exclude_paths: [.cache/, .github/, collections/]
skip_list:
- package-latest # state: latest is deliberate in the patching role
warn_list:
- experimental
- yaml[line-length]
enable_list:
- no-log-password # opt-in rules
- no-same-owner
offline: true # do not install requirements or refresh schemas in CIansible-lint # whole project, profile from .ansible-lint
ansible-lint --profile safety roles/nginx # one path at a stricter or looser profile
ansible-lint --fix # apply autofixes (FQCN, key order, yaml formatting); review the diff
ansible-lint -L # list rules and their tags
ansible-lint --offline --parseable # machine-readable path:line: [rule] message, for CI annotations
ansible-lint --generate-ignore # write .ansible-lint-ignore with every current violation, then burn it downRules that fail most first runs: fqcn[action-core] (use ansible.builtin.copy), name[missing] and name[casing] (every task named, starting with a capital), yaml[truthy] (yes/no must be true/false), no-changed-when (command without changed_when), risky-file-permissions (copy/template without mode), and var-naming[no-role-prefix] (role variables must start with the role name). # noqa: rule-id on a task line silences one occurrence; prefer fixing the task.
Troubleshooting#
| Symptom | Cause | Check or fix |
|---|---|---|
UNREACHABLE | SSH, not Ansible | ssh -v deploy@web-01.example.com; check ansible_user, key and host key |
Missing sudo password or Timeout waiting for privilege escalation prompt | become needs a password | --ask-become-pass, or fix sudoers |
MODULE FAILURE with a Python traceback or /usr/bin/python: not found | Missing or unsupported Python on the target | ansible host -m ansible.builtin.raw -a 'command -v python3'; set ansible_python_interpreter |
couldn't resolve module/action | Collection not installed, or a short name that no longer resolves | ansible-galaxy collection list; use the FQCN |
Conditional result ... was derived from value of type 'str' | 2.19+ requires boolean conditionals | Rewrite the expression to return true/false |
Task always reports changed | command/shell without creates or changed_when | Add a guard |
| Variable has the wrong value | Precedence | ansible-inventory --host <host>, debug: var= at the point of use |
| Works ad hoc, fails in the play | Different become, user, environment or interpreter | Compare with -vvv |
| Handler never runs | The notifying task reported ok, or a later failure dropped pending handlers | --force-handlers or force_handlers: true |
| Slow runs | Fact gathering, pipelining off, low forks | profile_tasks callback, the settings above |
| Dynamic inventory returns no hosts, play “succeeds” | Expired cloud credentials or wrong plugin file suffix | ansible-inventory -i inventory/prod.aws_ec2.yml --list -vvv; set any_unparsed_is_failed = True |
Failed to import the required Python library on the controller | Collection module needs a library (boto3, kubernetes) in the control node’s venv | python3 -c 'import boto3' in the same interpreter as ansible --version |
ansible_job_id undefined after an async task | Task skipped, or async without poll: 0 returned the final result instead of a job | Check is skipped; with poll > 0 the register holds the result, not a job |
Async task fails with could not find job | async timeout shorter than the job, or ~/.ansible_async cleaned | Raise async; check ansible_remote_tmp and home directory permissions |
Role argument validation failure | Variable missing or wrong type against meta/argument_specs.yml | The message names the option; ansible-doc -t role roles/<name> shows the spec |
Molecule converge works, idempotence fails | A task reports changed on the second run | molecule converge twice with --diff, look for command, state: latest, templates with timestamps |
Molecule create fails with permission errors on Podman | Rootless Podman lacks the image or privileged needs a user namespace | podman pull <image>, molecule --debug create, drop privileged unless systemd is needed |
ansible-lint passes locally, fails in CI | Different profile, version or collection set | Pin ansible-lint in the CI image; ansible-lint --version; commit .ansible-lint |
Template renders {{ var }} literally | Value came from an untrusted source (module result, external file) and 2.19+ no longer templates it | Render explicitly in a task with ansible.builtin.template or restructure so the value is a playbook variable |
Vault Decryption failed | Wrong password or vault id for this file | head -1 file shows the vault id; pass a matching --vault-id label@source |
Oneliners#
# Hosts that are not reachable
ansible all -m ansible.builtin.ping -o | grep -v SUCCESS
# OS version per host
ansible all -m ansible.builtin.setup -a 'filter=ansible_distribution*' --tree "$TMPDIR/facts" >/dev/null && jq -r '"\(.ansible_facts.ansible_distribution) \(.ansible_facts.ansible_distribution_version)"' "$TMPDIR"/facts/*
# Package version everywhere
ansible web -m ansible.builtin.command -a 'rpm -q nginx' -o
# Every command or shell task without a guard
grep -rnE 'ansible\.builtin\.(shell|command):' roles/ playbooks/
# Variables one host resolves to
ansible-inventory --host web-01.example.com | jq 'keys'
# Vault files that are not encrypted
grep -L '^\$ANSIBLE_VAULT' group_vars/*/vault.yml
# Vault id every encrypted file expects
for f in group_vars/*/vault.yml; do printf '%s\t%s\n' "$f" "$(head -1 "$f" | cut -d';' -f4)"; done
# Hosts a play would touch, one per line, for review before a production run
ansible-playbook site.yml --limit prod --list-hosts | sed -n '/hosts (/,$p' | tail -n +2 | tr -d ' '
# Tasks that would run for a tag
ansible-playbook site.yml --tags nginx --list-tasks
# Uptime and load across the fleet, one line per host
ansible all -m ansible.builtin.command -a 'uptime' -o | sort
# Reboot required (Fedora/RHEL): non-zero exit means yes
ansible all -m ansible.builtin.command -a 'needs-restarting -r' -o | grep -v 'rc=0'
# Pending updates count per host
ansible all -m ansible.builtin.shell -a 'dnf -q check-update | grep -c . || true' -o
# Free memory in MB per host, sorted
ansible all -m ansible.builtin.setup -a 'filter=ansible_memfree_mb' -o | sed -E 's/^([^ ]+).*"ansible_memfree_mb": ([0-9]+).*/\2\t\1/' | sort -n
# Kernel version distribution
ansible all -m ansible.builtin.command -a 'uname -r' -o | awk '{print $NF}' | sort | uniq -c | sort -rn
# Effective ansible.cfg settings that differ from defaults
ansible-config dump --only-changed
# Which ansible.cfg is in use
ansible --version | grep 'config file'
# Every module a project uses, by FQCN, with counts
grep -rhoE '^\s+[a-z_]+\.[a-z_]+\.[a-z_]+:' roles/ playbooks/ | tr -d ' :' | sort | uniq -c | sort -rn
# Collections required by the project versus installed
diff <(yq -r '.collections[] | .name // .' requirements.yml | sort) <(ansible-galaxy collection list --format json | jq -r '.[] | keys[]' | sort -u)
# Variables defined in defaults/ that no task or template references
for v in $(yq -r 'keys[]' roles/nginx/defaults/main.yml); do grep -rq "$v" roles/nginx/{tasks,templates,handlers,vars} 2>/dev/null || echo "unused: $v"; done
# Role variables not prefixed with the role name (ansible-lint var-naming)
yq -r 'keys[]' roles/nginx/defaults/main.yml | grep -v '^nginx_'
# Run a single role against one host without a playbook
ansible web-01.example.com -m ansible.builtin.include_role -a name=nginx --become
# Template a file locally and print it, using a host's variables
ansible web-01.example.com -m ansible.builtin.template -a 'src=roles/nginx/templates/nginx.conf.j2 dest=/dev/stdout' --check --diff 2>/dev/null | sed -n '/^+++/,$p'
# Evaluate a Jinja expression against real inventory variables
ansible web-01.example.com -m ansible.builtin.debug -a "msg={{ groups['web'] | map('extract', hostvars, 'ansible_host') | list }}"
# Facts for one host saved as JSON for offline querying
ansible web-01.example.com -m ansible.builtin.setup --tree "$TMPDIR/facts" && jq '.ansible_facts | keys' "$TMPDIR/facts/web-01.example.com"
# Diff inventory between two branches without connecting anywhere
diff <(git show main:inventory/prod.yml | ansible-inventory -i /dev/stdin --list 2>/dev/null | jq -S .) <(ansible-inventory -i inventory/prod.yml --list | jq -S .)
# Start a long job everywhere and return immediately (job ids in the output)
ansible all -m ansible.builtin.command -a '/opt/my-app/bin/reindex' -B 3600 -P 0 --become
# Lint only files changed on this branch
git diff --name-only main... -- '*.yml' '*.yaml' | xargs -r ansible-lint
# Time per task for a run, slowest first
ANSIBLE_CALLBACKS_ENABLED=ansible.posix.profile_tasks ansible-playbook site.yml --check 2>&1 | sed -n '/^=====/,$p' | head -20
# Kill a stuck run's leftover async jobs on the targets
ansible all -m ansible.builtin.shell -a 'pkill -f ansible_async_wrapper; rm -rf ~/.ansible_async' --becomeScripts#
Verify that every host in inventory is reachable and has a usable Python and sudo before a production run, and print a table rather than 200 lines of JSON.
#!/usr/bin/env bash
# preflight.sh [limit]: connectivity, interpreter and become check for every host, tab-separated
set -euo pipefail
limit=${1:-all}
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
ansible "$limit" -m ansible.builtin.setup -a 'gather_subset=min' --tree "$tmp/facts" >/dev/null 2>&1 || true
ansible "$limit" -m ansible.builtin.command -a 'id -un' --become -o > "$tmp/become.txt" 2>&1 || true
printf 'HOST\tREACHABLE\tPYTHON\tOS\tBECOME\n'
for host in $(ansible "$limit" --list-hosts | tail -n +2 | tr -d ' '); do
f="$tmp/facts/$host"
if [ -f "$f" ] && jq -e '.ansible_facts' "$f" >/dev/null 2>&1; then
reach=yes
py=$(jq -r '.ansible_facts.ansible_python_version // "?"' "$f")
os=$(jq -r '"\(.ansible_facts.ansible_distribution // "?") \(.ansible_facts.ansible_distribution_version // "")"' "$f")
else
reach=NO py=- os=-
fi
if grep -q "^$host | CHANGED.*\"stdout\": \"root\"" "$tmp/become.txt"; then become=ok
elif grep -q "^$host | " "$tmp/become.txt"; then become=FAIL
else become=-; fi
printf '%s\t%s\t%s\t%s\t%s\n' "$host" "$reach" "$py" "$os" "$become"
done | column -t -s $'\t'Report configuration drift across the fleet by running the playbook in check mode and summarising which hosts would change and in which tasks, suitable for a nightly cron and a Slack post.
#!/usr/bin/env bash
# drift-report.sh PLAYBOOK [ansible-playbook args...]: hosts and tasks that would change under --check
set -euo pipefail
playbook=$1; shift
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
ANSIBLE_STDOUT_CALLBACK=ansible.builtin.json ANSIBLE_CALLBACK_RESULT_FORMAT=json \
ansible-playbook "$playbook" --check --diff "$@" > "$tmp/run.json" || true # non-zero when hosts are unreachable; still report
jq -r '
.plays[] as $p
| $p.tasks[]
| .task.name as $t
| .hosts | to_entries[]
| select(.value.changed == true or .value.failed == true or .value.unreachable == true)
| "\(.key)\t\(if .value.unreachable then "UNREACHABLE" elif .value.failed then "FAILED" else "would change" end)\t\($p.play.name) / \($t)"
' "$tmp/run.json" | sort > "$tmp/drift.tsv"
if [ ! -s "$tmp/drift.tsv" ]; then echo "no drift: every host matches $playbook"; exit 0; fi
printf 'hosts with drift: %s\n\n' "$(cut -f1 "$tmp/drift.tsv" | sort -u | wc -l)"
column -t -s $'\t' "$tmp/drift.tsv"
exit 2Rotate a vaulted secret across environments: generate a new value, re-encrypt it with each environment’s vault id and rewrite only that key, leaving the rest of each vault file untouched.
#!/usr/bin/env python3
"""Rotate one vaulted variable in several vault files without decrypting the others to disk.
Usage: rotate-secret.py vault_db_password group_vars/prod/vault.yml group_vars/dev/vault.yml
Requires ANSIBLE_VAULT_PASSWORD_FILE or a vault_identity_list in ansible.cfg that covers every file.
"""
import secrets
import subprocess
import sys
from pathlib import Path
import yaml
key, *files = sys.argv[1:]
new_value = secrets.token_urlsafe(32)
for f in map(Path, files):
decrypted = subprocess.run(["ansible-vault", "decrypt", "--output", "-", str(f)], check=True, capture_output=True, text=True).stdout
data = yaml.safe_load(decrypted) or {}
if key not in data:
print(f"{f}: {key} not present, skipping", file=sys.stderr)
continue
data[key] = new_value
vault_id = f.read_text().splitlines()[0].split(";")[3] if f.read_text().count(";") >= 3 else None
cmd = ["ansible-vault", "encrypt", "--output", str(f)]
if vault_id:
cmd += ["--encrypt-vault-id", vault_id]
subprocess.run(cmd, input=yaml.safe_dump(data, sort_keys=False), check=True, text=True)
print(f"{f}: rotated {key}" + (f" (vault id {vault_id})" if vault_id else ""))
print("new value written; deploy with ansible-playbook and update the consuming service", file=sys.stderr)