NovuSpark
All articles
AnsibleAugust 22, 2025 · NovuSpark Team

Ansible 101: Inventory, Playbooks, and Ad-Hoc Commands

This is the first post in our Ansible fundamentals series. Later posts cover roles, variables and templates, Vault, and testing with Molecule.

Most configuration management tools require installing an agent on every machine they manage — a persistent process that phones home, needs its own updates, and is one more thing that can drift out of sync. Ansible's core design decision is refusing that trade: it manages remote machines entirely over SSH, using Python that's typically already present, and leaves nothing running once a task finishes.

control nodeansible-playbookSSHSSHSSHweb1.internalweb2.internaldb1.internalno agent process left running on any host
Fig. 1 — Ansible's push model: the control node connects over SSH, runs Python, and leaves nothing behind

That single decision explains most of what makes Ansible feel different to work with. This post covers the three concepts everything else in Ansible is built from.

Inventory: telling Ansible what "the fleet" means

An inventory is a list of the machines Ansible can manage, optionally organized into groups.

# inventory.ini
[web]
web1.internal ansible_host=10.0.1.10
web2.internal ansible_host=10.0.1.11
 
[db]
db1.internal ansible_host=10.0.2.10
 
[production:children]
web
db

Groups exist so you can target a subset of your fleet without repeating a list of hostnames everywhere. production:children above is a group of groups — anything targeting production automatically includes every host in web and db.

For anything beyond a handful of static hosts, a dynamic inventory — a script or plugin that queries AWS, Azure, or another source of truth at run time — replaces the static file entirely:

# aws_ec2.yml — dynamic inventory plugin config
plugin: amazon.aws.aws_ec2
regions:
  - eu-west-2
filters:
  tag:Environment: production
keyed_groups:
  - key: tags.Role
    prefix: role
ansible-inventory -i aws_ec2.yml --graph
@all:
  |--@role_web:
  |  |--i-0abc123
  |  |--i-0def456
  |--@role_db:
  |  |--i-0ghi789

The mental model stays identical either way: Ansible still just needs a list of hosts and groups; where that list comes from is the only thing that changes. This matters in practice more than it sounds — a static inventory file goes stale the moment autoscaling adds or removes instances, while a dynamic inventory always reflects exactly what's actually running, tagged and grouped by whatever convention your cloud provider already uses.

Ad-hoc commands: Ansible without writing a file first

Before playbooks, it's worth knowing Ansible can run a single task directly from the command line — useful for a quick check across a fleet, without writing any configuration at all:

ansible web -i inventory.ini -m ping
web1.internal | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
web2.internal | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

-m ping invokes the ping module — not an ICMP ping, but a check that Ansible can connect and run Python on the target. Any module can be run this way:

ansible web -i inventory.ini -m shell -a "df -h /"
ansible web -i inventory.ini -m setup -a "filter=ansible_distribution*"

That second example runs the setup module — the same fact-gathering step every playbook runs automatically before its first task — filtered to just the distribution facts, a genuinely useful way to sanity-check what Ansible actually sees about a host before writing a task that depends on it.

Ad-hoc commands are genuinely useful for exactly this kind of one-off, read-only check across a fleet. They're the wrong tool the moment you want the same set of actions to run reliably, repeatedly, and in a specific order — which is exactly what a playbook is for.

Playbooks: the actual unit of automation

A playbook is a YAML file describing a set of tasks to run against a set of hosts.

# webserver.yml
---
- name: Configure web servers
  hosts: web
  become: true
 
  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true
 
    - name: Ensure nginx is running
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true
 
    - name: Deploy index page
      ansible.builtin.copy:
        src: files/index.html
        dest: /var/www/html/index.html
        owner: www-data
        group: www-data
        mode: "0644"
ansible-playbook -i inventory.ini webserver.yml

