# Ansible CLI Cheat Sheet

> **Tool:** Ansible (ansible-playbook, ansible, ansible-vault)
> **Category:** Infrastructure as Code
> **Verified against:** ansible-core 2.20.0 (local `ansible --version`), flags verified via `<cmd> --help` and
> `ansible-galaxy role init` tested against a live role scaffold; the roles-directory layout and
> `ansible-lint` sections verified via official docs (docs.ansible.com/ansible/latest/playbook_guide/playbooks_reuse_roles.html,
> ansible.readthedocs.io/projects/lint), not executed locally, 2026-08-29
> **Official docs:** https://docs.ansible.com/ansible/latest/cli/

## What it is and where it fits 🎯

Ansible is agentless configuration management — it connects over plain SSH (or WinRM for Windows targets) and
pushes Python modules to run on the target, with no persistent daemon to install or maintain on managed hosts.
This is the core architectural difference from Terraform (which this series also covers): Terraform declares
*infrastructure that should exist* (a VM, a network, a database instance); Ansible declares *the state of
software running on hosts that already exist* (packages installed, config files rendered, services running).
Many real pipelines use both — Terraform provisions the VM, Ansible configures what runs on it — rather than
treating them as competitors.

## Idempotency — the property that makes re-running a playbook safe

```mermaid
flowchart LR
    A["ansible-playbook site.yml"] --> B{"Is the target state<br/>already achieved?"}
    B -->|"yes"| C["Task reports: ok<br/>(no change made)"]
    B -->|"no"| D["Task reports: changed<br/>(module performs the action)"]

    classDef info fill:#e5f0fa,stroke:#1d6fb8,color:#10161c
    classDef ok fill:#e5f5ea,stroke:#1f8a4c,color:#10161c
    class C ok
    class D info
```

> [!IMPORTANT]
> **A well-written Ansible task is idempotent — running the same playbook twice in a row should report
> `changed` the first time and `ok` the second, with no error either time.** This is what makes
> `ansible-playbook site.yml` safe to run repeatedly (e.g., in a cron job, or after every commit) rather than
> only being safe to run once against a fresh host. It's also why raw `command`/`shell` tasks are a code
> smell in Ansible code review — they're rarely idempotent on their own (running `useradd myuser` twice
> errors the second time) unless explicitly guarded with `creates:`/`when:`, whereas the dedicated `user`
> module handles that idempotency automatically.

## Ad-hoc commands (no playbook)

```bash
ansible all -i inventory.ini -m ping                          # connectivity check against every host
ansible webservers -i inventory.ini -a "systemctl status nginx"   # -a with no -m defaults to the 'command' module
ansible webservers -i inventory.ini -m yum -a "name=nginx state=present" -b   # -b = become (sudo)
ansible webservers -i inventory.ini -m ping -l "web01,web02"   # limit to a subset of the matched pattern
```

`-m ping` is the standard first check when troubleshooting connectivity — it verifies SSH access and that Python is reachable on the target, without changing anything. `-a` without `-m` implicitly uses the `command` module, running the string as a raw shell command.

## Running a playbook

```bash
ansible-playbook -i inventory.ini site.yml
ansible-playbook -i inventory.ini site.yml --limit web01
ansible-playbook -i inventory.ini site.yml --tags "deploy,config"
ansible-playbook -i inventory.ini site.yml --skip-tags "slow-tests"
ansible-playbook -i inventory.ini site.yml -e "app_version=1.4.2"   # pass extra vars from the command line
```

## Dry-run and diff before applying

```bash
ansible-playbook -i inventory.ini site.yml --check              # predict changes without making them
ansible-playbook -i inventory.ini site.yml --check --diff       # + show the actual file/template diffs
ansible-playbook -i inventory.ini site.yml --syntax-check       # validate YAML/module syntax only, no connection
ansible-playbook -i inventory.ini site.yml --list-tasks         # show what would run, in order
```

`--check` mode is not a guarantee — modules that don't support check mode (some shell/command tasks) either skip or report inaccurately. Treat it as a strong signal for well-behaved modules, not an absolute preview for every task type.

## Debugging a playbook run

```bash
ansible-playbook -i inventory.ini site.yml -v      # verbose (stack -vv, -vvv, -vvvv for more detail per level)
ansible-playbook -i inventory.ini site.yml --start-at-task="Install package"   # resume from a specific task
ansible-playbook -i inventory.ini site.yml --step  # confirm each task interactively before running it
```

## Ansible Vault — encrypting secrets

```bash
ansible-vault create secrets.yml                    # create a new encrypted file
ansible-vault edit secrets.yml                       # decrypt, open in $EDITOR, re-encrypt on save
ansible-vault view secrets.yml                        # print decrypted contents, don't write to disk
ansible-vault encrypt group_vars/prod/vault.yml        # encrypt an existing plaintext file in place
ansible-vault decrypt group_vars/prod/vault.yml
ansible-playbook -i inventory.ini site.yml --ask-vault-pass       # prompt for the vault password at runtime
ansible-playbook -i inventory.ini site.yml --vault-password-file=.vault-pass   # read it from a file instead
```

