Ansible

Ansible Cheat Sheet: Commands, Playbooks and Errors (PDF)

Plenty of Ansible cheat sheets still show ufw: as a module and ansible_os_family in a when: line. The older version of this page did too, and a current ansible-core punishes both. The bare ufw: task stops with couldn't resolve module/action 'ufw' unless a collection is installed, and every top-level fact now prints a deprecation warning that ends in removal.

Original content from computingforgeeks.com - post 165300

This cheat sheet covers the commands and playbook patterns you type every week, written for current releases. Every command and output here was run in October 2026 on ansible-core 2.21.5 installed with pipx on Ubuntu 26.04, driving an Ubuntu 26.04 node and a Rocky Linux 10.2 node with SELinux enforcing, and the release notes were checked again on ansible-core 2.22.0 in November 2026. The real error messages and their fixes are near the end, and the whole sheet comes as a one-page PDF.

Find the Ansible command for the job

Start with this table when you know the task but not the command. Each row has a tested example further down, and all module names are written in full (FQCN), which is what ansible-lint and the official docs expect:

JobCommand
Check every host answersansible all -m ansible.builtin.ping
Run one command on a groupansible web -m ansible.builtin.command -a 'uptime -p'
Install a package as rootansible web -b -m ansible.builtin.package -a 'name=tree state=present'
See the inventory treeansible-inventory --graph
Dry run a playbook with diffsansible-playbook site.yml --check --diff
Run one host or group onlyansible-playbook site.yml --limit rocky
Run only tagged tasksansible-playbook site.yml --tags motd
Encrypt one secretansible-vault encrypt_string 'value' --name db_password
Install collections from a fileansible-galaxy collection install -r requirements.yml
Show a module’s optionsansible-doc -s ansible.builtin.copy
Show only the settings you changedansible-config dump --only-changed
Catch a bad ansible.cfg keyansible-config validate
Lint against the strictest profileansible-lint --profile production site.yml

Install a current ansible-core

Distro packages lag well behind upstream, and on Rocky Linux the package most guides name does not exist. Each source gave us a different version in October 2026:

SourcePackageVersion we got
Ubuntu 26.04 apt (universe)ansible-core2.20.1
Rocky Linux 10.2 AppStreamansible-core2.16.16 (upstream end of life)
Rocky Linux 10.2, any repoansiblenone: Error: Unable to find a match: ansible
pipx, from PyPIansible-core2.21.5 in October, 2.22.0 since November 2

pipx gives you the current release in its own virtual environment, so upgrading Ansible never touches the Python packages apt manages. That matters on Ubuntu 26.04 because the system Python is marked as externally managed, so a plain pip install outside a virtual environment is refused. You also get one command, pipx upgrade ansible-core, for every later release. Install pipx from apt, then ansible-core:

sudo apt install -y pipx
pipx install ansible-core
pipx ensurepath

Open a new shell so ~/.local/bin is on your PATH, then confirm the version and the Python it runs on:

ansible --version

The same pipx install now pulls 2.22.0, which PyPI published on November 2, 2026. In the October run, our control node reported 2.21.5, then the current release, on Ubuntu’s Python 3.14:

