Introduction

An Ansible playbook is a YAML file that defines automation tasks — install packages, deploy configs, restart services, create users. It's the core of Ansible: you describe what you want (desired state), and Ansible figures out how to get there.

Minimal Playbook

---
- name: My first playbook
  hosts: all
  become: true
  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present

Run it:

ansible-playbook -i inventory playbook.yml

Anatomy of a Playbook

---
# A playbook contains one or more PLAYS
- name: Configure web servers       # Play 1
  hosts: webservers                 # Which hosts to target
  become: true                      # Run as root (sudo)
  vars:                             # Variables for this play
    http_port: 80

  tasks:                            # List of tasks to execute
    - name: Install nginx           # Task 1
      ansible.builtin.apt:
        name: nginx
        state: present

    - name: Start nginx             # Task 2
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

  handlers:                         # Run only when notified
    - name: Restart nginx
      ansible.builtin.service:
        name: nginx
        state: restarted

- name: Configure databases         # Play 2
  hosts: dbservers
  become: true
  tasks:
    - name: Install PostgreSQL
      ansible.builtin.apt:
        name: postgresql
        state: present

Key Components

ComponentPurpose
nameDescription (shows in output)
hostsTarget hosts/groups from inventory
becomePrivilege escalation (sudo)
varsVariables for this play
tasksOrdered list of actions
handlersTasks triggered by notify
rolesInclude reusable role packages
gather_factsCollect host info (default: true)

Variables

---
- name: Deploy application
  hosts: appservers
  become: true
  vars:
    app_name: myapp
    app_port: 8080
    app_user: deploy
    packages:
      - nginx
      - redis
      - python3

  vars_files:
    - vars/common.yml
    - vars/secrets.yml        # Can be vault-encrypted

  tasks:
    - name: Install packages
      ansible.builtin.apt:
        name: "{{ packages }}"
        state: present

    - name: Create app user
      ansible.builtin.user:
        name: "{{ app_user }}"

    - name: Show config
      ansible.builtin.debug:
        msg: "{{ app_name }} will run on port {{ app_port }}"

Complete Example: Web Server Setup

---
- name: Set up web server
  hosts: webservers
  become: true
  vars:
    domain: example.com
    document_root: /var/www/html
    http_port: 80

  tasks:
    # 1. Install packages
    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600

    - name: Install web server packages
      ansible.builtin.apt:
        name:
          - nginx
          - certbot
          - python3-certbot-nginx
        state: present

    # 2. Configure
    - name: Create document root
      ansible.builtin.file:
        path: "{{ document_root }}"
        state: directory
        owner: www-data
        group: www-data
        mode: "0755"

    - name: Deploy nginx config
      ansible.builtin.template:
        src: templates/nginx-site.conf.j2
        dest: "/etc/nginx/sites-available/{{ domain }}"
        validate: "nginx -t -c %s"
      notify: Reload nginx

    - name: Enable site
      ansible.builtin.file:
        src: "/etc/nginx/sites-available/{{ domain }}"
        dest: "/etc/nginx/sites-enabled/{{ domain }}"
        state: link
      notify: Reload nginx

    - name: Deploy index page
      ansible.builtin.copy:
        content: |
          <!DOCTYPE html>
          <html>
          <body><h1>Welcome to {{ domain }}</h1></body>
          </html>
        dest: "{{ document_root }}/index.html"
        owner: www-data
        mode: "0644"

    # 3. Service management
    - name: Ensure nginx is running
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

    # 4. Firewall
    - name: Allow HTTP
      community.general.ufw:
        rule: allow
        port: "{{ http_port }}"
        proto: tcp

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

Run a Playbook

# Basic run
ansible-playbook -i inventory/hosts site.yml

# With extra variables
ansible-playbook site.yml -e "env=production app_version=2.0"

# Limit to specific hosts
ansible-playbook site.yml --limit web-01

# Check mode (dry run — no changes)
ansible-playbook site.yml --check

# Diff mode (show what would change)
ansible-playbook site.yml --check --diff

