Ansible One-Liners: ad-hoc commands, quick checks and safe change
ansible web -m ping; ansible all -m command -a uptime; ansible-playbook site.yml --check --diff
The Ansible sheet on this site explains the pieces (inventory, playbooks, roles, vault). This guide is the practical side: the one-line commands you reach for every day to ask a question of many servers or change one thing on all of them, how to read the results, how to preview a change before you make it, and how to debug the errors you will meet.
To keep every example safe and repeatable, they all ran for real against a LAB inventory in which several host NAMES (web01, web02, db01) are all connected to this one machine with ansible_connection=local. Nothing here touched another computer. In real life those names are your servers and the connection is SSH; the commands and the output look the same. ansible-core 2.21 was used. Commands that need root or change the system are shown without output.
The lab: an inventory that runs safely anywhere
An inventory lists the hosts and groups Ansible can talk to. Here are three hosts in two groups (plus a parent group), with a variable for the web group. The ansible_connection=local part is the lab trick: it means "run it right here" instead of SSH.
$ cat > inventory.ini <<'EOF'
> [web]
> web01 ansible_connection=local
> web02 ansible_connection=local
>
> [db]
> db01 ansible_connection=local
>
> [prod:children]
> web
> db
>
> [web:vars]
> http_port=8080
> EOF
$ ansible-inventory -i inventory.ini --graph
@all:
|--@ungrouped:
|--@prod:
| |--@web:
| | |--web01
| | |--web02
| |--@db:
| | |--db01
$ ansible-inventory -i inventory.ini --host web01
{
"ansible_connection": "local",
"http_port": 8080
}
Real inventories name real servers (web01.example.com ansible_host=10.0.0.11 ansible_user=deploy). The default inventory is /etc/ansible/hosts or the one in ansible.cfg, so in a project you usually drop the -i. A quick inventory for a one-off, without a file: give a comma-separated list (the trailing comma matters, it tells Ansible this is a list and not a filename):
$ ansible all -i 'web01,web02,' -c local -m command -a 'echo one-off inventory works' -o
web01 | CHANGED | rc=0 | (stdout) one-off inventory works
web02 | CHANGED | rc=0 | (stdout) one-off inventory works
Anatomy of an ad-hoc command
ansible PATTERN -i INVENTORY -m MODULE -a 'ARGUMENTS' [options]
PATTERN which hosts: a host, a group, or an expression (see the next section)
-m MODULE what to do: ping, command, copy, file, apt, service ... (a module is a small program Ansible runs on the host)
-a 'ARGS' the module's arguments, as key=value pairs or a plain command line
Options you will use constantly:
-b become: run with sudo (root). Add -K to be asked for the sudo password
-u USER, -k connect as USER, ask for the SSH password (key-based login needs neither)
-f N forks: how many hosts to work on in parallel (default 5)
-o one line per host: perfect for short answers
-C --check dry run: show what WOULD change, change nothing
-D --diff show the actual text changes of files
-e 'var=value' set a variable (-e @file.yml loads a file)
-l / --limit restrict to a subset of the pattern
-v -vv -vvv more detail (the connection details appear at -vvv)
$ ansible web -i inventory.ini -m ping
web01 | SUCCESS => {
"changed": false,
"ping": "pong"
}
web02 | SUCCESS => {
"changed": false,
"ping": "pong"
}
$ ansible all -i inventory.ini -m ping -o
web01 | SUCCESS => {"changed": false,"ping": "pong"}
web02 | SUCCESS => {"changed": false,"ping": "pong"}
db01 | SUCCESS => {"changed": false,"ping": "pong"}
ping is not ICMP: it checks that Ansible can connect, find Python and run a module on the host. It is the first command to run against any new server ("pong" means all of that works). SUCCESS or CHANGED means the module ran; changed:false means it made no change.
Host patterns: picking exactly the hosts you mean
$ ansible all -i inventory.ini --list-hosts
hosts (3):
web01
web02
db01
$ ansible web -i inventory.ini --list-hosts
hosts (2):
web01
web02
$ ansible 'web:db' -i inventory.ini --list-hosts
hosts (3):
web01
web02
db01
$ ansible 'prod:!db' -i inventory.ini --list-hosts
hosts (2):
web01
web02
$ ansible 'prod:&web' -i inventory.ini --list-hosts
hosts (2):
web01
web02
$ ansible 'web[0]' -i inventory.ini --list-hosts
hosts (1):
web01
$ ansible '~web0[12]' -i inventory.ini --list-hosts
hosts (2):
web01
web02
$ ansible all -i inventory.ini -l web02 --list-hosts
hosts (1):
web02
$ ansible all -i inventory.ini -l 'web*,!web02' --list-hosts
hosts (1):
web01
Patterns: all (everything), a group or host name, A:B (either: union), A:&B (in both: intersection), A:!B (in A but not in B), a[0] (first of a group) and a[0:1] (a slice), * wildcards, and ~regex. Always run --list-hosts FIRST when the command will change things: it shows the exact hosts that will be touched, without touching them. -l (--limit) narrows whatever the pattern selected, which is how you run on one canary host before all of them. A pattern that matches nothing is not silent:
$ ansible nosuch -i inventory.ini -m ping 2>&1 | tail -1
[WARNING]: No hosts matched, nothing to do
Facts and variables: asking the hosts about themselves
$ ansible db01 -i inventory.ini -m setup -a 'filter=ansible_system'
db01 | SUCCESS => {
"ansible_facts": {
"ansible_system": "Linux"
},
"changed": false
}
$ ansible web -i inventory.ini -m setup -a 'filter=ansible_os_family' -o
web01 | SUCCESS => {"ansible_facts": {"ansible_os_family": "Debian"},"changed": false}
web02 | SUCCESS => {"ansible_facts": {"ansible_os_family": "Debian"},"changed": false}
$ ansible web01 -i inventory.ini -m setup -a 'filter=ansible_pkg_mgr'
web01 | SUCCESS => {
"ansible_facts": {
"ansible_pkg_mgr": "apt"
},
"changed": false
}
$ ansible web01 -i inventory.ini -m debug -a 'var=http_port'
web01 | SUCCESS => {
"http_port": 8080
}
$ ansible all -i inventory.ini -m debug -a 'msg="{{ inventory_hostname }} is in {{ group_names | join(\",\") }}"'
web01 | SUCCESS => {
"msg": "web01 is in prod,web"
}
web02 | SUCCESS => {
"msg": "web02 is in prod,web"
}
db01 | SUCCESS => {
"msg": "db01 is in db,prod"
}
$ ansible web01 -i inventory.ini -m debug -a 'msg="override wins: {{ http_port }}"' -e http_port=9090
web01 | SUCCESS => {
"msg": "override wins: 9090"
}
setup collects "facts" (OS, CPU, memory, disks, addresses). The filter= argument keeps the output short (it accepts wildcards: filter=ansible_distribution*, filter=ansible_default_ipv4). The same facts are variables in playbooks: ansible_distribution, ansible_os_family, ansible_hostname, ansible_memtotal_mb, ansible_default_ipv4.address, ansible_mounts. debug prints variables and messages; -e on the command line beats every other place a variable is defined.
$ ansible all -m setup -a 'filter=ansible_distribution*' -o # on real servers: which OS and release is each one?
$ ansible all -m setup -a 'filter=ansible_default_ipv4' -o
$ ansible all -m setup -a 'gather_subset=min' -o # quicker: only the basic facts
$ ansible web -m setup --tree facts/ # save every host's facts to one file per host in facts/
Running commands: command, shell, raw, script
$ ansible all -i inventory.ini -m command -a 'echo hello from {{ inventory_hostname }}' -o
web01 | CHANGED | rc=0 | (stdout) hello from web01
web02 | CHANGED | rc=0 | (stdout) hello from web02
db01 | CHANGED | rc=0 | (stdout) hello from db01
$ ansible web01 -i inventory.ini -m command -a 'uname -s'
web01 | CHANGED | rc=0 >>
Linux
$ ansible web01 -i inventory.ini -m command -a 'echo one | tr a-z A-Z' -o
web01 | CHANGED | rc=0 | (stdout) one | tr a-z A-Z
$ ansible web01 -i inventory.ini -m shell -a 'echo one | tr a-z A-Z'
web01 | CHANGED | rc=0 >>
ONE
$ ansible web01 -i inventory.ini -a 'echo no -m means the command module' -o
web01 | CHANGED | rc=0 | (stdout) no -m means the command module
Choosing the right one:
command runs a program directly (no shell): safe and predictable, but no pipes, redirects, wildcards or variables like $HOME (look at the output of the command-with-a-pipe example above: the pipe was handed to echo as plain text, nothing was piped). THE DEFAULT when you leave out -m.
shell runs through /bin/sh: pipes, &&, redirects, wildcards work. Use only when you need them.
raw sends the text over SSH with no Python on the target (for bootstrapping a bare machine, routers and switches).
script copies a LOCAL script to the host and runs it there (the script does not have to be on the host first).
Both command and shell always report "changed", because Ansible cannot know whether a command changed anything. Make them idempotent with creates= and removes=, which skip the command when the file already exists (or is missing):
$ ansible web01 -i inventory.ini -m command -a 'touch marker.txt creates=marker.txt'
web01 | CHANGED | rc=0 >>
$ ansible web01 -i inventory.ini -m command -a 'touch marker.txt creates=marker.txt'
web01 | SUCCESS | rc=0 >>
skipped, since marker.txt existsDid not run command since 'marker.txt' exists
The second run says "skipped": the marker exists, so the command was not repeated. Whenever there is a real module for the job (file, copy, apt, service, user), use it instead of command: modules check the current state and only change what differs.
Questions you ask many servers at once (these are all read-only: safe on real fleets):
$ ansible all -m command -a 'uptime' -o
$ ansible all -m shell -a 'df -h / | tail -1' -o
$ ansible all -m shell -a 'free -m | awk "/Mem:/ {print \$3\"/\"\$2\" MB used\"}"' -o
$ ansible all -m command -a 'uname -r' -o # which kernel is each machine running?
$ ansible all -m stat -a 'path=/var/run/reboot-required' -o # is a reboot pending? (Ubuntu)
$ ansible all -m shell -a 'systemctl is-active nginx' -o
$ ansible all -m command -a 'cat /etc/os-release' --limit 'web01'
$ ansible all -i inventory.ini -m shell -a 'id -u' -o
web01 | CHANGED | rc=0 | (stdout) 1000
web02 | CHANGED | rc=0 | (stdout) 1000
db01 | CHANGED | rc=0 | (stdout) 1000
$ ansible web01 -i inventory.ini -m file -a 'path=labdir/site state=directory mode=0755'
web01 | CHANGED => {
"changed": true,
"gid": 1000,
"group": "demo",
"mode": "0755",
"owner": "demo",
"path": "labdir/site",
"size": 4096,
"state": "directory",
"uid": 1000
}
$ ansible web01 -i inventory.ini -m file -a 'path=labdir/site state=directory mode=0755'
web01 | SUCCESS => {
"changed": false,
"gid": 1000,
"group": "demo",
"mode": "0755",
"owner": "demo",
"path": "labdir/site",
"size": 4096,
"state": "directory",
"uid": 1000
}
$ ansible web01 -i inventory.ini -m copy -a 'content="listen 8080;\nserver_name example.test;\n" dest=labdir/site/app.conf mode=0644'
web01 | CHANGED => {
"changed": true,
"checksum": "68602839c62236e69f4e790154c3ebab66dfe040",
"dest": "labdir/site/app.conf",
"gid": 1000,
"group": "demo",
"md5sum": "0a1ff6ea5dce66ec1ded009d85c391b0",
"mode": "0644",
"owner": "demo",
"size": 39,
"src": "~demo/.ansible/tmp/ansible-tmp-<timestamp>-<pid>-<id>/.source.conf",
"state": "file",
"uid": 1000
}
$ cat labdir/site/app.conf
listen 8080;
server_name example.test;
$ ansible web01 -i inventory.ini -m copy -a 'content="listen 8080;\nserver_name example.test;\n" dest=labdir/site/app.conf mode=0644' -o
web01 | SUCCESS => {"changed": false,"checksum": "68602839c62236e69f4e790154c3ebab66dfe040","dest": "labdir/site/app.conf","gid": 1000,"group": "demo","mode": "0644","owner": "demo","path": "labdir/site/app.conf","size": 39,"state": "file","uid": 1000}
Run it twice and read the CHANGED / SUCCESS words: a module that is "idempotent" does nothing the second time. That property is what makes Ansible safe to run again and again. Always preview with --check --diff:
$ ansible web01 -i inventory.ini -m lineinfile -a 'path=labdir/site/app.conf regexp="^listen" line="listen 9090;"' --check --diff
--- before: labdir/site/app.conf (content)
+++ after: labdir/site/app.conf (content)
@@ -1,2 +1,2 @@
-listen 8080;
+listen 9090;
server_name example.test;
web01 | CHANGED => {
"backup": "",
"changed": true,
"msg": "line replaced"
}
$ cat labdir/site/app.conf | head -1
listen 8080;
(check mode printed the diff but the file still says "listen 8080": nothing was changed.) Now do it for real, with a backup of the old file:
$ ansible web01 -i inventory.ini -m lineinfile -a 'path=labdir/site/app.conf regexp="^listen" line="listen 9090;" backup=yes' | grep -E 'changed|msg'
"changed": true,
"msg": "line replaced"
$ cat labdir/site/app.conf
listen 9090;
server_name example.test;
$ ls labdir/site | sed -E 's/^(app.conf)\..*~$/\1.<pid>.<timestamp>~/'
app.conf
app.conf.<pid>.<timestamp>~
$ ansible web01 -i inventory.ini -m lineinfile -a 'path=labdir/site/app.conf regexp="^server_name" state=absent' | grep -E 'changed|msg'
"changed": true,
"msg": "1 line(s) removed"
$ ansible web01 -i inventory.ini -m blockinfile -a 'path=labdir/site/app.conf block="location / {\n return 200;\n}" marker="# {mark} managed by ansible"' | grep -E 'changed|msg'
"changed": true,
"msg": "Block inserted"
$ cat labdir/site/app.conf
listen 9090;
# BEGIN managed by ansible
location / {
return 200;
}
# END managed by ansible
$ ansible web01 -i inventory.ini -m replace -a 'path=labdir/site/app.conf regexp="return 200" replace="return 204"' | grep -E 'changed|msg'
"changed": true,
"msg": "1 replacements made",
$ ansible web01 -i inventory.ini -m stat -a 'path=labdir/site/app.conf' | grep -E '"(exists|mode|size|isreg)"'
"exists": true,
"isreg": true,
"mode": "0644",
"size": 94,
$ ansible web01 -i inventory.ini -m fetch -a 'src=labdir/site/app.conf dest=fetched/ flat=yes' | grep -E 'changed|dest'
"changed": true,
"dest": "/home/demo/fetched/app.conf",
$ ansible web01 -i inventory.ini -m file -a 'path=labdir/site state=absent' | grep -E 'changed|state'
"changed": true,
"state": "absent"
What they are for:
file create folders and links, set owner and permissions, delete (state=directory | touch | link | absent)
copy put a file or literal text on the host (src=local file or content=text), with checksum comparison
lineinfile make sure ONE line is present, replaced (regexp=) or absent: for config files you do not own entirely
blockinfile manage a whole block between marker comments
replace regex substitution across a file (like sed -i, with idempotence)
template render a Jinja2 template from the control machine to the host (used in playbooks; see below)
fetch pull a file from the hosts back to the control machine (one folder per host unless flat=yes)
stat facts about a path: exists, mode, size, checksum (read-only: great for checks)
get_url, unarchive, find download a file, unpack an archive, search for files by age or size
The same on real servers (shown without output, they need your hosts):
$ ansible web -b -m copy -a 'src=nginx.conf dest=/etc/nginx/nginx.conf owner=root mode=0644 backup=yes validate="nginx -t -c %s"'
$ ansible all -b -m lineinfile -a 'path=/etc/ssh/sshd_config regexp="^#?PermitRootLogin" line="PermitRootLogin no"' --check --diff
$ ansible all -b -m file -a 'path=/var/www state=directory owner=www-data group=www-data mode=0755 recurse=no'
$ ansible all -m find -a 'paths=/var/log patterns="*.log" size=500m recurse=yes' -o # big log files
$ ansible all -b -m fetch -a 'src=/etc/hostname dest=/tmp/hosts/ ' ; ls /tmp/hosts # one subfolder per host
validate= runs a command on the new file BEFORE it replaces the old one (%s is the file): a config that does not pass is not installed, which is how you avoid breaking a service with a typo.
Packages, services, users, cron: the daily admin modules
These change the system and need root, so they are shown without output. The pattern is always: state the DESIRED result, let the module work out the steps.
$ ansible web -b -m apt -a 'update_cache=yes cache_valid_time=3600' # apt update (at most once an hour)
$ ansible web -b -m apt -a 'name=nginx state=present' # installed (any version)
$ ansible web -b -m apt -a 'name=nginx state=latest' # installed AND upgraded
$ ansible web -b -m apt -a 'name=nginx=1.24.0-2ubuntu7 state=present' # an exact version
$ ansible web -b -m apt -a 'upgrade=dist autoremove=yes' --check # what a full upgrade would do
$ ansible all -b -m package -a 'name=curl state=present' # works on apt, dnf, zypper and more
$ ansible rhel -b -m dnf -a 'name=httpd state=latest'
$ ansible web -b -m service -a 'name=nginx state=started enabled=yes' # running now AND at boot
$ ansible web -b -m service -a 'name=nginx state=restarted'
$ ansible web -b -m systemd -a 'name=nginx daemon_reload=yes state=reloaded'
$ ansible all -b -m user -a 'name=alice shell=/bin/bash groups=sudo append=yes'
$ ansible all -b -m user -a 'name=alice state=absent remove=yes'
$ ansible all -b -m authorized_key -a 'user=alice key="{{ lookup(\"file\", \"~/.ssh/id_ed25519.pub\") }}"'
$ ansible all -b -m cron -a 'name="nightly backup" minute=30 hour=2 job="/usr/local/bin/backup.sh"'
$ ansible all -b -m hostname -a 'name={{ inventory_hostname }}'
$ ansible all -b -m sysctl -a 'name=vm.swappiness value=10 state=present reload=yes'
$ ansible all -b -m reboot -a 'reboot_timeout=600' # reboots and WAITS until the host answers again
$ ansible web -b -m ufw -a 'rule=allow port=443 proto=tcp'
$ ansible all -b -m apt -a 'name=nginx state=present' --check --diff -l web01 # ALWAYS: preview on one host first
state= is the heart of it: present/absent, started/stopped/restarted/reloaded, latest, directory/touch/link. Because the module compares the current state with the requested one, running the same command again changes nothing.
Look up any module without leaving the terminal
$ ansible-doc -t module ansible.builtin.lineinfile | sed -n '1,8p'
> MODULE ansible.builtin.lineinfile (/home/demo/site-tools/handbook/venv-ansible/lib/python3.12/site-packages/ansible/modules/lineinfile.py)
This module ensures a particular line is in a file, or replace an
existing line using a back-referenced regular expression.
This is primarily useful when you want to change a single line in a
file only.
See the [ansible.builtin.replace] module if you want to change
multiple, similar lines or check [ansible.builtin.blockinfile] if
$ ansible-doc -s ansible.builtin.file | sed -n '1,14p'
- name: Manage files and file properties
file:
access_time: # This parameter indicates the time the file's
# access time should
# be set to. Should
# be `preserve' when
# no modification is
# required,
# `YYYYMMDDHHMM.SS'
# when using default
# time format, or
# `now'. Default is
# `None' meaning that
# `preserve' is the
$ ansible-doc -l ansible.builtin 2>/dev/null | grep -E '^ansible.builtin.(apt|copy|file|service|user) ' | cut -c1-90
ansible.builtin.apt Manages apt-packages
ansible.builtin.copy Copy files to remote locations
ansible.builtin.file Manage files and file properties
ansible.builtin.service Manage services
ansible.builtin.user Manage user accounts
$ ansible-doc ansible.builtin.copy | sed -n '/^EXAMPLES/,/^$/p' | head -12
EXAMPLES:
- name: Copy file with owner and permissions
ansible.builtin.copy:
src: /srv/myfiles/foo.conf
dest: /etc/foo.conf
owner: foo
group: foo
mode: '0644'
ansible-doc NAME shows the full documentation of a module (arguments, defaults, examples, return values); -s prints a ready-to-edit snippet; -l lists what is installed. The documentation on the web matches your version only if you pick the right release: ansible-doc always matches the one you run.
From one-liner to quick playbook
When a one-liner gets long or has to run several steps, put it in a playbook: it is the same modules in a file you can keep in Git. A small example that installs a config from a template and reloads when it changes (the reload is a "handler": it runs once at the end, and only if something notified it):
$ cat > templates/app.conf.j2 <<'EOF'
> # managed by Ansible: do not edit by hand
> listen {{ http_port | default(8080) }};
> server_name {{ inventory_hostname }}.example.test;
> worker_processes {{ workers | default(2) }};
> EOF
$ cat > site.yml <<'EOF'
> - name: Configure the web servers
> hosts: web
> vars:
> conf_dir: "labconf/{{ inventory_hostname }}" # on real servers: /etc/myapp (the lab gives each host its own folder because all three live on this one machine)
> tasks:
> - name: The config folder exists
> ansible.builtin.file:
> path: "{{ conf_dir }}"
> state: directory
> mode: "0755"
>
> - name: The app config is rendered from the template
> ansible.builtin.template:
> src: templates/app.conf.j2
> dest: "{{ conf_dir }}/app.conf"
> mode: "0644"
> notify: Reload the app
>
> handlers:
> - name: Reload the app
> ansible.builtin.debug:
> msg: "pretend: systemctl reload app (only when the config changed)"
> EOF
$ ansible-playbook -i inventory.ini site.yml --syntax-check
playbook: site.yml
$ ansible-playbook -i inventory.ini site.yml --list-tasks
playbook: site.yml
play #1 (web): Configure the web servers TAGS: []
tasks:
The config folder exists TAGS: []
The app config is rendered from the template TAGS: []
$ ansible-playbook -i inventory.ini site.yml --check --diff
PLAY [Configure the web servers] ***********************************************
TASK [Gathering Facts] *********************************************************
ok: [web01]
ok: [web02]
TASK [The config folder exists] ************************************************
--- before
+++ after
@@ -1,4 +1,4 @@
{
"path": "labconf/web01",
- "state": "absent"
+ "state": "directory"
}
changed: [web01]
--- before
+++ after
@@ -1,4 +1,4 @@
{
"path": "labconf/web02",
- "state": "absent"
+ "state": "directory"
}
changed: [web02]
TASK [The app config is rendered from the template] ****************************
--- before
+++ after: /home/demo/.ansible/tmp/ansible-local-<id>/tmp<id>/app.conf.j2
@@ -0,0 +1,4 @@
+# managed by Ansible: do not edit by hand
+listen 8080;
+server_name web01.example.test;
+worker_processes 2;
changed: [web01]
--- before
+++ after: /home/demo/.ansible/tmp/ansible-local-<id>/tmp<id>/app.conf.j2
@@ -0,0 +1,4 @@
+# managed by Ansible: do not edit by hand
+listen 8080;
+server_name web02.example.test;
+worker_processes 2;
changed: [web02]
RUNNING HANDLER [Reload the app] ***********************************************
ok: [web01] => {
"msg": "pretend: systemctl reload app (only when the config changed)"
}
ok: [web02] => {
"msg": "pretend: systemctl reload app (only when the config changed)"
}
PLAY RECAP *********************************************************************
web01 : ok=4 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02 : ok=4 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
$ ansible-playbook -i inventory.ini site.yml
PLAY [Configure the web servers] ***********************************************
TASK [Gathering Facts] *********************************************************
ok: [web01]
ok: [web02]
TASK [The config folder exists] ************************************************
changed: [web01]
changed: [web02]
TASK [The app config is rendered from the template] ****************************
changed: [web01]
changed: [web02]
RUNNING HANDLER [Reload the app] ***********************************************
ok: [web01] => {
"msg": "pretend: systemctl reload app (only when the config changed)"
}
ok: [web02] => {
"msg": "pretend: systemctl reload app (only when the config changed)"
}
PLAY RECAP *********************************************************************
web01 : ok=4 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02 : ok=4 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
$ ansible-playbook -i inventory.ini site.yml
PLAY [Configure the web servers] ***********************************************
TASK [Gathering Facts] *********************************************************
ok: [web01]
ok: [web02]
TASK [The config folder exists] ************************************************
ok: [web01]
ok: [web02]
TASK [The app config is rendered from the template] ****************************
ok: [web01]
ok: [web02]
PLAY RECAP *********************************************************************
web01 : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02 : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
$ cat labconf/web01/app.conf
# managed by Ansible: do not edit by hand
listen 8080;
server_name web01.example.test;
worker_processes 2;
The two real runs show the idempotence in numbers: the first run reports changed tasks and fires the handler; the second reports changed=0 and the handler does not run. The recap line is the summary to watch: ok (nothing to do), changed (it changed something), failed, unreachable, skipped. --check --diff showed the file that WOULD be created without creating it (the "before" side is empty because the file did not exist yet).
Override a value without editing any file, run on a subset, run only parts:
$ ansible-playbook -i inventory.ini site.yml -e workers=8 -l web01 | grep -E 'changed|ok='
changed: [web01]
"msg": "pretend: systemctl reload app (only when the config changed)"
web01 : ok=4 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
$ grep worker labconf/web01/app.conf labconf/web02/app.conf
labconf/web01/app.conf:worker_processes 8;
labconf/web02/app.conf:worker_processes 2;
$ ansible-playbook -i inventory.ini site.yml --tags nosuchtag | tail -3
web01 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
$ ansible-playbook site.yml --limit web01 # a canary: ONE host first
$ ansible-playbook site.yml --start-at-task "The app config is rendered from the template"
$ ansible-playbook site.yml --step # ask before each task
$ ansible-playbook site.yml --tags config --skip-tags slow # run only tagged tasks (add "tags: [config]" to a task)
$ ansible-playbook site.yml -e @vars/prod.yml -e 'workers=4'
Rolling out gently: process the hosts a few at a time, and stop if too many fail. In a play add serial and max_fail_percentage (these are play keywords, not command-line options):
$ cat <<'EOF'
> - hosts: web
> serial: 1 # one host at a time (or "25%", or [1, 5, 10] to widen gradually)
> max_fail_percentage: 0 # stop the whole rollout if any host fails
> tasks:
> - ansible.builtin.service: {name: nginx, state: restarted}
> EOF
- hosts: web
serial: 1 # one host at a time (or "25%", or [1, 5, 10] to widen gradually)
max_fail_percentage: 0 # stop the whole rollout if any host fails
tasks:
- ansible.builtin.service: {name: nginx, state: restarted}
Making sure of what you are about to do
The rule: preview, narrow, then run.
1. --list-hosts exactly which machines will be touched
2. --check --diff exactly what would change (modules that cannot predict, such as command and shell, are skipped in check mode)
3. -l one-host run it for real on ONE host and look
4. then the rest (with serial: when each step can break service)
Some commands that help you read the result:
$ ansible web -i inventory.ini -m command -a 'echo hi' -o
web01 | CHANGED | rc=0 | (stdout) hi
web02 | CHANGED | rc=0 | (stdout) hi
$ ansible-playbook -i inventory.ini site.yml -e workers=8 | grep -E '^(web01|web02)' | sed 's/ *: */: /'
web01: ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02: ok=4 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
$ ANSIBLE_CALLBACK_RESULT_FORMAT=yaml ansible web01 -i inventory.ini -m stat -a 'path=labconf/web01/app.conf' | sed -n '1,8p'
web01 | SUCCESS =>
changed: false
stat:
atime: 1790902959.3316503
attr_flags: e
attributes:
- extents
block_size: 4096
$ ansible web01 -i inventory.ini -m setup -a 'filter=ansible_system' -t results
web01 | SUCCESS => {
"ansible_facts": {
"ansible_system": "Linux"
},
"changed": false
}
$ cat results/web01 | python3 -c 'import json,sys; print(json.load(sys.stdin)["ansible_facts"]["ansible_system"])'
Linux
-o is the compact form; the default output is JSON; ANSIBLE_CALLBACK_RESULT_FORMAT=yaml (or result_format = yaml in ansible.cfg) prints the same result as YAML, easier to read; -t DIR saves each host's result in its own file for scripts to parse. In a playbook, register the result and use debug and failed_when / changed_when to control and inspect it.
Secrets: ansible-vault one-liners
$ ansible-vault encrypt_string 's3cr3t-db-password' --name db_password --vault-password-file .vaultpass | sed -n '1,2p'
db_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
$ printf 'db_password: s3cr3t-db-password\napi_token: abc123\n' > secrets.yml
$ ansible-vault encrypt secrets.yml --vault-password-file .vaultpass
$ head -1 secrets.yml
$ANSIBLE_VAULT;1.1;AES256
$ ansible-vault view secrets.yml --vault-password-file .vaultpass
db_password: s3cr3t-db-password
api_token: abc123
$ ansible web01 -i inventory.ini -m debug -a 'var=db_password' -e @secrets.yml --vault-password-file .vaultpass
web01 | SUCCESS => {
"db_password": "s3cr3t-db-password"
}
$ ansible-vault rekey secrets.yml --vault-password-file .vaultpass --new-vault-password-file .vaultpass 2>&1 | tail -1
Rekey successful
$ ansible web01 -i inventory.ini -m debug -a 'var=db_password' -e @secrets.yml 2>&1 | sed -n '1,2p'
[WARNING]: Failed to parse inventory with 'ini' plugin: Attempting to decrypt but no vault secrets found.
encrypt_string makes one encrypted value to paste into a normal YAML file; encrypt makes a whole file unreadable; the password comes from a file (keep it OUT of the repository, mode 600) or from a prompt (--ask-vault-pass). Without the password Ansible refuses, as the last example shows. In tasks that handle secrets add no_log: true so passwords never appear in output or logs. Set vault_password_file in ansible.cfg to stop typing it.
$ ansible-vault create group_vars/prod/vault.yml # opens your editor on a new encrypted file
$ ansible-vault edit group_vars/prod/vault.yml
$ ansible-playbook site.yml --ask-vault-pass
Long jobs and speed
$ ansible web -m command -a 'tar czf /backup/www.tgz /var/www' -b -B 3600 -P 15 # run in the BACKGROUND for up to 3600 s, poll every 15 s (no SSH timeout on slow jobs)
$ ansible web -m command -a 'sleep 600' -B 700 -P 0 # fire and forget: prints a job id
$ ansible web -m async_status -a 'jid=123456.7890' # ask how that job is doing
$ ansible all -m ping -f 20 # 20 hosts at once (default 5): size it to your control machine and network
Speed settings for ansible.cfg (a project file; the example is what most people set first):
$ cat <<'EOF'
> [defaults]
> inventory = ./inventory.ini
> forks = 20
> gathering = smart ; collect facts once, cache them
> fact_caching = jsonfile
> fact_caching_connection = ./.facts
> host_key_checking = True
> stdout_callback = default
> result_format = yaml
>
> [ssh_connection]
> pipelining = True ; fewer SSH round trips (needs requiretty off in sudoers)
> ssh_args = -o ControlMaster=auto -o ControlPersist=60s
> EOF
[defaults]
inventory = ./inventory.ini
forks = 20
gathering = smart ; collect facts once, cache them
fact_caching = jsonfile
fact_caching_connection = ./.facts
host_key_checking = True
stdout_callback = default
result_format = yaml
[ssh_connection]
pipelining = True ; fewer SSH round trips (needs requiretty off in sudoers)
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
Why a playbook run feels slow: fact gathering (turn it off with gather_facts: false when you do not use facts), many tiny tasks (use loops and list arguments such as name=[a,b,c] to install many packages in one call), and one SSH connection per task (pipelining and ControlPersist reuse it).
Errors you will meet, with the real messages
$ ansible web01 -i inventory.ini -m debug -a 'msg="{{ no_such_variable }}"'
[ERROR]: Task failed: Finalization of task args for 'ansible.builtin.debug' failed: Error while resolving value for 'msg': 'no_such_variable' is undefined
Task failed.
Origin: <adhoc 'debug' task>
{'action': 'debug', 'args': {'msg': '{{ no_such_variable }}'}, 'timeout': 0, 'async_val': 0, 'poll': 15}
<<< caused by >>>
Finalization of task args for 'ansible.builtin.debug' failed.
Origin: <CLI option '-m'>
debug
<<< caused by >>>
Error while resolving value for 'msg': 'no_such_variable' is undefined
Origin: <CLI option '-a'>
{{ no_such_variable }}
web01 | FAILED! => {
"changed": false,
"msg": "Task failed: Finalization of task args for 'ansible.builtin.debug' failed: Error while resolving value for 'msg': 'no_such_variable' is undefined"
}
# (exit status 2)
$ ansible-playbook -i inventory.ini broken.yml --syntax-check
[ERROR]: YAML parsing failed: Mapping values are not allowed in this context.
Origin: /home/demo/broken.yml:2:6
1 app: web
2 bad: indent
^ column 6
# (exit status 4)
$ ansible web01 -i inventory.ini -m copy -a 'content=x dest=labdir/x colour=red'
[ERROR]: Task failed: Module failed: Unsupported parameters for (ansible.legacy.copy) module: colour. Supported parameters include: _original_basename, attributes, backup, checksum, content, dest, directory_mode, follow, force, group, local_follow, mode, owner, remote_src, selevel, serole, setype, seuser, src, unsafe_writes, validate (attr).
Origin: <adhoc 'copy' task>
{'action': 'copy', 'args': {'content': 'x', 'dest': 'labdir/x', 'colour': 'red'}, 'timeout': 0, 'async_val': 0, [...]
web01 | FAILED! => {
"changed": false,
"checksum": "11f6ad8ec52a2984abaafd7c3b516503785c2072",
"msg": "Unsupported parameters for (ansible.legacy.copy) module: colour. Supported parameters include: _original_basename, attributes, backup, checksum, content, dest, directory_mode, follow, force, group, local_follow, mode, owner, remote_src, selevel, serole, setype, seuser, src, unsafe_writes, validate (attr)."
}
# (exit status 2)
$ ansible -i '127.0.0.1,' -c ssh -e ansible_port=9 -m ping all
[ERROR]: Task failed: Failed to connect to the host via ssh: ssh: connect to host 127.0.0.1 port 9: Connection refused
Origin: <adhoc 'ping' task>
{'action': 'ping', 'args': {}, 'timeout': 0, 'async_val': 0, 'poll': 15}
127.0.0.1 | UNREACHABLE! => {
"changed": false,
"msg": "Task failed: Failed to connect to the host via ssh: ssh: connect to host 127.0.0.1 port 9: Connection refused",
"unreachable": true
}
# (exit status 4)
$ ansible web01 -i inventory.ini -m nosuchmodule
[ERROR]: Task failed: Cannot resolve 'nosuchmodule' to an action or module.
Task failed.
Origin: <adhoc 'nosuchmodule' task>
{'action': 'nosuchmodule', 'args': {}, 'timeout': 0, 'async_val': 0, 'poll': 15}
<<< caused by >>>
Cannot resolve 'nosuchmodule' to an action or module.
Origin: <CLI option '-m'>
nosuchmodule
web01 | FAILED! => {
"changed": false,
"msg": "Task failed: Cannot resolve 'nosuchmodule' to an action or module."
}
# (exit status 2)
Message Cause and first move
UNREACHABLE! ... Failed to connect to the host via ssh the host is down, the name or port is wrong, a firewall, or an SSH key problem: try ssh user@host by hand with the same options; ansible -vvv shows the ssh command it runs
Permission denied (publickey,password) the key is not authorised for that user: -u USER, --private-key FILE, or ansible_user/ansible_ssh_private_key_file in the inventory
Missing sudo password / sudo: a password is required -b needs sudo: add -K (ask for the password), or configure passwordless sudo for the automation account
"msg": "Could not detect which package manager to use" the host has no Python or an odd one: set ansible_python_interpreter=/usr/bin/python3 (this also silences the interpreter-discovery warning)
The task includes an option with an undefined variable a typo, or a group_vars/host_vars file that was not loaded: ansible-inventory --host NAME lists every variable a host really gets
Unsupported parameters for module a wrong or misspelled argument for that module: ansible-doc -s MODULE
ERROR! Syntax Error while loading YAML YAML indentation (spaces only, never tabs): ansible-playbook --syntax-check, or a YAML linter
couldn't resolve module/action a typo, or a module from a collection you have not installed: ansible-galaxy collection install community.general
Could not match supplied host pattern the group or host is not in this inventory: ansible-inventory --graph
"changed" on every run for a command task command and shell always report changed: use creates=/removes=, or set changed_when in a playbook
Host key verification failed the host key is new or changed: verify it (ssh-keyscan), add it to known_hosts; do NOT turn host_key_checking off in production
Safe habits
Preview first (--list-hosts, --check --diff), then ONE host (-l), then the fleet (serial).
Use a real module instead of command/shell whenever one exists: it is idempotent, supports check mode, and reports honestly.
Keep playbooks, inventories and group_vars in Git. Keep secrets in vault files and the vault password outside the repository.
Name tasks clearly (the name is what the log shows), tag them, and keep tasks small.
Run automation as a dedicated account with limited sudo, from a controlled host or CI system, not from a laptop with your personal key.
Pin versions: ansible-core and collections in a requirements file, so that the same playbook behaves the same next month.
Test on a staging copy or a container first (ansible_connection=docker or local) and use ansible-lint on playbooks.
Never put real passwords on the command line (-e db_password=...): they land in shell history and process lists. Use vault files.
"It ran, so it must have worked" is not verification: read the recap, check the service, and keep the playbook idempotent so that running it again is the test.
Tips
Learn ping, setup with a filter, command, copy, file, lineinfile, service and the --check --diff pair first. That is most of daily Ansible.
-o for short answers, -l for a canary, --list-hosts before anything that changes things.
ansible-doc, ansible-inventory --host and -vvv answer "why" without leaving the terminal.
The inventory is the map. A wrong group in it is how "all the web servers" ends up including a database.
Idempotence is the feature: a command you are scared to run twice is a command that needs a better module.
The best Ansible runs are boring: ok, ok, ok, changed=0.