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.
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:
| Job | Command |
|---|---|
| Check every host answers | ansible all -m ansible.builtin.ping |
| Run one command on a group | ansible web -m ansible.builtin.command -a 'uptime -p' |
| Install a package as root | ansible web -b -m ansible.builtin.package -a 'name=tree state=present' |
| See the inventory tree | ansible-inventory --graph |
| Dry run a playbook with diffs | ansible-playbook site.yml --check --diff |
| Run one host or group only | ansible-playbook site.yml --limit rocky |
| Run only tagged tasks | ansible-playbook site.yml --tags motd |
| Encrypt one secret | ansible-vault encrypt_string 'value' --name db_password |
| Install collections from a file | ansible-galaxy collection install -r requirements.yml |
| Show a module’s options | ansible-doc -s ansible.builtin.copy |
| Show only the settings you changed | ansible-config dump --only-changed |
| Catch a bad ansible.cfg key | ansible-config validate |
| Lint against the strictest profile | ansible-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:
| Source | Package | Version we got |
|---|---|---|
| Ubuntu 26.04 apt (universe) | ansible-core | 2.20.1 |
| Rocky Linux 10.2 AppStream | ansible-core | 2.16.16 (upstream end of life) |
| Rocky Linux 10.2, any repo | ansible | none: Error: Unable to find a match: ansible |
| pipx, from PyPI | ansible-core | 2.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:
| Command | What it does |
|---|---|
ansible-inventory --graph | Tree of groups and hosts |
ansible-inventory --graph --vars | The same tree with variables |
ansible-inventory --host web02 | Variables for one host, as JSON |
ansible-inventory --list | Whole inventory as JSON, what a dynamic inventory must return |
ansible web --list-hosts | Which hosts a pattern matches, without running anything |
ansible-config dump --only-changed | Only the settings that differ from defaults, and where each came from |
ansible-config validate | Flags 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.cfg | Every 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:
| Module | Example | Use it for |
|---|---|---|
ansible.builtin.ping | ansible all -m ansible.builtin.ping | SSH login plus a working Python on the target |
ansible.builtin.command | ansible all -m ansible.builtin.command -a 'uptime -p' | Commands with no pipes or redirects (the default module) |
ansible.builtin.shell | ansible all -m ansible.builtin.shell -a 'ps aux | wc -l' | When you need pipes, globs or variables |
ansible.builtin.package | ansible web -b -m ansible.builtin.package -a 'name=tree state=present' | Install on apt and dnf hosts alike |
ansible.builtin.service | ansible web -b -m ansible.builtin.service -a 'name=nginx state=restarted' | Start, stop, restart, enable |
ansible.builtin.copy | ansible web -b -m ansible.builtin.copy -a 'src=motd dest=/etc/motd mode=0644' | Push a file |
ansible.builtin.file | ansible web -b -m ansible.builtin.file -a 'path=/srv/app state=directory' | Directories, permissions, links, deletes |
ansible.builtin.user | ansible web -b -m ansible.builtin.user -a 'name=deploy state=present' | Users |
ansible.builtin.setup | ansible 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:
| Flag | Meaning |
|---|---|
-b / --become | Run as root through sudo |
-K / --ask-become-pass | Prompt for the sudo password |
-k / --ask-pass | Prompt for the SSH password (lowercase, easy to mix up with -K) |
-u deploy | Log in as another user |
-f 20 | Run on 20 hosts at a time (default 5) |
-l rocky / --limit | Narrow the pattern further |
-e 'env=prod' | Extra variables, highest precedence of all |
-C / --check | Dry run where the module supports it |
-B 120 -P 0 | Start in the background, poll later |
--flush-cache | Clear cached facts for every host in the inventory, even with --limit |
-o, -t DIR | Deprecated (one-line and tree output), removed in ansible-core 2.23 |
--inventory-file | Deprecated 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:
| Option | What it does |
|---|---|
--syntax-check | Parse the playbook and stop |
--list-tasks, --list-hosts, --list-tags | Show what would run, and where |
--check --diff | Dry run that shows file changes line by line |
--limit web01 | Run on a subset of the play’s hosts |
--tags motd / --skip-tags packages | Run or skip tagged tasks |
--start-at-task 'Write the message of the day' | Resume from a named task |
--step | Confirm each task before it runs |
-e @vars.yml | Load extra variables from a file |
-v to -vvvv | More 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
loopover two service accounts registerwithchanged_when: falsefor a read-only command- a
whenonansible_facts - a handler, flushed early with
meta: flush_handlers - a
uricheck that retries until the service answers block,rescueandalways
---
- 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:
| Expression | Result |
|---|---|
{{ 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:
| Task | Command |
|---|---|
| Encrypt a file | ansible-vault encrypt secrets.yml |
| Read it | ansible-vault view secrets.yml |
| Edit it in place | ansible-vault edit secrets.yml |
| Decrypt it for good | ansible-vault decrypt secrets.yml |
| Change the password | ansible-vault rekey secrets.yml |
| Encrypt one value for a vars file | ansible-vault encrypt_string 'S3cret' --name db_password |
| Run with a prompt | ansible-playbook site.yml --ask-vault-pass |
| Run with a password file | ansible-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:
| Command | What it does |
|---|---|
ansible-galaxy collection install -r requirements.yml | Install everything in the file |
ansible-galaxy collection install community.general | Install one collection (took 24.6 s on our box, with a dependency) |
ansible-galaxy collection list | Installed collections and versions |
ansible-galaxy collection verify community.general | Compare installed files against the Galaxy checksums |
ansible-galaxy role init webserver | Scaffold a role directory |
ansible-galaxy role install -r requirements.yml | Install 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:
minbasicmoderatesafetysharedproduction
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:
| Setting | Run 1 | Run 2 | Run 3 |
|---|---|---|---|
ANSIBLE_PIPELINING=False (default) | 7.8 s | 7.9 s | 7.8 s |
ANSIBLE_PIPELINING=True | 6.2 s | 6.2 s | 6.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:
| Setting | Default | Change it when |
|---|---|---|
pipelining = True (in [connection]) | False | Almost always, as measured above |
forks = 20 | 5 | You manage more than 5 hosts; the run does 5 at a time otherwise |
gather_facts: false (per play) | true | The play uses no facts |
gather_subset: ['!all', '!min', 'hardware'] (per play, not ansible.cfg) | all | You need one fact family, such as memory |
fact_caching = jsonfile, fact_caching_connection = ~/.ansible/facts_cache and gathering = smart | memory, implicit | Several runs reuse the same facts |
strategy: free (per play) | linear | Hosts don’t need to stay in step |
serial: 25% (per play) | all hosts | Rolling 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 habit | Current form | What happens on 2.21 |
|---|---|---|
ufw:, timezone: and other short names | community.general.ufw | couldn't resolve module/action on bare ansible-core |
ansible_os_family | ansible_facts['os_family'] | Deprecation warning, default flips in 2.24 |
when: some_string | when: some_string | bool or | length > 0 | Error since 2.19 |
-c paramiko | The default ssh connection | the connection plugin 'paramiko' was not found |
interpreter_python = auto_legacy | auto or auto_silent | Removed in 2.21 |
transport = smart | Leave 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_repository | ansible.builtin.deb822_repository | Deprecated in 2.21 |
ansible -o, --inventory-file | Callback config, -i | Deprecation 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_hostin the inventory Permission denied: setansible_userandansible_ssh_private_key_fileHost key verification failed: add the host withssh-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 valuewhen: deploy_env | length > 0for any non-empty stringwhen: enable_tls | boolfor 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.