# Step through tasks one at a time
ansible-playbook site.yml --step

# Start at a specific task
ansible-playbook site.yml --start-at-task "Deploy nginx config"

# List all tasks without running
ansible-playbook site.yml --list-tasks

# Verbose output
ansible-playbook site.yml -v     # Some detail
ansible-playbook site.yml -vv    # More detail
ansible-playbook site.yml -vvv   # Full detail (SSH debug)

Tags

Run only specific tasks:

tasks:
  - name: Install packages
    ansible.builtin.apt:
      name: [nginx, redis]
    tags: [install, packages]

  - name: Deploy config
    ansible.builtin.template:
      src: app.conf.j2
      dest: /etc/app.conf
    tags: [config, deploy]

  - name: Start services
    ansible.builtin.service:
      name: nginx
      state: started
    tags: [services]
# Run only tasks tagged 'config'
ansible-playbook site.yml --tags config

# Run everything EXCEPT 'install'
ansible-playbook site.yml --skip-tags install

# Multiple tags
ansible-playbook site.yml --tags "config,services"

Multi-Play Playbook

---
# Play 1: Common setup on all hosts
- name: Common configuration
  hosts: all
  become: true
  tasks:
    - name: Update packages
      ansible.builtin.apt:
        upgrade: safe
        update_cache: true

    - name: Install common tools
      ansible.builtin.apt:
        name: [vim, curl, htop, git]

    - name: Set timezone
      community.general.timezone:
        name: UTC

# Play 2: Web servers only
- name: Web server setup
  hosts: webservers
  become: true
  roles:
    - nginx
    - certbot

# Play 3: Database servers only
- name: Database setup
  hosts: dbservers
  become: true
  roles:
    - role: postgresql
      vars:
        postgresql_version: 16

# Play 4: Post-deploy verification
- name: Verify deployment
  hosts: all
  gather_facts: false
  tasks:
    - name: Check connectivity
      ansible.builtin.ping:

Error Handling

tasks:
  # Ignore errors on a task
  - name: Check optional service
    ansible.builtin.command: systemctl status optional-svc
    ignore_errors: true

  # Block with rescue (try/catch)
  - name: Deploy with rollback
    block:
      - name: Deploy new version
        ansible.builtin.copy:
          src: app-v2.tar.gz
          dest: /opt/app/

      - name: Restart app
        ansible.builtin.service:
          name: myapp
          state: restarted

    rescue:
      - name: Rollback to previous version
        ansible.builtin.copy:
          src: app-v1.tar.gz
          dest: /opt/app/

      - name: Restart with old version
        ansible.builtin.service:
          name: myapp
          state: restarted

    always:
      - name: Send notification
        ansible.builtin.debug:
          msg: "Deployment {{ 'failed — rolled back' if ansible_failed_task is defined else 'succeeded' }}"

Best Practices

  1. Name every task — clear names make output readable and debugging easy
  2. Use FQCN — ansible.builtin.apt not just apt
  3. Use become at play level — not on every task
  4. Keep playbooks small — use roles for reusable components
  5. Use --check first — dry run before applying changes
  6. Use handlers — don't restart services unconditionally
  7. Use variables — never hardcode values that might change
  8. Use tags — allow running subsets of tasks

Troubleshooting

"ERROR! 'tasks' is not a valid attribute for a Play"

Indentation error — YAML is whitespace-sensitive:

# WRONG
- name: My play
  hosts: all
tasks:           # ← wrong indentation
  - name: ...

# RIGHT
- name: My play
  hosts: all
  tasks:         # ← indented under the play
    - name: ...

Task Always Shows "changed"

Use changed_when for commands:

- ansible.builtin.command: /opt/check-status.sh
  changed_when: false    # Read-only command

Conclusion

Playbooks are Ansible's automation scripts — YAML files that describe your desired infrastructure state. Start simple (one play, a few tasks), then grow into multi-play playbooks with roles, variables, handlers, and error handling. Always use --check for dry runs before applying changes. Name every task clearly, use FQCN module names, and keep playbooks focused — break large ones into roles for reusability.