Verified19 commandsAI-assisted

Ansible CLI

.md

Verified against ansible-core 2.20.0 (local `ansible --version`), flags verified via `<cmd> --help` and · official docs

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#

Diagram

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)#

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#

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#

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#

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#

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#

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#

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#

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#

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 notifys 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#

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#

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#

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#

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#

# 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#

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"
# 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:

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
# 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#

# .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).