Ansible Lesson 13 – Writing Your First Playbook | Dataplexa
Section II · Lesson 13

Writing Your First Playbook

In this lesson

Scenario setup Building the playbook Running & verifying Idempotency test Extending the playbook

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

1

System packages are up to date

2

Nginx is installed and running

3

Nginx starts automatically on every boot

4

A custom /var/www/html/index.html is deployed

5

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

Always verify connectivity with a ping before the first playbook run — a failed SSH connection produces a confusing error mid-playbook; a pre-flight ping pinpoints the problem in seconds.
Build playbooks incrementally — add one task, run it, verify it, then add the next. This makes debugging trivial because you always know which task introduced a problem.
Verify the actual system state after the first run — do not rely on the play recap alone. SSH in or use ad-hoc commands to confirm each requirement was met on the managed node itself.
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.
Move hardcoded values into variables as soon as the playbook works — replacing literal strings with {{ 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.