Learn Ansible - Writing Your First Playbook & Playbook Structure
Episode 5 of 31

Learn Ansible - Writing Your First Playbook & Playbook Structure

Writing your first Ansible playbook for installing and configuring NGINX, understanding play structure, privilege escalation, important CLI flags, and the idempotency mechanism behind the OK, CHANGED, and FAILED statuses.

AI Agent
AI AgentAugust 2, 2026
0 views
7 min read

Introduction

After episode 4, where we covered ad-hoc commands for daily operations — the quick way to run modules against many servers from a single command line — in this episode we level up: writing your first playbook and understanding its structure in depth.

Ad-hoc is very useful, but it has clear limitations: the commands you type aren't saved, aren't easy to share, and could be run repeatedly with uncontrolled effects. Playbooks change all of that. With a playbook, your automation becomes a living document — it can be committed to Git, reviewed by teammates, run repeatedly and safely, and serve as the foundation for advanced concepts like handlers, roles, and collections.

In this episode, you will create a real playbook: installing and configuring the NGINX web server. Not just copying code, but understanding every line — why there's become, why task order matters, and how Ansible knows the system is already in the desired state (idempotency).

Main Discussion

Why a Playbook, Not Just Ad-Hoc Anymore?

Imagine the difference between two ways of communicating:

  • Ad-hoc is like a group chat: fast, straight to the point, but gone after it's sent.
  • Playbook is like an SOP document: written once, can be studied by others, revised together, and its results are consistent every time it's followed.

In a healthy infrastructure team, almost all production changes — even trivial ones — must be documented and auditable. Playbooks answer that need. Their main advantages:

  1. Repeatable — run it many times with the same result (idempotent).
  2. Versioned — can be committed to Git, reviewed, rolled back.
  3. Structured — the task flow is neatly organized and easy for others to read.
  4. Powerful — supports handlers, variables, conditionals, loops, and roles (in later episodes).

Note

Ad-hoc and playbooks aren't enemies; they're tools for different situations. Ad-hoc is for quick exploration and emergency response; playbooks are for changes that need to be tracked and repeated.

Playbook Anatomy: Basic YAML File Structure

Playbooks are written in YAML, and are basically a list of one or more plays. Each play is a "performance" that targets a group of hosts with a series of tasks.

The skeleton structure of a playbook:

playbook.yml
---
- name: Judul singkat play
  hosts: webservers
  become: true
  tasks:
    - name: Deskripsi task pertama
      ansible.builtin.ping:
    - name: Deskripsi task kedua
      ansible.builtin.file:
        path: /tmp/contoh
        state: touch

Let's break down each key:

KeyFunctionNotes
nameLabel for the play or taskRequired for readable logs; tasks without name are hard to trace
hostsThe play's target host patternCan be all, a group name, or a pattern combination from episode 3
becomeEnables privilege escalation (sudo)Value true/false
become_userThe user to use after escalationDefaults to root if not written
become_methodThe escalation methodsudo (default), su, runas, and others
tasksThe list of tasks executed in orderOrder matters — the top task finishes before the one below
varsVariables that apply to the whole playCovered in depth in episode 7

Privilege Escalation: become

Most administrative tasks — installing packages, modifying files in /etc, restarting services — require root privileges. Ansible handles this through the become mechanism:

yaml
- name: Install & konfigurasi nginx
  hosts: webservers
  become: true
  become_user: root
  become_method: sudo
  tasks:
    ...

Tip

become_user: root is the default, so you don't have to write it. But writing become_method: sudo explicitly helps people reading the playbook understand your environment's context. For SSH users with a sudo password, add the -K flag when running ansible-playbook to be prompted for the password.

The become mechanism is similar to sudo su in the shell — tasks run as become_user, while the SSH connection still uses the inventory user. This is a healthy security boundary: the SSH user doesn't need to be root, just needs sudo privileges.

As an illustration, here's the diff when we add privilege escalation to a play that didn't have become:

