# 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.

Canonical: https://www.wiki.jodisand.me/ansible/
Reviewed: 2026-09-24
Related: [SSH](https://www.wiki.jodisand.me/ssh/index.md), [Terraform](https://www.wiki.jodisand.me/terraform/index.md), [Vault](https://www.wiki.jodisand.me/vault/index.md), [Network automation](https://www.wiki.jodisand.me/netauto/index.md), [Python](https://www.wiki.jodisand.me/python/index.md)


## 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](https://docs.ansible.com/ansible/latest/reference_appendices/release_and_maintenance.html).

## How a play runs

Ansible connects to each host over [SSH](https://www.wiki.jodisand.me/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.

```sh
ansible-playbook site.yml --check --diff --limit staging
ansible-playbook site.yml --check --diff --tags nginx
```

`command` 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

```ini
# 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
```

```yaml
# 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: 4
```

```sh
ansible-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 web
```

`group_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`).

```yaml
# 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: 600
```

`ansible-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](https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_variables.html#understanding-variable-precedence).

## Playbook structure and rolling updates

```yaml
- 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: reloaded
```

`serial` 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.

```yaml
  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 changed
```

## Roles

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.

```text
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.md
```

`argument_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.

```yaml
# 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.

```sh
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 project
```

## Writing 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.

```yaml
- 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

```jinja

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.

```sh
ansible web-01 -m ansible.builtin.setup | less                        # every fact
ansible web-01 -m ansible.builtin.setup -a 'filter=ansible_mounts'
```

```yaml
- name: Show a value at the point of use
  ansible.builtin.debug:
    var: nginx_workers
    verbosity: 1              # printed only with -v or more
```

Fact 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.

```yaml
- 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
```

```yaml
- 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: true
```

A 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.

```yaml
- 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 between
```

With `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.

> [!IMPORTANT] 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](https://docs.ansible.com/ansible/latest/porting_guides/porting_guide_core_2.19.html).

## Secrets with Ansible Vault

```sh
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-pass
```

A 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](https://www.wiki.jodisand.me/vault/).

Vault IDs label secrets so one run can use several passwords, and a password script keeps the password out of files entirely:

```sh
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 disk
```

`vault_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

```ini
# 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=60s
```

`ansible.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.

```yaml
# 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]
```

```yaml
# 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 }
```

```sh
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 list
```

The 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.

```yaml
# .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 CI
```

```sh
ansible-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 down
```

Rules 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

```sh
# 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' --become
```

## Scripts

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.

```sh
#!/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.

```sh
#!/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 2
```

Rotate 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.

```python
#!/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)
```

## Further reading

- [Ansible playbook guide](https://docs.ansible.com/ansible/latest/playbook_guide/index.html)
- [Inventory plugins](https://docs.ansible.com/ansible/latest/plugins/inventory.html)
- [Using filters](https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_filters.html) and [tests](https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_tests.html)
- [Molecule documentation](https://ansible.readthedocs.io/projects/molecule/)
- [ansible-lint rules](https://ansible.readthedocs.io/projects/lint/rules/)
- [Ansible Vault](https://docs.ansible.com/ansible/latest/vault_guide/index.html)


