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, add creates: or changed_when: to keep runs idempotent.
  • Run ansible-lint to catch mistakes.
  • Always try --check --diff on production first.

Related: Ansible server hardening and infrastructure as code.

  • Ansible
  • Automation
  • DevOps