An Ansible playbook is a YAML file that describes how servers should be configured. Run it once to set up a new server, run it again any time to bring servers back in line — it only changes what differs. Kept in Git, playbooks become documentation and history of your infrastructure.
This tutorial assumes Ansible is installed with an inventory — see installing Ansible.
Anatomy of a playbook
- name: Configure web servers # a play
hosts: web # inventory group
become: true # use sudo
vars:
site_domain: example.com
tasks: # ordered list of tasks
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
- name: Ensure Nginx is running
ansible.builtin.service:
name: nginx
state: started
enabled: true
Run it:
ansible-playbook site.yml
ansible-playbook site.yml --check --diff # dry run with diffs
ansible-playbook site.yml --limit web1 # one host
The summary (ok, changed, failed) tells you exactly what happened on each host.
Variables
Define them in the play, in files, or per host/group:
group_vars/
all.yml # every host
web.yml # hosts in [web]
host_vars/
web1.yml # one host
# group_vars/web.yml
site_domain: example.com
php_version: "8.3"
Use them anywhere with {{ variable }}. Gathered facts are variables too, e.g. {{ ansible_distribution }}, {{ ansible_memtotal_mb }}.
Templates
Templates (Jinja2) generate config files from variables. templates/site.conf.j2:
server {
listen 80;
server_name {{ site_domain }} www.{{ site_domain }};
root /var/www/{{ site_domain }};
index index.html index.php;
}
Task:
- name: Nginx site config
ansible.builtin.template:
src: site.conf.j2
dest: /etc/nginx/sites-available/{{ site_domain }}
mode: "0644"
notify: Reload nginx
Handlers
Handlers run once at the end of the play, only if notified — perfect for reloads after config changes:
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
If the template didn’t change, Nginx isn’t reloaded.
Loops and conditionals
- name: Install base packages
ansible.builtin.apt:
name: "{{ item }}"
state: present
loop:
- htop
- curl
- unattended-upgrades
- name: Only on Ubuntu 24.04
ansible.builtin.debug:
msg: "Noble!"
when: ansible_distribution == "Ubuntu" and ansible_distribution_version == "24.04"
(For packages, passing the list directly to name: is faster than a loop.)
A complete example: a hardened web server
- name: Base web server
hosts: web
become: true
vars:
admin_user: alice
admin_key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
tasks:
- name: Update apt cache and upgrade
ansible.builtin.apt:
update_cache: true
upgrade: dist
cache_valid_time: 3600
- name: Install packages
ansible.builtin.apt:
name: [nginx, ufw, fail2ban, unattended-upgrades]
state: present
- name: Admin user
ansible.builtin.user:
name: "{{ admin_user }}"
groups: sudo
append: true
shell: /bin/bash
- name: Admin SSH key
ansible.posix.authorized_key:
user: "{{ admin_user }}"
key: "{{ admin_key }}"
- name: Disable SSH password login
ansible.builtin.copy:
dest: /etc/ssh/sshd_config.d/10-hardening.conf
content: |
PasswordAuthentication no
PermitRootLogin no
mode: "0644"
validate: /usr/sbin/sshd -t -f %s
notify: Reload ssh
- name: Firewall rules
community.general.ufw:
rule: allow
name: "{{ item }}"
loop: [OpenSSH, Nginx Full]
- name: Enable firewall
community.general.ufw:
state: enabled
default: deny
handlers:
- name: Reload ssh
ansible.builtin.service:
name: ssh
state: reloaded
The validate option checks the SSH config before installing it — a safeguard against locking yourself out. community.general and ansible.posix collections come with the full ansible package.
Tags
- name: Nginx site config
ansible.builtin.template: { src: site.conf.j2, dest: /etc/nginx/sites-available/site }
tags: [nginx]
ansible-playbook site.yml --tags nginx
Organising larger projects
As playbooks grow, split them into roles (reusable units like nginx, postgres, hardening) — see Ansible roles. Encrypt secrets with Ansible Vault.
Tips
- Prefer modules over
shell/command; when you must use them, addcreates:orchanged_when:to keep runs idempotent. - Run
ansible-lintto catch mistakes. - Always try
--check --diffon production first.
Related: Ansible server hardening and infrastructure as code.