SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Ansible ad-hoc commands you will actually use

Run one Ansible task on many servers without writing a playbook. Learn host patterns, become, forks and dry runs, plus the modules a VPS admin uses every week.

What an Ansible ad-hoc command is

An Ansible ad-hoc command runs one module against one or more servers from a single command line. It needs no playbook file. You use it to check or fix something right now, such as making sure every host answers or restarting nginx on the web group. The Ansible docs give the trade-off in one line: "ad hoc commands are quick and easy, but they are not reusable."

The first-playbook tutorial only previews ad-hoc commands, and this guide is the full reference. It covers the flags that decide which hosts run, and the modules a VPS operator uses most. It ends with the point where a one-off line should become a playbook.

You run every command here from your own machine, against your own inventory. Results depend on your hosts. So this guide does not print sample output. It tells you what a healthy result looks like.

Install ansible-core on the control node

The control node is the machine where you type the commands. The managed nodes are the servers it connects to over SSH (secure shell). The installation guide says a managed node "does not require Ansible to be installed, but requires Python to run Ansible-generated Python code." So your servers need Python and nothing else.

As of 2 November 2026, PyPI lists ansible-core 2.22.0 as the latest release, and 2.21.6 as the latest 2.21 release. This guide pins 2.21.5, published on 5 October 2026, because the other Ansible guides on this site pin that release. ansible-core 2.21 needs Python 3.12 to 3.14 on the control node. It supports Python 3.9 to 3.14 on managed nodes. The 2.21 series gets only critical fixes from November 2026, and it reaches end of life in November 2027. Before you pin a version for the long term, check PyPI for a newer series.

python3 --version
sudo apt update && sudo apt install -y pipx
pipx ensurepath
pipx install ansible-core==2.21.5
ansible --version

python3 --version must report 3.12 or newer, because ansible-core 2.21.5 requires it. ansible --version should report core 2.21.5. If your shell says ansible: command not found, open a new terminal so the PATH change from pipx ensurepath takes effect.

The inventory these examples use

Ansible reads its list of hosts from an inventory. With no -i flag, ansible-core 2.21.5 reads /etc/ansible/hosts. To use a different file, pass -i or set the ANSIBLE_INVENTORY environment variable. Every example below uses this small INI file, saved as inventory.ini:

[webservers]
web1.example.com
web2.example.com

[dbservers]
db1.example.com

[staging]
web2.example.com
db1.example.com

Two of the hosts are in two groups each. The pattern examples later in this guide depend on that overlap. If groups and host variables are new to you, read how the Ansible inventory file works first. You can stop typing -i inventory.ini on every line: run export ANSIBLE_INVENTORY=$PWD/inventory.ini once in each shell. If your SSH login name on the servers is not your local user name, add -u and the remote name, for example -u deploy.

The shape of an ad-hoc command

The ad-hoc guide gives this shape:

ansible [pattern] -m [module] -a "[module options]"

The pattern picks the hosts. -m names the module. -a passes options to the module. Write them as key=value pairs separated by spaces, or as a JSON string. The default module is command. So when you only want to run a program, you can leave out -m.

ansible all -i inventory.ini -m ansible.builtin.ping
ansible webservers -i inventory.ini -a "uptime"
ansible webservers -i inventory.ini -m ansible.builtin.apt -a '{"name": "htop", "state": "present"}' -C

The second line has no -m. So Ansible uses the command module to run uptime on both web servers. The third line shows the JSON form of -a. It runs in check mode (-C), so it changes nothing. A later section covers check mode.

Your local shell must pass the whole -a value as one argument, so always put it in quotes. Use single quotes when the value contains $, ! or *. Inside single quotes, your local shell leaves those characters alone, so they reach Ansible exactly as you typed them.

Command vs shell: why pipes and globs fail

This is the most common surprise with ad-hoc commands. The default command module does not start a shell. It splits your string into words. Then it runs the program directly, with those words as arguments. The command module docs say: "The command(s) will not be processed through the shell". Pipes and wildcards are features of the shell. Without a shell, they do nothing.

