Ansible Cheatsheet - Playbook, Modules & Ad-Hoc Commands

Ansible's defining trait is that it installs no agent on managed machines and executes everything over SSH, which sets its boundary precisely: if you can SSH to it, you can manage it, across clouds, datacenters and ephemeral containers alike. The second trait is module idempotence - running the same task twice should change nothing - which is what makes it safe to re-run, and what separates it fundamentally from batch shell scripting, where a second pass often breaks things because no state is inspected. This sheet covers core commands, ad-hoc commands, common modules, playbook structure, variables and templates, inventory management, and roles with best practices. Reach for it by starting from an ad-hoc one-liner to prove connectivity, then promoting it into a playbook; before any change, dry-run with --check, or validate syntax alone with --syntax-check. Roles are where team collaboration turns: variables in defaults, reusable tasks in roles, secrets in ansible-vault - get those three right and a playbook becomes safe for someone else to edit.

SysOps·42 commands·Last updated 2026-07-21

Typical Use Case

For the ops engineer who must change dozens of identical machines and cannot accept a script with one machine quietly missed. The core is idempotence: a second run should produce no further change, which is why modules beat shell, and shell is a last resort guarded by creates or removes. Covers ad-hoc commands, common modules, playbook structure with handlers and roles, variables and templating, inventory groups, and ansible-galaxy. The habit to build is asking what a second run would do.

Core Commands 6

ansible all -m ping
Test connectivity to all hosts
ansible-playbook site.yml
Run a playbook
ansible-galaxy init myrole
Init a role directory skeleton
ansible-galaxy install geerlingguy.nginx
Install a role from Galaxy
ansible-vault encrypt secrets.yml
Encrypt a secrets file
ansible-doc -l | grep copy
List modules and search usage

Ad-hoc Commands 7

ansible webservers -m shell -a "uptime"
Run a shell command on a group
ansible all -m setup -a "filter=ansible_os_family"
Gather host facts
ansible all -m copy -a "src=file.txt dest=/tmp/"
Copy a file to remote hosts
ansible all -m service -a "name=nginx state=started"
Start a service
ansible webservers -m yum -a "name=nginx state=latest"
Install or upgrade a package
ansible all -m ping --limit web1
Run only on matching hosts
ansible all -m shell -a "df -h" -f 10
Set fork count to parallelize

Common Modules 7

- name: Install nginx apt: name: nginx state: present
apt/yum package module
- name: Copy config template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf
template rendering module
- name: Start service service: name: nginx state: started enabled: yes
service management module
- name: Create user user: name: deploy shell: /bin/bash
user management module
- name: Run command shell: npm run build args: chdir: /app
shell command module
- name: Create dir file: path: /opt/app state: directory mode: "0755"
file/directory module
- name: Git clone git: repo: https://github.com/x/y.git dest: /srv/app
git checkout module

Playbook Structure 6

- hosts: webservers become: yes tasks: [...]
Target hosts and enable sudo
pre_tasks: / post_tasks:
Tasks before/after roles
handlers: - name: restart nginx service: name=nginx state=restarted
Define a handler (triggered by notify)
vars: nginx_port: 80
Inline variables in a playbook
- name: notify handler template: ... notify: restart nginx
Notify a handler after a change
- import_playbook: db.yml
Import another playbook

Variables & Templates 6

vars_files: - vars/secrets.yml
Load variables from a file
- command: ... when: ansible_os_family == "Debian"
Conditional run with Jinja2
- user: name={{ item }} with_items: - alice - bob
Loop over a list
- command: ... register: result
Register task output to a variable
{{ nginx_port }} / {{ ansible_hostname }}
Jinja2 variable syntax
when: result.rc == 0
Condition on the previous task's result

Inventory 5

[webservers] web1 ansible_host=10.0.1.1 web2 ansible_host=10.0.1.2
INI-format inventory
[web:children] web1 web2
Define group nesting
ansible all -i hosts.yml --list-hosts
List matched hosts with an inventory
ansible all -i ec2.py --list-hosts
Use a dynamic inventory (cloud)
web1 ansible_host=10.0.1.1 ansible_user=deploy ansible_port=2222
Set host connection params

Roles & Best Practices 5