ansible [core 2.21.5]
  config file = None
  configured module search path = ['/home/josphat/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /home/josphat/.local/share/pipx/venvs/ansible-core/lib/python3.14/site-packages/ansible
  ansible collection location = /home/josphat/.ansible/collections:/usr/share/ansible/collections
  executable location = /home/josphat/.local/bin/ansible
  python version = 3.14.4 (main, Aug 20 2026, 10:41:58) [GCC 15.2.0] (/home/josphat/.local/share/pipx/venvs/ansible-core/bin/python)
  jinja version = 3.1.6
  pyyaml version = 6.0.3 (with libyaml v0.2.5)

On Rocky Linux 10, sudo dnf install ansible fails with No match for argument: ansible, because neither the base repos nor EPEL 10 carry that package. pipx comes from EPEL. ansible-core 2.22.0, released on November 2, 2026, needs Python 3.13 or newer on the control node, so build the pipx environment on the python3.14 package from AppStream rather than on the system Python 3.12, where pip stops at 2.21.6:

sudo dnf install -y epel-release
sudo dnf install -y pipx python3.14
pipx install --python python3.14 ansible-core

That took 10 seconds on our Rocky Linux 10.2 node and reported ansible [core 2.21.5] on Python 3.14.8. Our Ansible install guide walks through each method in more detail.

ansible-core holds the ansible.builtin modules only. The big ansible package on PyPI bundles a large set of community collections on top, but you can stay lean and add single collections with ansible-galaxy when a task needs one, as the ufw error later shows.

Set up an inventory and ansible.cfg

Keep the inventory and an ansible.cfg in the project directory, so you can drop -i from every command and the settings travel with the code in Git. Ansible reads ansible.cfg from the current directory before ~/.ansible.cfg and /etc/ansible/ansible.cfg, which means a project file wins as long as you run commands from that directory. Two exceptions catch people out: an ANSIBLE_CONFIG variable overrides all three, and Ansible ignores an ansible.cfg in a world-writable directory. Our lab used three groups, one for all web servers and one per distro:

[web]
web01
web02

[ubuntu]
web01

[rocky]
web02

The names must resolve from the control node, and ours came from /etc/hosts. If yours don’t, give each host its address in the inventory, as in web01 ansible_host=10.0.1.50, or the first ping ends in the UNREACHABLE error covered at the end. Our login user also has an SSH key on both nodes and passwordless sudo, which every -b example below relies on; if your user needs a sudo password, add -K to those commands. Then add a short config to go with it. interpreter_python = auto_silent keeps automatic Python discovery but drops the warning it prints for every host:

[defaults]
inventory = inventory.ini
interpreter_python = auto_silent

Without that line, every command prints this pair of warnings. Rocky Linux 10.2 was driven with Python 3.12 and Ubuntu 26.04 with 3.14:

[WARNING]: Host 'web02' is using the discovered Python interpreter at '/usr/bin/python3.12', but future installation of another Python interpreter could cause a different interpreter to be discovered. See https://docs.ansible.com/ansible-core/2.21/reference_appendices/interpreter_discovery.html for more information.
[WARNING]: Host 'web01' is using the discovered Python interpreter at '/usr/bin/python3.14', but future installation of another Python interpreter could cause a different interpreter to be discovered. See https://docs.ansible.com/ansible-core/2.21/reference_appendices/interpreter_discovery.html for more information.

Pin the interpreter per host with ansible_python_interpreter=/usr/bin/python3 if you want the same Python every time. Older guides quote the pre-2.19 wording, Platform linux on host web02 is using the discovered Python interpreter, which is the same warning. The inventory and config commands below are the ones worth memorising:

CommandWhat it does
ansible-inventory --graphTree of groups and hosts
ansible-inventory --graph --varsThe same tree with variables
ansible-inventory --host web02Variables for one host, as JSON
ansible-inventory --listWhole inventory as JSON, what a dynamic inventory must return
ansible web --list-hostsWhich hosts a pattern matches, without running anything
ansible-config dump --only-changedOnly the settings that differ from defaults, and where each came from
ansible-config validateFlags unknown sections or keys in ansible.cfg; add -f env for ANSIBLE_* variables, -t all for plugin settings
ansible-config init --disabled -t all > full.cfgEvery setting, commented out, with its default

The graph for our inventory came back like this. ungrouped is always there, even when empty:

@all:
  |--@ungrouped:
  |--@web:
  |  |--web01
  |  |--web02
  |--@ubuntu:
  |  |--web01
  |--@rocky:
  |  |--web02

ansible-config validate answered All configurations seem valid! with exit 0 for this file. Run it after every hand edit, because a misspelled key in ansible.cfg is otherwise ignored without a word and you only notice when the setting has no effect. The generated full.cfg is the other half of the habit. It ran to 981 lines on our install, and these three defaults are the ones you are most likely to change:

  • ;forks=5, hosts handled at once
  • ;pipelining=False, measured further down
  • ;inject_facts_as_vars=True, deprecated since 2.20

Uncomment a line and set a value to change it, and the comment above each setting names its environment variable too. For groups of groups, host ranges and per-group variables, see the inventory management guide.

Run ad-hoc commands

An ad-hoc command is one module run against a host pattern, with no playbook. The shape is always ansible <pattern> -m <module> -a '<arguments>', and these are the modules you will reach for most:

ModuleExampleUse it for
ansible.builtin.pingansible all -m ansible.builtin.pingSSH login plus a working Python on the target
ansible.builtin.commandansible all -m ansible.builtin.command -a 'uptime -p'Commands with no pipes or redirects (the default module)
ansible.builtin.shellansible all -m ansible.builtin.shell -a 'ps aux | wc -l'When you need pipes, globs or variables
ansible.builtin.packageansible web -b -m ansible.builtin.package -a 'name=tree state=present'Install on apt and dnf hosts alike
ansible.builtin.serviceansible web -b -m ansible.builtin.service -a 'name=nginx state=restarted'Start, stop, restart, enable
ansible.builtin.copyansible web -b -m ansible.builtin.copy -a 'src=motd dest=/etc/motd mode=0644'Push a file
ansible.builtin.fileansible web -b -m ansible.builtin.file -a 'path=/srv/app state=directory'Directories, permissions, links, deletes
ansible.builtin.useransible web -b -m ansible.builtin.user -a 'name=deploy state=present'Users
ansible.builtin.setupansible all -m ansible.builtin.setup -a 'filter=ansible_distribution*'Facts, filtered

Facts are the quickest way to see what you are talking to. The distribution filter returned these keys for the Rocky host (the Ubuntu host answered Ubuntu, 26.04 and resolute):

web02 | SUCCESS => {
    "ansible_facts": {
        "ansible_distribution": "Rocky",
        "ansible_distribution_file_parsed": true,
        "ansible_distribution_file_path": "/etc/redhat-release",
        "ansible_distribution_file_variety": "RedHat",
        "ansible_distribution_major_version": "10",
        "ansible_distribution_release": "Red Quartz",
        "ansible_distribution_version": "10.2",
        "discovered_interpreter_python": "/usr/bin/python3.12"
    },
    "changed": false
}

The flags matter as much as the modules. A few of them mean different things in different tools, and two common ones are on their way out:

FlagMeaning
-b / --becomeRun as root through sudo
-K / --ask-become-passPrompt for the sudo password
-k / --ask-passPrompt for the SSH password (lowercase, easy to mix up with -K)
-u deployLog in as another user
-f 20Run on 20 hosts at a time (default 5)
-l rocky / --limitNarrow the pattern further
-e 'env=prod'Extra variables, highest precedence of all
-C / --checkDry run where the module supports it
-B 120 -P 0Start in the background, poll later
--flush-cacheClear cached facts for every host in the inventory, even with --limit
-o, -t DIRDeprecated (one-line and tree output), removed in ansible-core 2.23
--inventory-fileDeprecated alias of -i, removed in 2.23

Watch -t in particular. On ansible it is the deprecated --tree output option, while on ansible-doc and ansible-config it selects a plugin type. We ran ansible all -m ansible.builtin.shell -a 'echo $SHELL; whoami' -o to see what you get today. It still works, but each run opens with two deprecation warnings before the one-line results (excerpt):

[DEPRECATION WARNING]: The '-o' argument is deprecated. This feature will be removed from ansible-core version 2.23. Use callback configuration to enable the oneline callback instead.
[DEPRECATION WARNING]: oneline has been deprecated. Use another callback plugin, or vendor and/or move the oneline callback to a collection. This feature will be removed from ansible-core version 2.23.

web02 | CHANGED | rc=0 | (stdout) /bin/bash\njosphat
web01 | CHANGED | rc=0 | (stdout) /bin/bash\njosphat

Long jobs are better started in the background, so your terminal and the SSH session don’t sit waiting on them. -B 120 -P 0 starts the command with a 120 second limit and returns a job ID straight away. You then check on it with async_status:

ansible web01 -b -B 120 -P 0 -m ansible.builtin.command -a 'sleep 5'
ansible web01 -b -m ansible.builtin.async_status -a 'jid=j848604740256.15370'

Replace the jid with the ansible_job_id the first command printed. Once the job had finished, the status call returned "finished": true with "rc": 0. Our ad-hoc commands guide has more module examples.

Use ansible-playbook options

Most day-to-day work happens through ansible-playbook, and a handful of flags cover nearly every run. These are the ones to remember:

OptionWhat it does
--syntax-checkParse the playbook and stop
--list-tasks, --list-hosts, --list-tagsShow what would run, and where
--check --diffDry run that shows file changes line by line
--limit web01Run on a subset of the play’s hosts
--tags motd / --skip-tags packagesRun or skip tagged tasks
--start-at-task 'Write the message of the day'Resume from a named task
--stepConfirm each task before it runs
-e @vars.ymlLoad extra variables from a file
-v to -vvvvMore output; -vvv shows the SSH commands, -vvvv the connection plugin internals

We tested them with a small playbook. It installs chrony, writes /etc/motd, and prints the OS family twice, once the old way and once the current way:

---
- name: Baseline web servers
  hosts: web
  become: true
  tasks:
    - name: Install chrony
      ansible.builtin.package:
        name: chrony
        state: present
      tags: [packages]

    - name: Write the message of the day
      ansible.builtin.copy:
        dest: /etc/motd
        content: "Managed by Ansible. Changes will be overwritten.\n"
        mode: "0644"
      tags: [motd]

    - name: Show the OS family the old way
      ansible.builtin.debug:
        msg: "{{ ansible_os_family }}"

    - name: Show the OS family the new way
      ansible.builtin.debug:
        msg: "{{ ansible_facts['os_family'] }}"

A check run limited to the motd tag shows exactly what would change on each host before you commit to it:

ansible-playbook site.yml --check --diff --tags motd

Rocky Linux already had an empty /etc/motd, so its diff names the file on both sides. Ubuntu 26.04 had none at all, and its diff shows an unnamed --- before instead:

TASK [Write the message of the day] ********************************************
--- before
+++ after: /etc/motd
@@ -0,0 +1 @@
+Managed by Ansible. Changes will be overwritten.

changed: [web01]
--- before: /etc/motd
+++ after: /etc/motd
@@ -0,0 +1 @@
+Managed by Ansible. Changes will be overwritten.

changed: [web02]

The real run then printed the warning that every playbook using top-level facts now produces. ansible-core 2.20 deprecated injecting facts as plain variables, and the default flips in 2.24:

[DEPRECATION WARNING]: INJECT_FACTS_AS_VARS default to `True` is deprecated, top-level facts will not be auto injected after the change. This feature will be removed from ansible-core version 2.24.
Origin: /home/josphat/ansible-lab/site.yml:21:14

19     - name: Show the OS family the old way
20       ansible.builtin.debug:
21         msg: "{{ ansible_os_family }}"
                ^ column 14

Use `ansible_facts["fact_name"]` (no `ansible_` prefix) instead.

The new-style task printed the same two answers with no warning. Rewrite the old names now, while they still work, because once 2.24 flips the default a playbook that reads ansible_os_family fails on an undefined variable instead of warning. The change is mechanical, so you can do it with a search and replace: ansible_os_family becomes ansible_facts['os_family'], and ansible_distribution becomes ansible_facts['distribution']. The playbook tutorial covers plays, tasks and modules from the start.

Reuse these playbook patterns

A handful of constructs cover most real playbooks. This one combines them, and we ran it as written against both hosts after purging nginx from them, so it installs everything it needs. It passed ansible-lint --profile production too:

  • a package list, nginx included, installed in one transaction
  • a service started now and enabled at boot
  • a loop over two service accounts
  • register with changed_when: false for a read-only command
  • a when on ansible_facts
  • a handler, flushed early with meta: flush_handlers
  • a uri check that retries until the service answers
  • block, rescue and always
---
- name: Everyday playbook patterns
  hosts: web
  become: true
  vars:
    base_packages: [curl, tree, nginx]
  handlers:
    - name: Restart nginx
      ansible.builtin.service:
        name: nginx
        state: restarted
  tasks:
    - name: Install packages in one transaction
      ansible.builtin.package:
        name: "{{ base_packages }}"
        state: present

    - name: Create two service accounts
      ansible.builtin.user:
        name: "{{ item }}"
        shell: /usr/sbin/nologin
        system: true
      loop: [appsvc, backupsvc]

    - name: Start nginx now and at boot
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

    - name: Read the running kernel
      ansible.builtin.command: uname -r
      register: kernel
      changed_when: false

    - name: Report it
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} runs kernel {{ kernel.stdout }}"

    - name: Only on Debian-family hosts
      ansible.builtin.debug:
        msg: "apt-based host"
      when: ansible_facts['os_family'] == 'Debian'

    - name: Add an nginx status endpoint on 127.0.0.1:81
      ansible.builtin.copy:
        dest: /etc/nginx/conf.d/cfg-status.conf
        content: |
          server {
              listen 127.0.0.1:81;
              location /nginx_status { stub_status; }
          }
        mode: "0644"
      notify: Restart nginx

    - name: Restart nginx now instead of at the end of the play
      ansible.builtin.meta: flush_handlers

    - name: Wait for the status page to answer
      ansible.builtin.uri:
        url: http://127.0.0.1:81/nginx_status
        return_content: true
      register: status
      until: status.status == 200
      retries: 5
      delay: 2

    - name: Fail, recover, clean up
      block:
        - name: A step that fails
          ansible.builtin.command: /bin/false
          changed_when: false
      rescue:
        - name: Runs only because the block failed
          ansible.builtin.debug:
            msg: "rescued on {{ inventory_hostname }}"
      always:
        - name: Runs every time
          ansible.builtin.debug:
            msg: "cleanup done"