## Inventory

```bash
ansible-inventory -i inventory.ini --list          # full inventory as JSON
ansible-inventory -i inventory.ini --graph          # human-readable group/host tree
ansible-inventory -i inventory.ini --host web01      # variables resolved for one specific host
```

Static inventory files use INI or YAML — group hosts under `[groupname]` headers in INI, with `[groupname:vars]` for group-level variables and `[groupname:children]` to nest groups. `--graph` is the fastest way to sanity-check that a nested group structure actually resolved the way you intended before running a playbook against it.

## Roles — directory structure and scaffolding

```bash
ansible-galaxy role init myrole    # scaffold the standard role directory layout
```

```
myrole/
├── defaults/main.yml    # lowest-precedence variables, meant to be overridden
├── files/                # static files referenced by the `copy` module
├── handlers/main.yml     # tasks triggered by `notify`
├── meta/main.yml         # role metadata + dependencies on other roles
├── tasks/main.yml        # the role's actual task list
├── templates/            # Jinja2 templates referenced by the `template` module
├── tests/                # a minimal test inventory + playbook
└── vars/main.yml         # higher-precedence variables, not meant to be overridden
```

A role is included from a playbook with `roles: [myrole]` or, for finer control over ordering relative to other tasks, `import_role`/`include_role`. Ansible discovers a role by name in `roles/` next to the playbook, or in any path listed in `ANSIBLE_ROLES_PATH` — no explicit path is needed if it lives in the conventional location.

## Installing roles and collections from Galaxy

```bash
ansible-galaxy install geerlingguy.docker              # install a single role from Ansible Galaxy
ansible-galaxy install -r requirements.yml              # install everything listed in a requirements file
ansible-galaxy role list                                 # show installed roles + versions
ansible-galaxy collection install community.general      # install a collection instead of a role
ansible-galaxy collection install -r requirements.yml -p ./collections   # into a project-local path
```

A `requirements.yml` can pin both roles and collections with version constraints in one file — check it into the repo alongside the playbooks so `ansible-galaxy install -r requirements.yml` reproduces the exact dependency set on any machine, rather than relying on whatever happens to already be installed.

## Handlers — running tasks only on change

```yaml
tasks:
  - name: Update nginx config
    ansible.builtin.template:
      src: nginx.conf.j2
      dest: /etc/nginx/nginx.conf
    notify: Restart nginx

handlers:
  - name: Restart nginx
    ansible.builtin.service:
      name: nginx
      state: restarted
```

Handlers only run if a task that `notify`s them actually reports `changed`, and by default they run once, after all regular tasks in the play finish — not immediately after the notifying task. Use `--force-handlers` on `ansible-playbook` to run notified handlers even if a later task in the play fails, or `meta: flush_handlers` in the task list to run them early, mid-play.

## Tags in depth

```bash
ansible-playbook -i inventory.ini site.yml --list-tags     # see every tag defined in the playbook, without running it
ansible-playbook -i inventory.ini site.yml --tags "always,deploy"   # combine the implicit 'always' tag with a specific one
```

Two tag names are special: a task or role tagged `always` runs on every invocation regardless of `--tags`/`--skip-tags` (unless explicitly skipped), and one tagged `never` is skipped by default unless its exact tag is requested with `--tags`. Tags applied to a `roles:` entry or an `import_playbook`/`import_tasks` propagate down to every task inside it — useful for tagging an entire role's inclusion in one place instead of every task within it.

## Privilege escalation with become

```bash
ansible-playbook -i inventory.ini site.yml -b                          # become the default target user (root)
ansible-playbook -i inventory.ini site.yml -b --become-user deploy     # become a specific non-root user
ansible-playbook -i inventory.ini site.yml -b -K                       # prompt for the become password
ansible-playbook -i inventory.ini site.yml -b --become-method su       # use su instead of the default sudo
ansible-doc -t become -l                                                # list every become plugin available
```

`become` can also be set per-task or per-play in YAML (`become: true`, `become_user: deploy`) rather than globally on the CLI — task/play-level settings override whatever was passed on the command line, so a task explicitly marked `become: false` stays unprivileged even if `-b` was passed to `ansible-playbook`.

## Linting playbooks with ansible-lint

```bash
ansible-lint site.yml                    # lint a single playbook
ansible-lint                              # lint the whole current directory (roles, playbooks, etc.)
ansible-lint --profile production         # run the stricter production rule profile instead of the default
ansible-lint -x yaml[line-length]         # exclude a specific rule by its rule id
```

`ansible-lint` is a separate PyPI/pipx package (`pipx install ansible-lint`), not bundled with `ansible-core` —
it catches style and correctness issues `ansible-playbook --syntax-check` doesn't, like deprecated module
names, missing `name:` keys, and risky `command`/`shell` usage where a dedicated module exists.

## Loops, conditionals, and registered variables