ansible-galaxy init myrole
Generate a standard role layout
roles/myrole/{tasks,handlers,templates,files,defaults,vars}/main.yml
Standard role directory convention
- hosts: web roles: - nginx - { role: db, when: enable_db }
Use roles in a playbook
- import_role: name: nginx tags: setup
Statically import a role with tags
[defaults] inventory = hosts remote_user = deploy host_key_checking = False
ansible.cfg global config

Parameter matrix

ParameterEffectExample
ansible all -m pingRun the ping module in ad-hoc mode to verify connectivity and Python availability; the first step before any operationansible all -m ping
ansible webservers -m shell -a "uptime"Run a command within the webservers group; shell is not idempotent, so re-running executes it againansible webservers -m shell -a "uptime"
ansible all -m setup -a "filter=ansible_os_family"Gather facts and use filter to keep only the needed fields, far more readable than a full setup dumpansible all -m setup -a "filter=ansible_os_family"
ansible all -m copy -a "src=file.txt dest=/tmp/"Idempotent copy; when the file content is identical, the reported changed is falseansible all -m copy -a "src=file.txt dest=/tmp/"
ansible all -m service -a "name=nginx state=started"Declare the service as running; if it is already running, it will not be restartedansible all -m service -a "name=nginx state=started"
ansible all -m ping --limit web1Narrow the scope to a single host; the key switch for staged rollouts and troubleshootingansible all -m ping --limit web1
ansible all -m shell -a "df -h" -f 10-f sets the concurrency level; the default is 5, too low for hundreds of machines and easily saturates the controller at high valuesansible all -m shell -a "df -h" -f 10
ansible-playbook site.ymlRun the playbook, the single entry point for all formal changesansible-playbook site.yml
ansible-galaxy init myroleGenerate a role skeleton that follows the official convention (tasks/handlers/templates/files/defaults/vars)ansible-galaxy init myrole
ansible-galaxy install geerlingguy.nginxInstall a third-party role from Galaxy; pin the version beforehand, or an upgrade brings unintended changesansible-galaxy install geerlingguy.nginx
ansible-vault encrypt secrets.ymlEncrypt a sensitive variables file; decryption needs the vault password, which itself must not go into the repoansible-vault encrypt secrets.yml
ansible-doc -l | grep copyLook up the module list and docs; more accurate than a search engine when you cannot recall the parametersansible-doc -l | grep copy
apt: name=nginx state=presentDeclare the package as installed, far more idempotent than command + dpkg- name: Install nginx apt: name: nginx state: present
template: src=nginx.conf.j2 dest=/etc/nginx/nginx.confPush config via a Jinja2 template; an actual write happens only when the template changes- name: Copy config template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf
service: name=nginx state=restarted enabled=yesRestart the service and enable it at boot; state=restarted restarts unconditionally, so it is only sensible with a handler- name: Start service service: name: nginx state: started enabled: yes
shell: npm run build args: chdir: /appRun a command in a given directory; chdir is required, otherwise it runs in the current directory of the controller- name: Run command shell: npm run build args: chdir: /app
file: path=/opt/app state=directory mode="0755"Idempotently create a directory and set permissions; write mode as a string, or 0755 is parsed as decimal- name: Create dir file: path: /opt/app state: directory mode: "0755"
git: repo=... dest=/srv/appIdempotently pull code; add version to specify a branch or tag- name: Git clone git: repo: https://github.com/x/y.git dest: /srv/app
hosts: webservers + become: yesTask scope and privilege-escalation switch; become is the most common source of permission pitfalls- hosts: webservers become: yes tasks: [...]
pre_tasks: / post_tasks:Insert actions before and after roles, for pre-flight assertions and final validationpre_tasks: / post_tasks:
handlers + notifyTrigger a final action once only when a task changes; using notify instead of a direct restart avoids restarting when config has not changed- name: notify handler template: ... notify: restart nginx
import_playbook: db.ymlSplit and reuse playbooks to keep a giant playbook from spiraling out of control- import_playbook: db.yml
vars_files: - vars/secrets.ymlInject a variables file; together with inventory group_vars/host_vars it forms the precedence chainvars_files: - vars/secrets.yml
when: ansible_os_family == "Debian"Conditional execution; cross-distro playbooks rely on it to branch package managers and service names- command: ... when: ansible_os_family == "Debian"
register: result + when: result.rc == 0Capture the result of the previous step to make a decision; the common way to wrap a non-idempotent command into an idempotent one- command: ... register: result
with_items:Expand a task per list item; easier to maintain than writing N duplicated tasks- user: name={{ item }} with_items: - alice - bob
[web:children]Group nesting, so the web group automatically includes the child group and hosts disappear from the group when removed[web:children] web1 web2
ansible all -i hosts.yml --list-hostsOnly parse inventory and list the target hosts without running any task; the first step in troubleshooting inventory problemsansible all -i hosts.yml --list-hosts
web1 ansible_host=10.0.1.1 ansible_user=deploy ansible_port=2222Host-level variables; when a connection fails it is almost always one of these three misconfiguredweb1 ansible_host=10.0.1.1 ansible_user=deploy ansible_port=2222
roles/myrole/{tasks,handlers,templates,files,defaults,vars}/main.ymlThe officially agreed role directory structure; defaults holds overridable defaults, vars holds internal variablesroles/myrole/{tasks,handlers,templates,files,defaults,vars}/main.yml
- { role: db, when: enable_db }Add a condition to a single role in the roles list, enabling "a group of tasks gated by a switch"- hosts: web roles: - nginx - { role: db, when: enable_db }
tags: setupTag tasks so --tags can run only a subset; the essential escape hatch for large playbooks- import_role: name: nginx tags: setup
[defaults] inventory = hostsThe ansible.cfg defaults section, centrally managing the inventory path and behaviors such as host_key_checking[defaults] inventory = hosts remote_user = deploy host_key_checking = False