Port 81 is deliberate. It is labelled http_port_t in SELinux, so nginx on Rocky Linux can bind it with SELinux enforcing; a port outside that list would need a semanage port rule first. The run shows a distro difference worth knowing. Ubuntu starts nginx as soon as apt installs it, so the start task reported ok there, while Rocky Linux leaves a new service stopped and the same task reported changed. The service accounts already existed from an earlier run, so the loop reported ok for each item:

TASK [Install packages in one transaction] *************************************
changed: [web02]
changed: [web01]

TASK [Create two service accounts] *********************************************
ok: [web01] => (item=appsvc)
ok: [web02] => (item=appsvc)
ok: [web01] => (item=backupsvc)
ok: [web02] => (item=backupsvc)

TASK [Start nginx now and at boot] *********************************************
ok: [web01]
changed: [web02]

Further down, the Rocky host skipped the Debian-only task and the rescue caught the deliberate failure. The play recap counts the rescue separately from failures, so a rescued block still ends the run with failed=0. A second run was idempotent, with changed=0 on both hosts and the handler left alone:

PLAY RECAP *********************************************************************
web01                      : ok=11   changed=0    unreachable=0    failed=0    skipped=0    rescued=1    ignored=0   
web02                      : ok=10   changed=0    unreachable=0    failed=0    skipped=1    rescued=1    ignored=0   

