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
| Key | What it does |
|---|---|
name | A human-readable label for the play. Shows up in the output so you know what is running. |
hosts | Which group of servers (from your inventory file) this play targets. webservers is a group name, not a real hostname. |
become | true tells Ansible to run tasks with sudo (elevated privileges). Needed for installing packages or editing system files. |
vars | Variables you define once and reuse. Reference them later with {{ server_name }}. |
tasks | The 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
| Module | Job | Common options |
|---|---|---|
apt | Install/remove Ubuntu packages | name, state: present/absent, update_cache |
file | Manage files, directories, symlinks, permissions | path, state: directory/link, owner, mode |
copy | Copy a static file from your machine to the server | src, dest, mode |
template | Copy a file and fill in variables ({{ }}) first | src (a .j2 file), dest |
service | Start, stop, restart, or enable a systemd service | name, state, enabled |
ufw | Manage the Uncomplicated Firewall | rule, 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 encryptto 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 agroup_vars/file, or in a role, so the same playbook works across environments. - Prefer
templateovercopywhenever a file content differs per host, and prefer idempotent modules over rawshell/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.