A few structural details worth being precise about:

  • hosts: web targets the web group from the inventory — this is the same targeting mechanism as ad-hoc commands, just declared in the file instead of on the command line.
  • become: true tells Ansible to escalate privileges (sudo, by default) for tasks that need it — installing packages, managing system services. It's set once at the play level rather than repeated per task.
  • Each task names a module (ansible.builtin.apt, ansible.builtin.service, ansible.builtin.copy) with parameters specific to that module. This is the core Ansible pattern: you describe what state a task should leave the system in (state: present, state: started), not the imperative commands to get there.

Running a playbook safely: --check and --diff

Before running a playbook for real against a fleet you actually care about, two flags are worth making a habit, the same way terraform plan is a habit before terraform apply:

ansible-playbook -i inventory.ini webserver.yml --check --diff

--check runs the playbook in dry-run mode — Ansible reports what would change without actually changing anything. --diff shows the actual content difference for file-modifying tasks (like the template and copy modules covered in later posts in this series), not just "this file would change" but exactly which lines. Together, they give you the same "read before you apply" discipline covered for Terraform, adapted to configuration management specifically — genuinely worth running before any playbook change touches production for the first time.

Idempotency: why running this twice should be safe

Run webserver.yml a second time, and Ansible's output distinguishes between tasks that actually changed something and tasks that found the system already in the desired state:

TASK [Install nginx] **********************************************
ok: [web1.internal]

TASK [Ensure nginx is running] ************************************
ok: [web1.internal]

ok (not changed) means Ansible checked, found nginx already installed and already running, and did nothing further. This property — safe to run repeatedly, with no effect beyond the first successful run — is called idempotency, and it's the property that makes playbooks trustworthy enough to run on a schedule, in CI, or as part of a deployment pipeline without fear of a second run doing something unexpected.

This doesn't happen automatically for every possible task — it's a property of well-written modules and well-written tasks. ansible.builtin.shell running an arbitrary command has no automatic idempotency at all; it's on you to make sure that command is safe to run more than once, or to guard it with a creates: or when: condition. We come back to this directly, including the tooling that actually verifies it, in the Molecule testing post later in this series.

Tags: running only part of a playbook

A long playbook doesn't always need to run start-to-finish for every change — tags let you selectively run (or skip) specific tasks:

tasks:
  - name: Install nginx
    ansible.builtin.apt:
      name: nginx
      state: present
    tags: [install]
 
  - name: Deploy index page
    ansible.builtin.copy:
      src: files/index.html
      dest: /var/www/html/index.html
    tags: [deploy]
ansible-playbook webserver.yml --tags deploy

Running only the deploy-tagged tasks skips the (presumably already-completed) install step entirely — genuinely useful for a fast, targeted re-run when only application content changed, without re-executing every setup task in the playbook unnecessarily on every single run.

Limiting execution to specific hosts

Beyond targeting a whole inventory group, --limit restricts a run to a subset of hosts without editing the playbook or inventory at all:

ansible-playbook -i inventory.ini webserver.yml --limit web1.internal

This is worth reaching for when validating a change against one canary host before rolling it out to an entire group — the same "test against a small slice before the full rollout" instinct behind a Kubernetes canary deployment or a GitHub Actions staging-before-production gate, covered elsewhere in this blog, applied here to a single Ansible run.

What to actually remember from this post

  • Inventory is Ansible's list of hosts and groups — static for small, stable fleets; dynamic for anything sourced from a cloud provider, which is what keeps it accurate as instances scale up and down.
  • Ad-hoc commands are for one-off checks; playbooks are for anything you want to run reliably, repeatedly.
  • --check --diff is Ansible's equivalent of terraform plan — read what would change before committing to a real run.
  • Modules describe desired state, not imperative steps — that distinction is what makes idempotency possible in the first place.
  • ok vs. changed in playbook output is a feature, not noise — it's telling you exactly what Ansible actually did on this run.

Next in the series: Ansible Roles: Structuring Reusable Automation, where a single playbook file becomes a reusable, shareable unit.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.