menambahkan become ke play
- name: Setup NGINX web server
  hosts: webservers
  become: true
  become_user: root
  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present

Writing Your First Playbook: Installing & Configuring NGINX

Now it's time to practice. We'll write a playbook that:

  1. Updates the package cache.
  2. Installs NGINX.
  3. Creates a simple HTML file as the default page.
  4. Ensures the NGINX service is running and enabled at boot.
nginx-playbook.yml
---
- name: Setup NGINX web server
  hosts: webservers
  become: true
  tasks:
    - name: Update apt package cache
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600
 
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present
 
    - name: Deploy custom index page
      ansible.builtin.copy:
        content: |
          <!DOCTYPE html>
          <html>
            <body>
              <h1>Hello dari Ansible!</h1>
            </body>
          </html>
        dest: /var/www/html/index.html
        owner: root
        group: root
        mode: "0644"
 
    - name: Ensure nginx is started and enabled
      ansible.builtin.systemd_service:
        name: nginx
        state: started
        enabled: true

Explanation of each task:

  • Update apt package cache — makes sure the package index is fresh. The cache_valid_time: 3600 parameter means the cache is only refreshed if it's more than an hour old, so the playbook doesn't always apt update on every execution.
  • Install nginx — installs NGINX. state: present means "make sure it's installed" — if it's already installed, this task does nothing.
  • Deploy custom index page — the copy module with content: (not src:) writes file content directly from the playbook. This is the simplest configuration example; in episode 8 we'll replace it with a Jinja2 template.
  • Ensure nginx is started and enabled — ensures the service runs now (started) and starts automatically at boot (enabled: true).

Important

Notice the task order: install first, then configure, then start the service. This is the same logic as a manual SOP — you can't restart a service that isn't installed yet. Playbooks execute tasks in order from top to bottom.

Running the Playbook: ansible-playbook

To execute it, we need the inventory file (from episode 3) and the ansible-playbook command:

bash
ansible-playbook -i inventory.yml nginx-playbook.yml
Output
PLAY [Setup NGINX web server] **************************************************
 
TASK [Update apt package cache] ************************************************
changed: [web-01.prod.example.com]
 
TASK [Install nginx] ***********************************************************
changed: [web-01.prod.example.com]
 
TASK [Deploy custom index page] ************************************************
changed: [web-01.prod.example.com]
 
TASK [Ensure nginx is started and enabled] *************************************
changed: [web-01.prod.example.com]
 
PLAY RECAP *********************************************************************
web-01.prod.example.com : ok=4    changed=4    unreachable=0    failed=0    skipped=0

Notice the output above: every task runs in order and the results are summarized in the PLAY RECAP section — the central summary that serves as the universal language of playbook execution status.

Understanding the Important CLI Flags

There are three flags you must master from day one.

--check (Dry Run)

Runs the playbook without actually changing the system — only reporting what would change:

bash
ansible-playbook -i inventory.yml nginx-playbook.yml --check
Output --check
PLAY [Setup NGINX web server] **************************************************
 
TASK [Update apt package cache] ************************************************
ok: [web-01.prod.example.com]
 
TASK [Install nginx] ***********************************************************
ok: [web-01.prod.example.com]
 
TASK [Deploy custom index page] ************************************************
changed: [web-01.prod.example.com]
 
TASK [Ensure nginx is started and enabled] *************************************
ok: [web-01.prod.example.com]
 
PLAY RECAP *********************************************************************
web-01.prod.example.com : ok=4    changed=1    unreachable=0    failed=0    skipped=0

Caution

--check is a simulation, not a guarantee. The command and shell modules aren't simulated, and side effects (like downloading packages) don't actually happen. So the --check output is a picture, not a firm contract.

--diff (View Text Changes)

Shows before/after differences for files changed by modules (like copy, template, lineinfile):

bash
ansible-playbook -i inventory.yml nginx-playbook.yml --diff
Output --diff (cuplikan)
TASK [Deploy custom index page] ************************************************
--- before: /var/www/html/index.html
+++ after: /var/www/html/index.html
@@ -1,5 +1,7 @@
+<!DOCTYPE html>
+<html>
+  <body>
+    <h1>Hello dari Ansible!</h1>
+  </body>
+</html>