Handlers normally wait until the end of the play, which is too late when a later task needs the restarted service. meta: flush_handlers runs them at that point instead. The handlers guide and the conditionals and loops guide cover both in depth.

Transform data with Jinja2 filters and lookups

Filters reshape variables inside {{ }}, and lookups pull data from the control node. Every result below came from one debug task run against localhost:

ExpressionResult
{{ pkgs | unique }} on [nginx, redis, nginx, chrony]["nginx", "redis", "chrony"]
{{ nums | sort | join(',') }} on [3, 1, 4, 1, 5]1,1,3,4,5
{{ undefined_var | default('production') }}production
{{ enable_tls | bool }} where the value is the string "yes"true
{{ (enable_tls | bool) | ternary('https', 'http') }}https
{{ 'web-01.example.com' | regex_replace('\\..*$', '') }}web-01
{{ {'a': 1, 'b': 2} | combine({'b': 3}) }}{"a": 1, "b": 3}
{{ users | selectattr('active') | map(attribute='name') | list }}["alice", "carol"]
{{ (api_reply | from_json).port }}8080
{{ lookup('ansible.builtin.env', 'HOME') }}The control node’s home directory
{{ lookup('ansible.builtin.pipe', 'date +%Y') }}2026
{{ query('ansible.builtin.sequence', 'start=8080 end=8082') }}["8080", "8081", "8082"]