Common pitfalls

SymptomThe config was changed via a template, but the service was not restarted, so the change appears not to have taken effect.

CauseThe restart is written inside a task instead of a handler, or the template content did not actually change, so the task reported changed=false.

FixWrite the restart as a handler and trigger it with notify; first confirm the task output actually shows changed, then rehearse the template diff with --check --diff.

SymptomThe task reports "command not found" or cannot find node / npm, though the same command works in a terminal.

CauseA non-interactive shell does not load the nvm / pyenv environment, so those directories are not in PATH.

FixIn the task, use command with chdir and environment, or run the profile script first; do not count on a login shell rc taking effect.

SymptomThe first run succeeds, but the second run still reports lots of changed, so idempotency is broken.

CauseIt uses the shell / command module without conditions like creates / removes, so the task itself is not idempotent.

FixPrefer switching to the matching module (file / copy / service / user); when you must use command, add creates or register the result of stat first and skip with when.

Symptommode: 0755 sets the wrong permissions: the value ends up as something other than 0755, or is treated as decimal 755.

CauseYAML parses 0755 as octal or decimal depending on the notation and parser behavior; a string is safer.

FixWrite mode as the string "0755", and when you need arithmetic use mode: "{{ 0o755 | int }}" to convert explicitly.

SymptomThe variable is clearly defined in group_vars, yet it is empty in the template.

CauseThe precedence chain is ignored: command line > role defaults > inventory group_vars/host_vars > role vars; a variable name written at the wrong level is invisible.

FixUse ansible all -m debug -a "var=nginx_port" to confirm the effective value on the target host, then double-check the variable name spelling.

SymptomThe playbook hangs on one host, dragging down every later task.

CauseThe default ssh connection waits a long time before timing out, and there is no concurrency cap when there are many hosts.

FixAdd ConnectTimeout and ssh_args ControlMaster reuse to the SSH config, limit concurrency in the playbook with -f, and batch with serial.

SymptomIt reports "MODULE FAILURE" at runtime or skips a batch of hosts.

CauseThe target host has no Python interpreter (common on old distros), or its remote Python version does not meet the requirement.

FixFirst run ansible all -m ping to confirm connectivity; for machines without Python, use the raw module to bootstrap minimally, then switch back to normal modules.

SymptomA role installed via galaxy runs fine, but fails on another machine.

CauseRole dependencies are not written into requirements.yml, and by default the latest version is installed, so behavior drifts with upstream.

FixPin role dependencies and versions in requirements.yml, and run galaxy install before the playbook in the deploy flow.

Troubleshooting

  1. 1Parse inventory only to confirm the target host list

    ansible all -i hosts.yml --list-hosts

    If the list is wrong, every later task is wasted; get the host scope right first.

  2. 2Verify connectivity and remote Python

    ansible all -m ping

    If ping fails, fix SSH, become, and Python first instead of running more tasks.

  3. 3Show only the key fact fields of the target hosts

    ansible all -m setup -a "filter=ansible_os_family"

    Confirm the OS family; cross-distro conditional branches depend on it.

  4. 4Rehearse changes without applying them

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

    --check only predicts and --diff shows file differences; the most important step to run before going live.

  5. 5Restrict to a single host for troubleshooting

    ansible-playbook site.yml --limit web1

    Shrink the blast radius and use -vvv to see the full output.

  6. 6View the complete parameter reference of a module

    ansible-doc -l | grep copy

    When you cannot recall the parameters, trust the module docs rather than assembling them from memory.

