Skip to content

Your First Playbook

A runnable lab, start to finish. You need one Linux machine you can already SSH into (a cloud VM, a local VM, or a container is fine) — if you're not there yet, finish SSH and Connectivity first.

The Setup

Your Laptop (control node)
      |
      | SSH (key-based)
      v
Linux Server (managed node)

Create a small project directory:

ansible-lab/
├── ansible.cfg
├── inventory.ini
└── site.yml
inventory.ini
[web]
web01 ansible_host=203.0.113.10 ansible_user=deploy
ansible.cfg
[defaults]
inventory = inventory.ini
host_key_checking = True

ansible.cfg in the project directory means you don't have to pass -i inventory.ini on every command — Ansible picks it up automatically from the current directory.

Step 1 — Prove the Connection

$ ansible web -m ping
web01 | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
}

This is an ad-hoc command: one module (ping), no playbook file. SUCCESS plus "pong" means SSH auth works and Ansible found a Python interpreter on the target. If this fails, stop here and work through SSH and Connectivity — nothing past this point will work until ping succeeds.

Step 2 — One More Ad-Hoc Command

$ ansible web -m command -a "uptime"
web01 | CHANGED | rc=0 >>
 14:32:01 up 3 days,  2:14,  1 user,  load average: 0.08, 0.03, 0.01

Ad-hoc commands are the right tool for a one-off check like this — see Ad-Hoc Commands for when to reach for them versus a playbook.

Step 3 — Write a Real Playbook

site.yml
---
- name: Install and start nginx
  hosts: web
  become: true

  tasks:
    - name: Install nginx
      ansible.builtin.package:
        name: nginx
        state: present

    - name: Ensure nginx is running and enabled
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

Step 4 — Dry-Run It First

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

--check runs every task's state comparison without making changes; --diff shows what would change. This costs nothing and catches syntax and logic errors before touching a real machine — see Check Mode and Diff Mode.

Step 5 — Run It

$ ansible-playbook site.yml

PLAY [Install and start nginx] ************************************

TASK [Gathering Facts] *********************************************
ok: [web01]

TASK [Install nginx] ***********************************************
changed: [web01]

TASK [Ensure nginx is running and enabled] *************************
changed: [web01]

PLAY RECAP **********************************************************
web01                      : ok=3    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Step 6 — Run It Again

$ ansible-playbook site.yml

PLAY RECAP **********************************************************
web01                      : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

changed=0 on the second run is the whole point. Nothing changed because nothing needed to change — nginx was already installed and already running. This is idempotency made visible in one line of output, and it's what makes "just run the playbook again" a safe operational default instead of a gamble.

What Actually Happened

Line What it means
hosts: web Targets the [web] group from inventory.ini
become: true Escalates privilege for this play's tasks (install/start need root)
ansible.builtin.package A cross-distro module — resolves to apt, dnf, etc. depending on the target OS
state: present Desired state: installed, don't care about exact version
ansible.builtin.service Manages the running/enabled state of a service
changed: [web01] The module compared current state to desired state and made a change
ok: [web01] State already matched — nothing to do

Common Mistakes

  • Skipping --check --diff and running straight against production on a playbook you just wrote.
  • Forgetting become: true and getting a confusing "permission denied" from the module, not from SSH — easy to misdiagnose as a connection problem when it's actually a privilege problem. See Troubleshooting.
  • Not noticing changed=2 on the first run and changed=0 on the second — that comparison is your fastest idempotency check on any new playbook.

Interview Questions

  • What's the difference between an ad-hoc command and a playbook?
  • Why run --check --diff before a real run?
  • What does it mean if a task reports changed on every single run?

Next

Continue to Core Concepts to build the vocabulary behind every line you just wrote — inventory, modules, tasks, and variables.