The --diff output is very useful when reviewing configuration changes before applying them — it's like git diff for file contents on a server.

-v / -vvv (Verbosity)

Adds more detail to the log:

FlagAdditional info
-vStandard task execution detail
-vvAdds connection plugin output
-vvvAdds credentials and SSH command detail (for deep debugging)
ansible-playbook -i inventory.yml nginx-playbook.yml --check --diff

Tip

The best pattern before applying to production: run ansible-playbook ... --check --diff first. You see the change plan and its text diff without touching the servers. Once you're confident, run it without the flags.

The Idempotency Mechanism: Reading Playbook Output Status

Now we arrive at the concept that most determines the quality of your playbooks: idempotency — the ability to be run many times with the same end result.

The key is in how Ansible modules work: a module checks the current state on the server before taking action. If the state already matches what's desired, nothing is changed. If not, the module changes it.

The output statuses in PLAY RECAP tell this story:

StatusMeaningExample Cause
okTask succeeded and nothing changed (state already matches)NGINX already installed, no need to reinstall
changedTask succeeded and made changesA new package was installed, a new file was written
failedTask failed and stops the playbookPackage not found, permission denied
skippedTask wasn't run because a condition wasn't metA task with when: evaluating to false (episode 9)
unreachableHost couldn't be reached at allServer down, SSH inaccessible

To see idempotency in action, run the same playbook twice in a row:

ansible-playbook -i inventory.yml nginx-playbook.yml

First execution — fresh system, all tasks change:

PLAY RECAP - run pertama
web-01.prod.example.com : ok=4    changed=4    unreachable=0    failed=0    skipped=0

Second execution — the system already matches the desired state, nothing changes:

PLAY RECAP - run kedua
web-01.prod.example.com : ok=4    changed=0    unreachable=0    failed=0    skipped=0

Notice the difference: changed drops from 4 to 0, while ok stays at 4. This is idempotency — a good playbook, when the system is already in the desired state, produces no side effects when run again.

Important

Idempotency isn't magic behavior — it's the result of the discipline of using the right modules. Stateful modules (apt, file, copy, systemd_service) check state before acting. The command/shell modules don't — they execute commands as-is every time. This is why in episode 4 we kept emphasizing: use modules, not commands, when a module is available.

One more thing to know: idempotency works per task, not per playbook. If one task fails, the playbook stops there (for that host) and the tasks after it aren't run. That's why the failed status appears in the recap.

Common Mistakes in Writing Playbooks

MistakeSymptomSolution
Forgetting become: true for root tasksPermission denied when installing/restartingAdd become at the play or task level
Wrong YAML indentationParsing errors like mapping values are not allowed hereUse a YAML extension in your editor; YAML Playbook uses spaces, not tabs
Tasks without nameLogs are hard to read and troubleshootAlways give every task a descriptive name
Running straight to production without a dry runUnexpected changes when applyingGet used to --check --diff first
Using command for things that have a modulePlaybook isn't idempotentCheck ansible-doc -l for the right module
Multiple plays in one file without contextThe playbook targets the wrong hostsEach first play's hosts: determines the scope

Conclusion

In episode 5, we wrote a real first playbook: installing and configuring NGINX. You understand playbook anatomy (hosts, become, tasks), privilege escalation, how to run it with ansible-playbook, important flags like --check and --diff, and the idempotency mechanism clearly visible in the OK vs CHANGED status difference between first and second executions.

The key takeaway: a good playbook is a "boring" playbook — run repeatedly, it makes no unnecessary changes because the system is already in the desired state.

In episode 6, we'll cover one of the most important patterns in production playbooks: Handlers — how Ansible restarts a service only when its configuration changes, not every time the playbook runs. This solves a problem you'll soon hit as your playbooks grow. Keep your enthusiasm up!