Lookups always run on the control node, never on the managed host, so lookup('ansible.builtin.env', 'HOME') returns your home directory and not the target’s. The | bool filter also matters for the conditional error covered further down. There are dozens more in the Ansible filters reference.

Encrypt secrets with ansible-vault

Vault encrypts whole files or single values with AES256, and playbooks decrypt them at run time. These commands cover the full cycle:

TaskCommand
Encrypt a fileansible-vault encrypt secrets.yml
Read itansible-vault view secrets.yml
Edit it in placeansible-vault edit secrets.yml
Decrypt it for goodansible-vault decrypt secrets.yml
Change the passwordansible-vault rekey secrets.yml
Encrypt one value for a vars fileansible-vault encrypt_string 'S3cret' --name db_password
Run with a promptansible-playbook site.yml --ask-vault-pass
Run with a password fileansible-playbook site.yml --vault-password-file .vault_pass

A password file saves typing, but treat it like a private key. Create it with a restrictive umask inside a subshell, so your shell keeps its normal umask, then encrypt a single value. Typing the password on the command line also puts it in your shell history, so an editor is the safer way to write the file on a shared box:

(umask 077; echo 'change-this-vault-pass' > .vault_pass)
ansible-vault encrypt_string --vault-password-file .vault_pass 'S3cret-db-pw' --name db_password