Command Examples

Check connectivity before touching production

ansible all -i hosts.yml --list-hosts ansible all -m ping

--list-hosts first confirms which hosts match, avoiding a wrong group name hitting an unexpected scope. ping verifies that the SSH and Python dependencies are ready.

Output

hosts (4):
  web1
  web2
  db1
  db2

web1 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

Deploy config with a template; restart only on change

- name: Render nginx.conf template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf notify: restart nginx handlers: - name: restart nginx service: name=nginx state=restarted

A handler fires only when the task reports changed, so the service is not restarted when the config has not changed, avoiding pointless connection drops. This is the primary purpose of the handler.

Dry-run with --check

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

--check does not actually execute; --diff shows the changes that would occur. Use it before going to production to confirm the impact, especially with --limit web1.

Output

TASK [Gathering Facts] *************************************
TASK [template : Render nginx.conf] ************************
--- before
+++ after
@@ -1 +1 @@
-worker_processes auto;
+worker_processes 4;

Common Pitfalls

  • ad-hoc -m shell is not guaranteed idempotent, so rerunning it can cause side effects; write a Playbook with idempotent modules for anything repeated.
  • Handlers run at the end of the play, not right after the task that triggered them; use a regular task when timing matters.
  • A mistyped group name silently matches nothing — run --list-hosts to confirm the target set before executing.
  • Template rendering failures, such as undefined Jinja2 variables, abort the task; encrypt sensitive variables with ansible-vault instead of inlining them.
  • register inside a loop accumulates into a list, so referencing it directly yields only the last item rather than all of them.

Tips

  • Ansible connects over SSH - no agent needed on targets.
  • template renders configs with Jinja2, more flexible than copy.
  • --check (dry-run) previews changes without applying them.
  • Role layout: tasks/main.yml, handlers/main.yml, templates/, files/, defaults/main.yml.
  • Encrypt secrets with ansible-vault via vars_files; never hardcode.
  • group_vars/ and host_vars/ auto-load per group/host - easier to maintain.

FAQ

When do Ansible handlers actually run?

A handler runs only when a task that notified it reported a change, it runs once, and it executes after all regular tasks have completed. That is the core distinction: ordinary tasks run every time, handlers run only when something changed. So if the rendered config is identical, the service is not restarted, avoiding pointless service churn. If you always want it to run, use a regular task instead.

When should I use ad-hoc commands versus a Playbook?

Ad-hoc is right for one-off work and quick triage — ansible webservers -m shell -a "uptime" answers a state question immediately. Playbooks are for anything repeated, version-controlled, or involving logic: they can be code reviewed and dry-run with --check. The rule of thumb: if you will run the operation again, write a Playbook.

What does idempotent mean, and why do Ansible modules emphasize it?

Idempotent means running an operation N times leaves the same result as running it once. Copying a config whose source has not changed transfers nothing and leaves mtime untouched; a service module with state=started means ensure it is running, doing nothing if it already is. Preferring these purpose-built modules over shell commands buys you idempotency for free and avoids hand-rolled state checks.

How do I run a task only under certain conditions?

Use the when field with a Jinja2 expression, for example when: ansible_os_family == "Debian", so the task runs only on Debian-family hosts. Extract the expression into a variable when reusing it across tasks. For iteration use with_items: - user: name={{ item }} with with_items executes the task once per element.

How should sensitive data be handled?

Encrypt the variables file with ansible-vault encrypt secrets.yml and reference it from vars_files. For templating use the template module with Jinja2 like {{ nginx_port }} rather than building strings by hand. Connection settings such as host_key_checking belong in ansible.cfg so they are configured once instead of scattered across playbooks.

Official References

Each command links to its official documentation below, so you can verify the latest usage and read deeper.

Maintained by LaoHand

Publicly updated on Jul 21, 2026, continuously proofread against official docs.

Contact Us

Wrong command or description? Send us corrections, business inquiries or product feedback by email.

Contact Us