Skip to content
DevOps devops iac 6 min read

Writing Ansible Playbooks

A playbook is the heart of Ansible. It is a plain-text file written in YAML (a human-friendly format for writing structured data, short for “YAML Ain’t Markup Language”) that describes the desired state of your servers — what packages should be installed, which files should exist, and which services should be running. Instead of typing commands by hand on every server, you write the steps once in a playbook and Ansible runs them for you across one machine or a thousand. This page walks through a real playbook that installs and starts Nginx on Ubuntu, and explains every piece along the way.

What a playbook actually is

When you run commands by hand (SSH into a box, sudo apt install nginx, edit a config, restart the service), the steps live only in your memory or a scratch file. A playbook turns those steps into code you can version-control, review, and re-run. It is declarative: you describe the end result you want, and Ansible figures out what needs to change to get there.

A playbook is made of one or more plays. A play maps a group of hosts (the servers you want to manage) to a list of tasks. Each task calls a module — a small, focused unit of work like “install a package” (apt) or “copy a file” (copy).

When to use a playbook: any time you would otherwise SSH in and run more than one or two commands, or any time you need the same setup repeated on multiple servers. For a single throwaway command on one box, a plain SSH session is fine — reach for a playbook the moment the work needs to be repeatable or documented.

Anatomy of a playbook

Here is the skeleton, with every top-level key explained below. Create a file called nginx.yml:

---
- name: Install and configure Nginx
  hosts: webservers
  become: true
  vars:
    server_name: example.com
  tasks:
    - name: Ensure Nginx is installed
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true
KeyWhat it does
nameA human-readable label for the play. Shows up in the output so you know what is running.
hostsWhich group of servers (from your inventory file) this play targets. webservers is a group name, not a real hostname.
becometrue tells Ansible to run tasks with sudo (elevated privileges). Needed for installing packages or editing system files.
varsVariables you define once and reuse. Reference them later with {{ server_name }}.
tasksThe ordered list of work to do. Each task runs top to bottom.

The inventory is a separate file listing your servers. A minimal inventory.ini:

[webservers]
web1 ansible_host=203.0.113.10 ansible_user=ubuntu

The [webservers] header defines the group that hosts: webservers refers to.

A complete Nginx playbook

This full example installs Nginx, drops in a custom site config from a template, enables the firewall rule, and starts the service. Save it as nginx.yml:

---
- name: Install and configure Nginx
  hosts: webservers
  become: true
  vars:
    server_name: example.com
    site_root: /var/www/example

  tasks:
    - name: Install Nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true

    - name: Create the site document root
      ansible.builtin.file:
        path: "{{ site_root }}"
        state: directory
        owner: www-data
        group: www-data
        mode: "0755"

    - name: Copy a static index page
      ansible.builtin.copy:
        src: files/index.html
        dest: "{{ site_root }}/index.html"
        mode: "0644"

    - name: Deploy the Nginx site config from a template
      ansible.builtin.template:
        src: templates/site.conf.j2
        dest: /etc/nginx/sites-available/example.conf
        mode: "0644"
      notify: Reload Nginx

    - name: Enable the site
      ansible.builtin.file:
        src: /etc/nginx/sites-available/example.conf
        dest: /etc/nginx/sites-enabled/example.conf
        state: link
      notify: Reload Nginx

    - name: Allow HTTP through the firewall
      community.general.ufw:
        rule: allow
        port: "80"
        proto: tcp

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

  handlers:
    - name: Reload Nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

Notice the firewall port is written as "80" in quotes — in YAML an unquoted number is treated as an integer, but the ufw module wants a string, so quoting keeps things safe.

The modules used here

ModuleJobCommon options
aptInstall/remove Ubuntu packagesname, state: present/absent, update_cache
fileManage files, directories, symlinks, permissionspath, state: directory/link, owner, mode
copyCopy a static file from your machine to the serversrc, dest, mode
templateCopy a file and fill in variables ({{ }}) firstsrc (a .j2 file), dest
serviceStart, stop, restart, or enable a systemd servicename, state, enabled
ufwManage the Uncomplicated Firewallrule, port, proto

The template file

The template module uses Jinja2 (a templating engine that substitutes {{ variables }}). Create templates/site.conf.j2:

server {
    listen 80;
    server_name {{ server_name }};
    root {{ site_root }};
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

When the playbook runs, {{ server_name }} becomes example.com and {{ site_root }} becomes /var/www/example. Use template (not copy) whenever a file needs values that differ between servers.

Handlers — running something only when needed

A handler is a special task that runs only if another task reports a change, and only once at the end of the play. In the example, two tasks have notify: Reload Nginx. If either the config template or the symlink changes, Nginx reloads — but if nothing changed, it does not reload at all. This avoids needless restarts.

Running the playbook

ansible-playbook -i inventory.ini nginx.yml

Output:

PLAY [Install and configure Nginx] *********************************************

TASK [Install Nginx] ***********************************************************
changed: [web1]

TASK [Deploy the Nginx site config from a template] ****************************
changed: [web1]

TASK [Ensure Nginx is running and enabled at boot] *****************************
changed: [web1]

RUNNING HANDLER [Reload Nginx] *************************************************
changed: [web1]

PLAY RECAP *********************************************************************
web1  : ok=8  changed=6  unreachable=0  failed=0  skipped=0

Idempotency in practice

Idempotency means running the same playbook many times produces the same result, and only makes changes when something is actually out of place. The first run installs Nginx and reports changed. Run the exact same command again:

ansible-playbook -i inventory.ini nginx.yml

Output:

PLAY RECAP *********************************************************************
web1  : ok=8  changed=0  unreachable=0  failed=0  skipped=0

This time changed=0 — Nginx is already installed, the config already matches, the service is already running, so Ansible does nothing. The handler does not fire because nothing changed. This is the core power of Ansible: you can run a playbook on a schedule or after every code change without fear of breaking a working server. Most built-in modules (apt, file, service, template) are idempotent by design. Be careful with the command and shell modules — they run blindly every time unless you add guards like creates: or when:.

Gotcha: never put secrets (passwords, API keys, TLS private keys) as plain text in a playbook or inventory. Use ansible-vault encrypt to encrypt sensitive files, and reference them as variables. A playbook is code you will commit to Git — treat it like a public file.

Best practices

  • Always set name: on every play and task — clear names make the output readable and turn the playbook into living documentation.
  • Use fully-qualified module names (ansible.builtin.apt) so your playbooks stay unambiguous as Ansible evolves.
  • Keep variables out of tasks: define them under vars:, in a group_vars/ file, or in a role, so the same playbook works across environments.
  • Prefer template over copy whenever a file content differs per host, and prefer idempotent modules over raw shell/command.
  • Use handlers for restarts and reloads so services only bounce when something truly changed.
  • Test with --check (a dry run that reports what would change) before applying to production: ansible-playbook -i inventory.ini nginx.yml --check.
  • Encrypt every secret with Ansible Vault and never commit unencrypted credentials.
Last updated June 15, 2026
Was this helpful?