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.

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).
Imagine the difference between two ways of communicating:
In a healthy infrastructure team, almost all production changes — even trivial ones — must be documented and auditable. Playbooks answer that need. Their main advantages:
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.
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:
---
- 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: touchLet's break down each key:
| Key | Function | Notes |
|---|---|---|
name | Label for the play or task | Required for readable logs; tasks without name are hard to trace |
hosts | The play's target host pattern | Can be all, a group name, or a pattern combination from episode 3 |
become | Enables privilege escalation (sudo) | Value true/false |
become_user | The user to use after escalation | Defaults to root if not written |
become_method | The escalation method | sudo (default), su, runas, and others |
tasks | The list of tasks executed in order | Order matters — the top task finishes before the one below |
vars | Variables that apply to the whole play | Covered in depth in episode 7 |
becomeMost administrative tasks — installing packages, modifying files in /etc, restarting services — require root privileges. Ansible handles this through the become mechanism:
- 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:
- name: Setup NGINX web server
hosts: webservers
become: true
become_user: root
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: presentNow it's time to practice. We'll write a playbook that:
---
- 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: trueExplanation 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.
ansible-playbookTo execute it, we need the inventory file (from episode 3) and the ansible-playbook command:
ansible-playbook -i inventory.yml nginx-playbook.ymlNotice 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.
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:
ansible-playbook -i inventory.yml nginx-playbook.yml --checkCaution
--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):
ansible-playbook -i inventory.yml nginx-playbook.yml --diffTASK [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:
| Flag | Additional info |
|---|---|
-v | Standard task execution detail |
-vv | Adds connection plugin output |
-vvv | Adds credentials and SSH command detail (for deep debugging) |
ansible-playbook -i inventory.yml nginx-playbook.yml --check --diffTip
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.
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:
| Status | Meaning | Example Cause |
|---|---|---|
ok | Task succeeded and nothing changed (state already matches) | NGINX already installed, no need to reinstall |
changed | Task succeeded and made changes | A new package was installed, a new file was written |
failed | Task failed and stops the playbook | Package not found, permission denied |
skipped | Task wasn't run because a condition wasn't met | A task with when: evaluating to false (episode 9) |
unreachable | Host couldn't be reached at all | Server down, SSH inaccessible |
To see idempotency in action, run the same playbook twice in a row:
ansible-playbook -i inventory.yml nginx-playbook.ymlFirst execution — fresh system, all tasks change:
web-01.prod.example.com : ok=4 changed=4 unreachable=0 failed=0 skipped=0Second execution — the system already matches the desired state, nothing changes:
web-01.prod.example.com : ok=4 changed=0 unreachable=0 failed=0 skipped=0Notice 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.
| Mistake | Symptom | Solution |
|---|---|---|
Forgetting become: true for root tasks | Permission denied when installing/restarting | Add become at the play or task level |
| Wrong YAML indentation | Parsing errors like mapping values are not allowed here | Use a YAML extension in your editor; YAML Playbook uses spaces, not tabs |
Tasks without name | Logs are hard to read and troubleshoot | Always give every task a descriptive name |
| Running straight to production without a dry run | Unexpected changes when applying | Get used to --check --diff first |
Using command for things that have a module | Playbook isn't idempotent | Check ansible-doc -l for the right module |
| Multiple plays in one file without context | The playbook targets the wrong hosts | Each first play's hosts: determines the scope |
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!