The output pastes straight into a vars file. It is truncated here after the first two lines of ciphertext:

db_password: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          66393835363465373564386131363538353338626466633032376264646535633034346231303339
          6363643064353666306166663533656538646363656561630a666464626665346662303133396435

Add .vault_pass to .gitignore before your first commit. On 2.21, a debug task still prints a decrypted value in clear text, so checking a secret that way works there. The 2.22 porting guide says that stops: vault values, prompted passwords and no_log data print as $REDACTED$ everywhere, debug included. We confirmed the vault part on 2.22.0 in November: a debug task printed "db_password": "$REDACTED$" where 2.21.6 printed the plain value, and a comparison against the real string still returned match=True on both. The guide’s own suggestion for checking a value is to write it to a file with mode 0600 instead. The Ansible Vault tutorial covers vault IDs and multiple passwords.

Manage roles and collections with ansible-galaxy

Collections ship modules that ansible-core lacks, such as community.general.ufw. Pin them in a requirements.yml so every machine gets the same set:

---
collections:
  - name: community.general
    version: ">=13.0.0"
  - name: ansible.posix

The commands you need around that file and around roles are few:

CommandWhat it does
ansible-galaxy collection install -r requirements.ymlInstall everything in the file
ansible-galaxy collection install community.generalInstall one collection (took 24.6 s on our box, with a dependency)
ansible-galaxy collection listInstalled collections and versions
ansible-galaxy collection verify community.generalCompare installed files against the Galaxy checksums
ansible-galaxy role init webserverScaffold a role directory
ansible-galaxy role install -r requirements.ymlInstall the roles: section of the file

Our lab ended up with community.general 13.5.0 and ansible.posix 2.2.2. role init printed - Role webserver was created successfully and ls webserver showed the standard layout:

README.md
defaults
files
handlers
meta
tasks
templates
tests
vars

From ansible-core 2.21, ansible-galaxy skips a collection whose requires_ansible range excludes the core you run, so an old pinned version may not install at all. The collections tutorial and the roles tutorial go further.

Read module docs and lint offline

ansible-doc works without a browser and always matches the version you run. -s prints a ready-to-paste task skeleton:

ansible-doc -s ansible.builtin.copy | head -5

Each option comes with its description as a comment:

- name: Copy files to remote locations
  copy:
      attributes:            # The attributes the resulting filesystem object
                             # should have. To get
                             # supported flags

ansible-doc -l community.general listed 581 modules, and ansible-doc -l -t connection lists connection plugins, where paramiko no longer appears. For style and correctness, ansible-lint is the standard checker, and we installed it the same way as ansible-core:

pipx install ansible-lint
ansible-lint old.yml

Version 26.10.1 ran on top of ansible-core 2.21.5. We fed it a playbook written the way older cheat sheets show, with a short package: name in key=value form and an unnamed shell: task. It returned 6 failures and exit 2:

no-free-form: Avoid using free-form when calling module actions. (package)
old.yml:5 package

fqcn[action-core]: Use FQCN for builtin module actions (package).
old.yml:6:7 Use `ansible.builtin.package` or `ansible.legacy.package` instead.

command-instead-of-shell: Use shell only when shell functionality is required.
old.yml:8 Task/Handler: shell echo hi

fqcn[action-core]: Use FQCN for builtin module actions (shell).
old.yml:8:7 Use `ansible.builtin.shell` or `ansible.legacy.shell` instead.

name[missing]: All tasks should be named.
old.yml:8 Task/Handler: shell echo hi

no-changed-when: Commands should not change things if nothing needs doing.
old.yml:8 Task/Handler: shell echo hi

The rule violation summary at the top of the output names the profile each rule belongs to, and the profiles stack from loosest to strictest, so you can adopt lint on an old code base without fixing everything on day one. In order, they are:

  1. min
  2. basic
  3. moderate
  4. safety
  5. shared
  6. production

Pick one with --profile and raise it as the code base cleans up. The patterns playbook above first failed production on no-changed-when for its deliberate /bin/false, and passed once that task got changed_when: false. For a book that teaches the reasoning behind all of this, Jeff Geerling’s Ansible for DevOps (paid link) is the one we recommend most often. The print edition dates from 2020, so read its short module names with this page’s FQCN table beside it, but its explanations of inventories, roles and real deployments hold up. When a run misbehaves rather than a lint, the Ansible debugging guide picks up from here.