```yaml
tasks:
  - name: Install a list of packages
    ansible.builtin.apt:
      name: "{{ item }}"
      state: present
    loop:
      - nginx
      - curl
      - git

  - name: Check if a config file exists
    ansible.builtin.stat:
      path: /etc/myapp/config.yml
    register: config_check

  - name: Deploy default config if missing
    ansible.builtin.template:
      src: default-config.yml.j2
      dest: /etc/myapp/config.yml
    when: not config_check.stat.exists
```

> [!TIP]
> **`register` + `when` is the standard pattern for "only do this if a previous check found something" —**
> and it composes with `failed_when`/`changed_when` to override a module's default success/change detection
> when a task's real-world meaning doesn't match what the module assumes (e.g. a `command` task whose exit
> code 1 genuinely means "already in the desired state," not failure).

## ansible.cfg — project-level defaults

```ini
# ansible.cfg, in the project root
[defaults]
inventory = ./inventory.ini
remote_user = deploy
host_key_checking = False
roles_path = ./roles

[privilege_escalation]
become = True
become_method = sudo
```

> [!NOTE]
> Ansible looks for `ansible.cfg` in the current directory first, then `~/.ansible.cfg`, then
> `/etc/ansible/ansible.cfg` — a project-local file is how a team standardizes defaults (inventory path,
> remote user, become settings) so every teammate's `ansible-playbook site.yml` behaves identically without
> everyone remembering the same long flag list by hand.

## Dynamic inventory

```bash
ansible-inventory -i inventory.aws_ec2.yml --list      # query a cloud provider's live inventory plugin
ansible-playbook -i inventory.aws_ec2.yml site.yml --limit "tag_Environment_production"
```

```yaml
# inventory.aws_ec2.yml
plugin: amazon.aws.aws_ec2
regions: [us-east-1]
keyed_groups:
  - key: tags.Environment
    prefix: tag_Environment
```

A dynamic inventory plugin queries the cloud provider's API live at run time instead of reading a static
file — the standard approach once hosts are ephemeral (autoscaled, frequently replaced) and a hand-maintained
`inventory.ini` would constantly drift out of date.

## Real-world scenario: safe production rollout with a canary host first

Rolling a config change to every production host at once is a real blast-radius risk if the change has a
subtle bug — `serial` and `--limit` combine to test on one host before the fleet:

```bash
ansible-playbook -i inventory.ini site.yml --limit web01 --check --diff   # dry-run against one host first
ansible-playbook -i inventory.ini site.yml --limit web01                    # apply for real, to just that one host
# confirm web01 is healthy, then:
ansible-playbook -i inventory.ini site.yml                                    # full fleet
```

```yaml
# Or, declared in the playbook itself, for automatic batching:
- hosts: webservers
  serial: "25%"        # roll 25% of matched hosts at a time, waiting for each batch to finish before the next
  tasks: [...]
```

> [!WARNING]
> **`--check` mode is not a substitute for a real canary rollout** — it predicts changes without making them,
> but modules that don't support check mode (some raw `shell`/`command` tasks) either skip or report
> inaccurately in that mode. A genuine single-host apply (not just a dry-run) before the full fleet is what
> actually catches an issue check mode can't predict.

## Real-world scenario: CI-driven deployment with Vault-encrypted secrets

```yaml
# .github/workflows/deploy.yml
name: Ansible Deploy
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install ansible ansible-lint
      - run: ansible-lint site.yml
      - name: Deploy
        run: |
          echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > .vault-pass
          ansible-playbook -i inventory.ini site.yml --vault-password-file=.vault-pass
          rm .vault-pass
```

> [!CAUTION]
> Writing the vault password to a temp file and removing it immediately after is a common CI pattern, but the
> file briefly exists on disk during the run — on a genuinely shared/untrusted CI runner, prefer an
> environment-variable-based vault password script (`ANSIBLE_VAULT_PASSWORD_FILE` pointing at an executable
> that prints the secret from a proper secrets manager) over a plain written file, even a short-lived one.

## Common pitfalls

- **Non-idempotent `command`/`shell` tasks** with no `creates:`/`when:` guard — see the idempotency section
  above; this is the single most common Ansible code-review flag.
- **Trusting `--check` mode as a full preview** — see the WARNING above; not every module supports it
  accurately.
- **Handlers not firing when expected** — remember they only run on `changed`, and only once at the end of
  the play by default (see the Handlers section above for `--force-handlers`/`flush_handlers`).
- **A stale static inventory file** for an environment with autoscaled/ephemeral hosts — see Dynamic
  inventory above.

## When to reach for something else

For provisioning infrastructure itself (VMs, networks, managed databases) rather than configuring software on
hosts that already exist, reach for Terraform (see this same category's Terraform cheat sheets) — the two
compose well together but solve different halves of the problem. For Kubernetes-native workloads specifically,
neither tool is usually the right fit for day-to-day application deployment — that's `kubectl`/Helm/Kustomize
territory (see the Containers & Orchestration category).
