Ansible Course
Writing Your First Playbook
In this lesson
Writing your first playbook is the step where Ansible stops being theory and becomes a tool you actually use. This lesson is entirely practical — no new concepts, only application. You will build a real playbook from scratch, task by task, run it against a managed node, verify the result, run it again to confirm idempotency, and then extend it with additional tasks. By the end you will have written, executed, and debugged your first piece of production-grade Ansible automation.
The Scenario
The scenario: Your team runs a small but growing web application. A new Ubuntu 22.04 server has just been provisioned and needs to be configured as a web server before the application can be deployed. The requirements are straightforward but they must be repeatable — the same playbook will run against every new server that joins the fleet.
Server requirements
System packages are up to date
Nginx is installed and running
Nginx starts automatically on every boot
A custom
/var/www/html/index.html is deployed
A deploy user exists with sudo access for future deployments
Setting Up the Project Files
Before writing the playbook, create the three files the project needs. This takes less than two minutes and gives you a clean, properly structured starting point as established in Lesson 10.
Step 1
Create the project directory and initialise Git
mkdir ~/ansible-webserver && cd ~/ansible-webserver
git init
Step 2
Create ansible.cfg
[defaults]
inventory = ./inventory.ini
remote_user = ansible
private_key_file = ~/.ssh/id_ed25519
host_key_checking = False # fine for lab — enable in production
[privilege_escalation]
become = True
become_method = sudo
[ssh_connection]
pipelining = True
Step 3
Create inventory.ini
[webservers]
# Replace with your managed node's IP or hostname
192.168.1.10 ansible_user=ansible
Step 4
Verify connectivity before writing a single line of playbook
# Always confirm ping before your first playbook run
ansible webservers -m ansible.builtin.ping
192.168.1.10 | SUCCESS => {
"changed": false,
"ping": "pong"
}The Construction Blueprint Analogy
Writing a playbook is like drawing a construction blueprint before breaking ground. You define what the finished building must look like — which rooms exist, where the pipes run, how the wiring is laid out — and hand it to the builders (Ansible) to execute. The blueprint does not care whether the building currently exists or is halfway built; it describes the end state, and the builders figure out what work remains. Run the same blueprint on a finished building and the builders confirm everything matches — no work needed.
Building the Playbook Task by Task
Build the playbook incrementally — add one
task at a time, run it, confirm it works, then add the next. This is how experienced
engineers write Ansible: small, verifiable steps rather than a 50-task playbook you
run blind. Create webserver.yml and add each block in order.
1 Play header and package update
---
- name: Provision web server
hosts: webservers
become: true
tasks:
- name: Update apt package cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600 # only update if cache is older than 1 hour
# this makes the task idempotent
2 Install Nginx
- name: Install Nginx web server
ansible.builtin.package:
name: nginx
state: present # install if missing; skip if already installed
3 Ensure Nginx is started and enabled
- name: Ensure Nginx is started and enabled on boot
ansible.builtin.service:
name: nginx
state: started # start now if not running
enabled: true # start automatically after every reboot
4 Deploy the custom index page
- name: Deploy custom index page
ansible.builtin.copy:
content: |
Provisioned by Ansible
Server: {{ inventory_hostname }}
dest: /var/www/html/index.html
owner: www-data
group: www-data
mode: "0644"
5 Create the deploy user
- name: Create deploy user for application deployments
ansible.builtin.user:
name: deploy
shell: /bin/bash
groups: sudo # add to sudo group for elevated access
append: true # append to groups — do not replace existing ones
state: present
The Complete Playbook
Here is the full
webserver.yml with all five tasks assembled. This is the file you
run against your managed node.
---
- name: Provision 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 web server
ansible.builtin.package:
name: nginx
state: present
- name: Ensure Nginx is started and enabled on boot
ansible.builtin.service:
name: nginx
state: started
enabled: true
- name: Deploy custom index page
ansible.builtin.copy:
content: |
Provisioned by Ansible
Server: {{ inventory_hostname }}
dest: /var/www/html/index.html
owner: www-data
group: www-data
mode: "0644"
- name: Create deploy user for application deployments
ansible.builtin.user:
name: deploy
shell: /bin/bash
groups: sudo
append: true
state: present
Running and Verifying
Run the playbook and then verify each requirement was met. Do not trust the play recap alone — check the actual state of the managed node.
Run the playbook
# Dry run first — see what would change
ansible-playbook webserver.yml --check
# If the dry run looks correct, apply it
ansible-playbook webserver.yml
PLAY [Provision web server] *************************************************** TASK [Gathering Facts] ******************************************************** ok: [192.168.1.10] TASK [Update apt package cache] *********************************************** changed: [192.168.1.10] TASK [Install Nginx web server] *********************************************** changed: [192.168.1.10] TASK [Ensure Nginx is started and enabled on boot] **************************** changed: [192.168.1.10] TASK [Deploy custom index page] *********************************************** changed: [192.168.1.10] TASK [Create deploy user for application deployments] ************************* changed: [192.168.1.10] PLAY RECAP ******************************************************************** 192.168.1.10 : ok=6 changed=5 unreachable=0 failed=0 skipped=0
What just happened?
All 5 tasks reported changed —
expected on a fresh server where nothing was configured. The ok=6
includes the Gathering Facts task. failed=0 confirms success.
Now verify the actual state of the server before declaring victory.
Verify each requirement on the managed node
# Check Nginx is running
ansible webservers -m ansible.builtin.command -a "systemctl status nginx"
# Check Nginx serves a response on port 80
ansible webservers -m ansible.builtin.uri -a "url=http://localhost return_content=yes"
# Check the deploy user exists
ansible webservers -m ansible.builtin.command -a "id deploy"
# Or SSH in directly and check manually
ssh ansible@192.168.1.10
curl localhost # should return your custom HTML page
id deploy # should show the deploy user
The Idempotency Test
Run the playbook a second time without
changing anything. Every task should report ok — not changed.
This is the idempotency test from Lesson 10 in practice.
# Second run — nothing should change
ansible-playbook webserver.yml
PLAY [Provision web server] *************************************************** TASK [Gathering Facts] ******************************************************** ok: [192.168.1.10] TASK [Update apt package cache] *********************************************** ok: [192.168.1.10] <-- cache still valid (under 3600 seconds) TASK [Install Nginx web server] *********************************************** ok: [192.168.1.10] <-- already installed TASK [Ensure Nginx is started and enabled on boot] **************************** ok: [192.168.1.10] <-- already running and enabled TASK [Deploy custom index page] *********************************************** ok: [192.168.1.10] <-- file content unchanged (checksum match) TASK [Create deploy user for application deployments] ************************* ok: [192.168.1.10] <-- user already exists PLAY RECAP ******************************************************************** 192.168.1.10 : ok=6 changed=0 unreachable=0 failed=0 skipped=0
✓ Idempotency confirmed
changed=0 on the
second run means every task correctly detected the existing state and took no action.
This playbook is safe to run at any time — during a deployment pipeline, as a
remediation check, or when onboarding new servers — without risk of unintended
changes.
Extending the Playbook
Now that the core playbook works and is idempotent, extend it with the patterns you will use in every real project. These additions do not change the behaviour of the existing tasks — they make the playbook smarter, safer, and more informative.
Recommended extensions
vars:
Move hardcoded
values (nginx, deploy, www-data) into
play variables. This makes the playbook configurable without editing the task
definitions themselves.
notify/handlers
Add a handler that
restarts Nginx only when the index page changes. Without a handler, a config
change takes effect immediately — the service would need a manual restart.
Covered in depth in Lesson 14.
tags:
Tag tasks by
category (packages, config, users)
so you can run only part of the playbook when iterating.
Covered in Lesson 22.
vars_files:
Move variables
into a separate vars/main.yml file. This keeps the playbook
focused on logic and variables in one dedicated, easy-to-find location.
Extended version — with variables and a handler
---
- name: Provision web server
hosts: webservers
become: true
vars:
web_package: nginx
web_user: www-data
deploy_user: deploy
index_content: |
Provisioned by Ansible
Server: {{ inventory_hostname }}
tasks:
- name: Update apt package cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Install Nginx web server
ansible.builtin.package:
name: "{{ web_package }}"
state: present
- name: Ensure Nginx is started and enabled on boot
ansible.builtin.service:
name: "{{ web_package }}"
state: started
enabled: true
- name: Deploy custom index page
ansible.builtin.copy:
content: "{{ index_content }}"
dest: /var/www/html/index.html
owner: "{{ web_user }}"
group: "{{ web_user }}"
mode: "0644"
notify: Reload Nginx # trigger handler only on change
- name: Create deploy user
ansible.builtin.user:
name: "{{ deploy_user }}"
shell: /bin/bash
groups: sudo
append: true
state: present
handlers:
- name: Reload Nginx # only runs if notified AND something changed
ansible.builtin.service:
name: "{{ web_package }}"
state: reloaded
Always Run --check Before Applying to a Server You Did Not Provision
When running a playbook against
an existing server for the first time — one that was configured manually before
Ansible was introduced — use --check --diff first. The diff output
will show you exactly what Ansible intends to change. A task that overwrites a
manually customised config file with a default template is perfectly valid YAML
but potentially catastrophic if you did not expect it.
Key Takeaways
cache_valid_time makes apt updates idempotent
— without it, update_cache: true runs and reports
changed on every single playbook run, even when the cache
is fresh.
{{ variable }} references
makes the playbook reusable across environments without editing task
definitions.
Teacher's Note
Run this playbook for real, not
just in your head. The moment you see changed=0 on the second run you
have personally verified idempotency — and that experience is worth ten lessons of
reading about it.
Practice Questions
1. Which parameter added to the
ansible.builtin.apt task prevents the package cache from being
updated on every single playbook run?
2. When using
ansible.builtin.user to add a user to the sudo group,
which parameter must be set to true to avoid removing the user from
their existing groups?
3. What should the play recap show in
the changed field when a fully idempotent playbook is run for the
second time against an unchanged server?
Quiz
1. You are running a new playbook against an existing production server for the first time. What is the safest approach?
2. The Deploy custom index page task
uses ansible.builtin.copy. Why does it report ok on the
second run even though it contains inline content?
3. The index page uses
{{ inventory_hostname }} in its content. What does this resolve to
when Ansible runs the task?
Up Next · Lesson 14
Tasks, Handlers and Variables
Go deeper into the building blocks you used in this lesson — learn how variables work at every scope level, and how handlers let you trigger actions only when something actually changes.