Speed up Ansible runs

SSH round trips are most of the time in a typical run. Pipelining sends the module over the SSH session it already has, instead of copying a file first, so it cuts operations per task. We timed a 20-task playbook (ansible.builtin.command: "true", facts off) against both hosts, three runs each:

SettingRun 1Run 2Run 3
ANSIBLE_PIPELINING=False (default)7.8 s7.9 s7.8 s
ANSIBLE_PIPELINING=True6.2 s6.2 s6.3 s

That is about 20% saved on two nearby hosts, and the gain grows with latency. Pipelining needs sudo to work without a TTY. Neither of our hosts had requiretty in /etc/sudoers or /etc/sudoers.d, but check older RHEL-family images before you enable it everywhere. The planned switch to pipelining on by default did not make the 2.22.0 release either, since ansible-config dump on it still reports ANSIBLE_PIPELINING(default) = False, so set it yourself. These settings all go under [defaults] in ansible.cfg unless noted:

SettingDefaultChange it when
pipelining = True (in [connection])FalseAlmost always, as measured above
forks = 205You manage more than 5 hosts; the run does 5 at a time otherwise
gather_facts: false (per play)trueThe play uses no facts
gather_subset: ['!all', '!min', 'hardware'] (per play, not ansible.cfg)allYou need one fact family, such as memory
fact_caching = jsonfile, fact_caching_connection = ~/.ansible/facts_cache and gathering = smartmemory, implicitSeveral runs reuse the same facts
strategy: free (per play)linearHosts don’t need to stay in step
serial: 25% (per play)all hostsRolling changes, to limit the blast radius

A subset run keeps facts cheap. ansible all -m ansible.builtin.setup -a 'gather_subset=!all,!min,hardware filter=ansible_memtotal_mb' returned 3653 MB for the Rocky host and 3910 MB for the Ubuntu host, without collecting the rest. The gather_subset key left ansible.cfg in ansible-core 2.18, so set it per play or as a module_defaults entry for ansible.builtin.setup. The fact cache needs both lines from the table. With fact_caching = jsonfile alone, our run printed [WARNING]: Required config '_uri' for 'jsonfile' cache plugin not provided. and carried on. With the connection path added, the next run wrote one file per host to ~/.ansible/facts_cache, yet it was no faster: two runs took 2.8 s and 2.7 s, because the default gathering = implicit ignores the cache and gathers facts in every play. Adding gathering = smart is what makes the cache pay off. The same two runs then took 1.4 s and 1.5 s, and the Gathering Facts task disappeared from the output.

Replace old habits that now warn or fail

Several things that older guides teach have been deprecated or removed over the last few releases. Each row below either failed or warned in our lab, or comes from the official porting guides:

Old habitCurrent formWhat happens on 2.21
ufw:, timezone: and other short namescommunity.general.ufwcouldn't resolve module/action on bare ansible-core
ansible_os_familyansible_facts['os_family']Deprecation warning, default flips in 2.24
when: some_stringwhen: some_string | bool or | length > 0Error since 2.19
-c paramikoThe default ssh connectionthe connection plugin 'paramiko' was not found
interpreter_python = auto_legacyauto or auto_silentRemoved in 2.21
transport = smartLeave it unset (ssh)Removed in 2.20 per the porting guide, though ANSIBLE_TRANSPORT=smart still connected in our test
with_items:loop:Still works; loop is the documented form
apt_key + apt_repositoryansible.builtin.deb822_repositoryDeprecated in 2.21
ansible -o, --inventory-fileCallback config, -iDeprecation warning, removed in 2.23

The full lists live in the official Ansible porting guides, one per core release.

Fix common Ansible errors

These are the errors we triggered on purpose in the lab, with the exact text ansible-core 2.21.5 printed and the fix that worked. Exit codes are worth knowing for CI: 4 for an unreachable host or a parse error, 2 for a failed task.

Error: “UNREACHABLE! Failed to connect to the host via ssh”

Ansible could not open SSH to the host at all. Our test used a name with no DNS entry:

web03 | UNREACHABLE! => {
    "changed": false,
    "msg": "Task failed: Failed to connect to the host via ssh: ssh: Could not resolve hostname web03: Temporary failure in name resolution",
    "unreachable": true
}