ansible webservers -i inventory.ini -a "df -h | grep /dev"

Here df gets |, grep and /dev as extra arguments. It treats | as a file name, so the error comes from df, not from grep. That error from df proves no shell was involved. Globs (wildcard patterns such as *.log) fail the same way. Through command, ls /var/log/*.log gives ls a literal asterisk. The shell is what turns * into a list of file names, and no shell runs here.

Environment variables are the one exception. The command module expands variables such as $HOME in Python before it starts the program. Its docs note one difference from a shell: "If a variable is not matched, it is left unchanged, unlike shell substitution which would remove it."

When you need shell syntax, switch to the shell module:

ansible webservers -i inventory.ini -m ansible.builtin.shell -a 'df -h | grep /dev'
ansible webservers -i inventory.ini -m ansible.builtin.shell -a 'ls /var/log/*.log'
ansible webservers -i inventory.ini -m ansible.builtin.shell -a 'executable=/bin/bash cat < /tmp/*txt'

By default, the shell module runs the command through /bin/sh. The executable option changes the shell, and it needs an absolute path. The third line is the example from the module docs. The docs set /bin/bash for that command because /bin/sh cannot handle a redirect and a wildcard together.

The docs still recommend command whenever you do not need shell features, and the reason is quoting. The command docs warn: "Parsing shell metacharacters can lead to unexpected commands being executed if quoting is not done correctly". So use command by default. Use shell only when the line needs a pipe, a redirect, a glob or &&.

There is one more trap. In ansible webservers -a "df -h" | grep /dev, the pipe is outside the quotes. Your local shell runs it, and it filters Ansible's output on your own machine. That can be useful, but it does something different from the shell module example.

Become: what -b and -K each do

Most admin work needs root. Ansible calls privilege escalation "become". Two flags control it. They do different jobs, and you often need both.

  • -b or --become turns become on. The docs describe it as "run operations with become (no password implied)".
  • -K or --ask-become-pass asks you for the sudo password. The docs add that it "does not imply become will be used". Ansible also uses that one password "for all hosts".

The default method is sudo, and the default target user is root. --become-user changes the target user. It does not turn become on by itself, so you still need -b.

ansible webservers -i inventory.ini -b -K -a "whoami"

You type the password once, and every host should answer root. If your user can use sudo without a password on the servers, drop -K and use -b alone.

Why does -b without -K fail at once instead of waiting? In ansible-core 2.21.5, the sudo plugin's default flags are -H -S -n. The sudo manual says -n means "Avoid prompting the user for input of any kind". If a password is required, sudo "will display an error message and exit". When you pass -K, the plugin removes -n and adds its own prompt. So sudo waits for the password that Ansible sends. Without -K, a host that needs a password makes sudo print sudo: a password is required and exit. Ansible watches for that sudo message. It also watches for Sorry, try again., which sudo prints when the password is wrong. Both messages come from sudo, not from Ansible. Read your own output to see how Ansible reports them.

Host patterns: all, groups, exclusions and intersections

The pattern is the first argument after ansible. These forms come from the patterns guide:

  • all or * selects every host in the inventory.
  • webservers selects a group, and web1.example.com selects one host.
  • webservers:dbservers selects all hosts in either group.
  • webservers:!staging selects web servers that are not in staging.
  • webservers:&staging selects web servers that are also in staging.
  • ~web[12]\.example\.com is a regular expression, because the pattern starts with ~.

You can chain these forms. The guide's example is webservers:dbservers:&staging:!phoenix. Put any pattern that contains !, &, * or ~ in single quotes. Otherwise your local shell may act on those characters before Ansible sees them.

Before you run anything on a pattern, check what it matches. --list-hosts "outputs a list of matching hosts; does not execute anything else":

ansible 'webservers:!staging' -i inventory.ini --list-hosts
ansible 'webservers:&staging' -i inventory.ini --list-hosts

With the inventory above, the first line lists only web1.example.com. The second lists only web2.example.com. If a list is wrong, fix the pattern before you add a module.

Narrow a run with --limit

-l or --limit will "further limit selected hosts to an additional pattern". Use it to try a command on one host before you run it on the whole group:

ansible webservers -i inventory.ini -l web1.example.com -a "uptime"
ansible all -i inventory.ini --limit 'all:!db1.example.com' -a "uptime"

The patterns guide is strict about the second form: "Note that single quotes MUST be used to prevent bash interpolation." Inside double quotes, interactive bash reads !db1 as a history lookup. It can then change the command before Ansible sees it. A good habit is to keep the main pattern the same and change only --limit. Then the trial run and the full run differ by one flag.

Forks: how many hosts run at once

The ad-hoc guide says "By default, Ansible uses only five simultaneous processes." -f or --forks changes that number. Say you have 20 hosts and keep the default of 5. Ansible works on five hosts while the rest wait for a free process.

ansible all -i inventory.ini -f 20 -a "uptime"

To change the default for every run, set forks in the [defaults] section of ansible.cfg, or export ANSIBLE_FORKS. Each fork is a separate process on your control node. So a large number of forks uses more memory and CPU on your machine. A lower number is useful too. With -f 1, Ansible finishes one host before it starts the next. That is what you want for a rolling restart or a rolling reboot.

One-line output with -o

The docs describe -o or --one-line with two words: "condense output". It helps when you run a short command against many hosts. The result is easier to scan, and easier to filter with grep on your own machine. Run a command with and without -o on your own inventory to see the difference.

Dry runs: --check and --diff on ad-hoc commands

With -C or --check, Ansible will "don't make any changes; instead, try to predict some of the changes that may occur". -D or --diff shows the changes inside files and templates, and the CLI docs say it "works great with --check". Use both flags together to try a file change safely:

ansible webservers -i inventory.ini -b -m ansible.builtin.copy -a "src=./motd dest=/etc/motd owner=root group=root mode=0644" -C -D

Check mode is only as good as the module's support for it. The copy module fully supports both check mode and diff mode. So the line above shows the diff it would apply. Modules that do not support check mode "report nothing and do nothing".

command and shell are the important exception. Ansible cannot know what an arbitrary program would change. So in check mode, these modules skip the task unless you give them creates or removes. With one of those options, they predict a change from whether that path exists. A skipped task carries the message Command would have run if not in check mode. That message does not mean your command is safe. It means Ansible did not test the command at all. For check mode in playbooks, see check mode and dry runs in Ansible.

The modules a VPS operator actually uses

All of these modules ship in ansible.builtin, so you do not need to install a collection. The examples use full names such as ansible.builtin.apt, the same names the module docs use.

ping: can Ansible log in and run Python?

ansible all -i inventory.ini -m ansible.builtin.ping

The module docs say: "This is NOT ICMP ping, this is just a trivial test module that requires Python on the remote-node." A success proves two things: you can log in over SSH, and the host has a Python that Ansible can use. A healthy host returns pong. If a server answers ICMP ping but fails here, look for an SSH or Python problem, not a network problem.

setup: read facts, filtered

setup collects facts. The ad-hoc guide defines facts as "discovered variables about a system". The full output is long, so filter it:

ansible web1.example.com -i inventory.ini -m ansible.builtin.setup -a "filter=ansible_*_mb"
ansible all -i inventory.ini -m ansible.builtin.setup -a "filter=ansible_distribution*"

The first line comes from the module docs. It returns the memory facts, in megabytes. The second line is a filter of our own. It shows each host's distribution name and version. The filter uses shell-style wildcards, but the docs add a limit: "The filter option filters only the first level subkey below ansible_facts." So you can select all of ansible_default_ipv4, but not one field inside it. How Ansible facts and gather_facts work explains how to use these values in a playbook.

apt: install, update and upgrade packages

ansible webservers -i inventory.ini -b -m ansible.builtin.apt -a "name=htop state=present update_cache=true cache_valid_time=3600"
ansible webservers -i inventory.ini -b -m ansible.builtin.apt -a "upgrade=safe update_cache=true" -C
ansible webservers -i inventory.ini -b -m ansible.builtin.apt -a "name=openssl state=latest update_cache=true"

state defaults to present, and update_cache defaults to false. Without update_cache, apt uses the package lists that are already on the host. Those lists may be old if the server has not run apt update for a while. cache_valid_time is in seconds. The docs say it will "Update the apt cache if it is older than the cache_valid_time". With 3600, a second run within the same hour skips the update. Setting cache_valid_time also turns update_cache on.

upgrade=safe upgrades the installed packages. The docs say upgrade "does not upgrade a specific package, use state=latest for that". The third line uses state=latest to upgrade one package. The target host needs python3-apt for this module. The module fully supports check mode, so run a big upgrade with -C first.

systemd_service and service: start, stop, restart

ansible webservers -i inventory.ini -b -m ansible.builtin.systemd_service -a "name=nginx state=started enabled=true"
ansible webservers -i inventory.ini -b -m ansible.builtin.systemd_service -a "name=nginx state=restarted"
ansible webservers -i inventory.ini -b -m ansible.builtin.systemd_service -a "daemon_reload=true"

state=started and state=stopped act only when needed. Run the first line twice, and the second run should report no change. enabled=true makes the unit start at boot. daemon_reload=true makes systemd read changed unit files again. The module takes one exact unit name. The docs say "Globs are not supported in name", so postgres*.service does not work. The module also needs a host that runs systemd.

ansible.builtin.service is the generic module. It works with systemd and with several other init systems, including OpenRC and SysV. By default, it uses the host's facts to choose one. Use it when your servers run different init systems:

ansible all -i inventory.ini -b -m ansible.builtin.service -a "name=cron state=started"

A restart is an action, not a state. The service docs say "restarted will always bounce the service" and "reloaded will always reload". So every run of a restart line reports a change and interrupts the service. When downtime matters, add -f 1 or --limit.

copy: push a file to many hosts

The copy example in the check mode section shows the normal form. These options matter on a live server:

  • owner, group and mode set ownership and permissions in the same step. Write the mode with its leading zero, as in 0644.
  • backup=true saves a copy of the old file before Ansible replaces it. The default is false.
  • force defaults to true, so Ansible overwrites a file that has different content.
  • remote_src defaults to false, so src is a path on your control node, not on the server.

copy is a poor fit for large directory trees. The docs say it "does not scale to lots (>hundreds) of files". For a directory with many files, look at ansible.posix.synchronize, which the docs list as a related module.

user: add or remove an account

ansible all -i inventory.ini -b -m ansible.builtin.user -a "name=deploy state=present shell=/bin/bash groups=sudo append=true" -C

If the check looks right, run the line again without -C. Pay attention to append. When it is false, the docs say the user "will only be added to the groups specified in groups, removing them from all other groups". From Ansible 2.21, you must set append whenever you set groups. This makes you choose between adding and replacing groups on purpose.

Do not pass a plain-text password. On Linux, password takes an encrypted hash. If you log in with SSH keys, you can leave password out.

Removing an account is destructive. Check the host list and the prediction first:

ansible all -i inventory.ini --list-hosts
ansible all -i inventory.ini -b -m ansible.builtin.user -a "name=olduser state=absent remove=true" -C

Replace olduser with the real name. remove=true also tries to delete the user's directories, such as the home directory.

reboot: restart servers and wait for them

ansible webservers -i inventory.ini -b -f 1 -m ansible.builtin.reboot -a "reboot_timeout=900"

The reboot module does more than send a reboot command. The docs say it will "Reboot a machine, wait for it to go down, come back up, and respond to commands." By default, it waits up to 600 seconds and tests the host with whoami. The line above raises the wait to 900 seconds for slow hosts. Rebooting a server normally needs root, so this guide adds -b. With -f 1, Ansible reboots one web server and waits for it to answer. Only then does it start the next one. So at most one web server is down at a time.

The ad-hoc guide's own reboot example is different. It runs /sbin/reboot through the command module. That sends the reboot, but it does not wait for the host or test it. So you do not learn whether the server came back. To reboot only the servers that need it, read rebooting with Ansible only when a reboot is needed.

What the common errors mean

"Could not match supplied host pattern, ignoring: webserver" means the pattern matched no host and no group in your inventory. The cause is usually a typo (webserver instead of webservers) or the wrong inventory file. The HOST_PATTERN_MISMATCH setting decides what happens: ansible-core shows a warning, raises an error, or ignores it. A warning can scroll past while the command does nothing for that pattern. Run the same pattern with --list-hosts, and check the -i path.

sudo: a password is required in a host's output means you used -b without -K, and your user needs a password for sudo on that host. Add -K, or set up passwordless sudo for that user. Sorry, try again. means the password was wrong. -K sends the same password to every host. Hosts with different sudo passwords cannot share one run.

ping fails but ICMP ping works. ansible.builtin.ping needs an SSH login and a usable Python on the host. Connect with ssh by hand as the same user. Then check that Python is installed on the host.

A pipe or wildcard does nothing, or the error comes from the wrong program. You used the default command module. Switch to -m ansible.builtin.shell, and put the whole command in single quotes.

Command would have run if not in check mode means check mode skipped the task, so nothing was tested. If the command creates a file when it runs, pass that path as creates. If not, test the command on one host with --limit.

When should a one-off become a playbook?

The ad-hoc guide states the limit: ad-hoc commands "are not reusable". Nothing records which command you ran or which hosts it touched. That is fine for a question such as "which servers still run the old kernel?" It is a problem for any command that changes a server.

The rules below are this guide's advice, not the docs. Switch to a playbook when one of these is true:

  • You are about to type the same line a second time, on another day or for another group.
  • The steps must run in order, for example copying a config file and then restarting the service.
  • A restart should happen only when a file actually changed. That needs a handler, and handlers exist only in playbooks.
  • The task needs a secret, such as a database password. A secret should not stay in your shell history.

A playbook also has features with no ad-hoc form, such as check_mode: true on a single task. Start with your first Ansible playbook on a VPS. Then read Ansible templates and handlers for config files that restart a service only when they change. Ansible Vault keeps secrets encrypted in git. Most flags in this guide also work with ansible-playbook. The ansible-playbook flags explained covers the ones that differ. If you have not chosen a way to run a small fleet yet, managing multiple Linux servers compares the options.

FAQ

What is the difference between an Ansible ad-hoc command and a playbook?

An ad-hoc command runs one module against a set of hosts from the command line. Its shape is ansible [pattern] -m [module] -a "[module options]". A playbook is a YAML file that runs many tasks in order, and it can use handlers and templates. The Ansible docs say ad-hoc commands "are quick and easy, but they are not reusable". Use them for one-time checks and fixes. Use a playbook for any job you will repeat.

Why does a pipe or wildcard not work in my Ansible ad-hoc command?

The default module is command, and it does not run a shell. It passes your words straight to the program, so |, >, && and * arrive as plain arguments. Use the shell module instead: -m ansible.builtin.shell -a 'df -h | grep /dev', with the whole command in single quotes. The shell module uses /bin/sh. If the command needs bash syntax, add executable=/bin/bash.

Do I need both -b and -K?

-b turns on become, which means sudo to root by default. It never asks for a password. -K asks for the password, but it does not turn become on. If your user needs a password for sudo, use both flags. If your user has passwordless sudo, -b alone is enough. If you use -b alone on a host that needs a password, sudo prints sudo: a password is required and the task fails.

Does the Ansible ping module use ICMP?

No. The docs say "This is NOT ICMP ping". ansible.builtin.ping logs in over SSH, checks that the host has a Python it can use, and returns pong. So a host can answer ICMP ping and still fail the Ansible ping. The usual causes are a wrong SSH user or a missing Python.

Can I dry-run an Ansible ad-hoc command?

Yes. Add -C (--check). To see file changes as well, add -D (--diff). Modules such as copy, apt, user and systemd_service fully support check mode. In check mode, the command and shell modules skip the task unless you set creates or removes. So a dry run of a raw command tests nothing. To test such a command, run it on one host with --limit.

Was this article useful?