Whatever follows via ssh: is SSH’s own error, so read it as an SSH problem and not an Ansible one. Run ssh user@host by hand with the same user and key first, because if that fails, no Ansible setting will help. Then match the fix to the message:

  • name resolution: fix DNS or set ansible_host in the inventory
  • Permission denied: set ansible_user and ansible_ssh_private_key_file
  • Host key verification failed: add the host with ssh-keyscan

Resist turning host key checking off to make the last one go away. You lose the only check that you are talking to the right machine, and ssh-keyscan fixes it for good in one line.

Error: “Task failed: Missing sudo password”

The task used -b and the remote user needs a password for sudo. We hit it as a deploy user without NOPASSWD rights; the run failed with exit 2. Add -K to be prompted, store ansible_become_password in a vault file, or give the automation user a NOPASSWD sudoers rule. The prompt from -K needs a terminal, so in CI use the vault variable.

Error: “couldn’t resolve module/action ‘ufw'”

The module is not in ansible-core and no collection that provides it is installed. The error points at the task:

[ERROR]: couldn't resolve module/action 'ufw'. This often indicates a misspelling, missing collection, or incorrect module path.
Origin: /home/josphat/ansible-lab/ufw.yml:5:7

Install the collection with ansible-galaxy collection install community.general and write the full name, community.general.ufw. Once the collection was installed, even the short ufw: name resolved, through ansible-core’s built-in routing, so the full name is about clarity rather than the cause of this error. The next run without -b failed on the target with ERROR: You need to be root to run this script, which is ufw’s own message and a different problem. With become: true on the play (or -b), the community.general.ufw task ran in check mode and reported changed.

Error: “Conditional result (True) was derived from value of type ‘str'”

Since ansible-core 2.19, when: must produce a real boolean. A variable holding the string production no longer counts as true:

fatal: [web01]: FAILED! => {"msg": "Task failed: A 'when' expression failed: Conditional result (True) was derived from value of type 'str' at '/home/josphat/ansible-lab/cond.yml:5:17'. Conditionals must have a boolean result."}

The fix is to say what you mean, and the right test depends on what the variable holds:

  • when: deploy_env == 'production' for a specific value
  • when: deploy_env | length > 0 for any non-empty string
  • when: enable_tls | bool for yes/no strings

The error also suggests ALLOW_BROKEN_CONDITIONALS as a stopgap. Use it only to get a pipeline green while you fix the expressions, because that setting stops having any effect in 2.23 and the same playbook will fail again then.

Error: “the connection plugin ‘paramiko’ was not found”

The paramiko connection plugin was removed in ansible-core 2.21, so -c paramiko or ansible_connection=paramiko now fails with exit 2. Remove the setting and let the default ssh plugin handle the host. If paramiko was there for password logins, use -k with the default ssh plugin. Since 2.19 it passes the password through SSH_ASKPASS, so sshpass is only needed if you set password_mechanism = sshpass.

Warning: “INJECT_FACTS_AS_VARS default to True is deprecated”

This one is a warning, not an error, but it fires once for every top-level fact a playbook reads. Rewrite the reference to ansible_facts['fact_name'] as shown in the playbook section. If you can’t change the code yet, setting inject_facts_as_vars = True explicitly in [defaults] silences it, and keeps the old behaviour after the default flips in 2.24. The Ansible automation guide lists the rest of this series in reading order.

Keep reading

OpenCode CLI Cheat Sheet: Commands, Sessions and Fixes (PDF) AI OpenCode CLI Cheat Sheet: Commands, Sessions and Fixes (PDF) Best Mini PC for Home Assistant: 4 Picks for HAOS and Proxmox Automation Best Mini PC for Home Assistant: 4 Picks for HAOS and Proxmox Ansible + Proxmox: Automated VM Management Ansible Ansible + Proxmox: Automated VM Management Best Weather Stations for Home Assistant: Ecowitt, Tempest, Davis Automation Best Weather Stations for Home Assistant: Ecowitt, Tempest, Davis Best Water Leak Sensors and Auto Shutoff Valves for Home Assistant Automation Best Water Leak Sensors and Auto Shutoff Valves for Home Assistant Claude Opus 4.8 Released: Features, Benchmarks, and Claude Code Guide AI Claude Opus 4.8 Released: Features, Benchmarks, and Claude Code Guide

Leave a Comment

Press ESC to close