Ansible Playbooks

Playbooks are the heart of Ansible automation — YAML files that define the desired state of your infrastructure. They describe what should happen, on which hosts, and in what order.

Playbook Structure

Basic Playbook

---
# site.yml
- name: Configure web servers
  hosts: webservers
  become: true        # Run as root (sudo)
  gather_facts: true  # Collect system info before tasks

  vars:
    http_port: 80
    app_name: myapp

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

    - name: Start and enable Nginx
      service:
        name: nginx
        state: started
        enabled: true

    - name: Deploy configuration file
      template:
        src: nginx.conf.j2
        dest: /etc/nginx/sites-available/{{ app_name }}
        owner: root
        group: root
        mode: '0644'
      notify: Reload Nginx

  handlers:
    - name: Reload Nginx
      service:
        name: nginx
        state: reloaded

Multi-Play Playbook

---
# site.yml — configure multiple server types

- name: Configure database servers
  hosts: dbservers
  become: true
  roles:
    - postgresql

- name: Configure web servers
  hosts: webservers
  become: true
  roles:
    - nginx
    - myapp

- name: Configure load balancers
  hosts: loadbalancers
  become: true
  roles:
    - haproxy

Tasks

Task Options

tasks:
  - name: Ensure directory exists
    file:
      path: /opt/myapp
      state: directory
      owner: ubuntu
      group: ubuntu
      mode: '0755'

    # Conditionally run only on Ubuntu
    when: ansible_distribution == "Ubuntu"

    # Run as a different user
    become_user: ubuntu

    # Ignore errors and continue
    ignore_errors: true

    # Retry on failure
    retries: 3
    delay: 5
    register: result
    until: result is succeeded

    # Tag for selective execution
    tags:
      - filesystem
      - setup

Loops

# Install multiple packages with loop
- name: Install required packages
  apt:
    name: "{{ item }}"
    state: present
  loop:
    - nginx
    - python3
    - git
    - curl

# Loop with dictionaries
- name: Create application users
  user:
    name: "{{ item.name }}"
    groups: "{{ item.groups }}"
    shell: "{{ item.shell }}"
    state: present
  loop:
    - { name: "appuser", groups: "www-data", shell: "/bin/bash" }
    - { name: "dbadmin", groups: "postgres", shell: "/bin/sh" }

# Loop over files
- name: Copy configuration files
  copy:
    src: "{{ item }}"
    dest: /etc/myapp/
  loop: "{{ query('fileglob', 'files/configs/*.conf') }}"

Conditionals

# Run task only on Debian-family systems
- name: Install package (apt)
  apt:
    name: nginx
    state: present
  when: ansible_os_family == "Debian"

# Run task only on RHEL-family systems
- name: Install package (dnf)
  dnf:
    name: nginx
    state: present
  when: ansible_os_family == "RedHat"

# Combine conditions
- name: Install on Ubuntu 22.04 only
  apt:
    name: myapp
    state: present
  when:
    - ansible_distribution == "Ubuntu"
    - ansible_distribution_version == "22.04"

# Check registered variable
- name: Check if service is running
  shell: systemctl is-active nginx
  register: nginx_status
  ignore_errors: true

- name: Start nginx if not running
  service:
    name: nginx
    state: started
  when: nginx_status.rc != 0

Variables

Defining Variables

---
- name: Configure application
  hosts: webservers
  become: true

  # Inline vars
  vars:
    app_name: myapp
    app_port: 8080
    app_version: "1.2.0"
    app_config:
      log_level: info
      max_connections: 100

  # Load from external files
  vars_files:
    - vars/common.yml
    - vars/{{ ansible_distribution }}.yml

  tasks:
    - name: Show app name
      debug:
        msg: "Deploying {{ app_name }} version {{ app_version }}"

    - name: Use nested variable
      debug:
        msg: "Log level: {{ app_config.log_level }}"

Variable Precedence (low to high)

# 1. Role defaults       (roles/myrole/defaults/main.yml)
# 2. Inventory vars      (inventory/group_vars/, inventory/host_vars/)
# 3. Playbook vars       (vars: in playbook)
# 4. Task vars           (vars: in a task)
# 5. Extra vars          (ansible-playbook -e "key=value")  ← highest priority

# Run with extra variables
ansible-playbook site.yml -e "app_version=2.0.0 env=production"
ansible-playbook site.yml -e @overrides.yml

Group and Host Variables

# inventory/group_vars/webservers.yml
http_port: 80
max_clients: 200
nginx_worker_processes: auto

# inventory/group_vars/production.yml
env: production
log_level: warning
app_version: "1.5.0"

# inventory/host_vars/web1.example.com.yml
ansible_host: 192.168.1.21
http_port: 8080    # Override group var for this host only

Handlers

Handlers are tasks triggered only when notified by another task — and only run once, at the end of the play.

---
- name: Configure Nginx
  hosts: webservers
  become: true

  tasks:
    - name: Install Nginx
      apt:
        name: nginx
        state: present
      notify: Start Nginx

    - name: Deploy Nginx config
      template:
        src: nginx.conf.j2
        dest: /etc/nginx/nginx.conf
      notify:
        - Validate Nginx config
        - Reload Nginx

    - name: Deploy SSL certificate
      copy:
        src: files/cert.pem
        dest: /etc/ssl/certs/myapp.pem
      notify: Reload Nginx

  handlers:
    - name: Start Nginx
      service:
        name: nginx
        state: started
        enabled: true

    - name: Validate Nginx config
      command: nginx -t
      changed_when: false

    - name: Reload Nginx
      service:
        name: nginx
        state: reloaded

Templates (Jinja2)

Template File Example

# templates/nginx.conf.j2

worker_processes {{ nginx_worker_processes | default('auto') }};

events {
    worker_connections {{ nginx_worker_connections | default(1024) }};
}

http {
    server {
        listen {{ http_port }};
        server_name {{ ansible_hostname }};

        {% if ssl_enabled | default(false) %}
        listen 443 ssl;
        ssl_certificate /etc/ssl/certs/{{ app_name }}.pem;
        ssl_certificate_key /etc/ssl/private/{{ app_name }}.key;
        {% endif %}

        location / {
            proxy_pass http://127.0.0.1:{{ app_port }};
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }

        {% for path in static_paths | default([]) %}
        location {{ path.url }} {
            alias {{ path.dir }};
        }
        {% endfor %}
    }
}

Using Templates in Tasks

- name: Deploy Nginx config from template
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    owner: root
    group: root
    mode: '0644'
    validate: nginx -t -c %s    # Validate before deploying
  notify: Reload Nginx

Error Handling

tasks:
  # Ignore a task's failure and continue
  - name: Try to stop old service
    service:
      name: old-service
      state: stopped
    ignore_errors: true

  # Force task to report 'changed' only on specific condition
  - name: Run init script
    command: /opt/myapp/init.sh
    register: init_result
    changed_when: "'initialized' in init_result.stdout"
    failed_when: init_result.rc != 0 and 'already exists' not in init_result.stderr

  # Block / Rescue / Always (try-catch-finally)
  - name: Deploy application with rollback
    block:
      - name: Stop application
        service:
          name: myapp
          state: stopped

      - name: Deploy new version
        unarchive:
          src: "myapp-{{ app_version }}.tar.gz"
          dest: /opt/myapp/
          remote_src: false

      - name: Start application
        service:
          name: myapp
          state: started

    rescue:
      - name: Rollback — restore previous version
        shell: cp -r /opt/myapp/backup/* /opt/myapp/

      - name: Rollback — start old version
        service:
          name: myapp
          state: started

    always:
      - name: Send deployment notification
        uri:
          url: "https://hooks.slack.com/services/xxx"
          method: POST
          body_format: json
          body:
            text: "Deployment of {{ app_version }} completed on {{ inventory_hostname }}"

Vault — Managing Secrets

# Encrypt a file
ansible-vault encrypt vars/secrets.yml

# Decrypt a file
ansible-vault decrypt vars/secrets.yml

# Edit an encrypted file
ansible-vault edit vars/secrets.yml

# Encrypt a single string value
ansible-vault encrypt_string 'my_secret_password' --name 'db_password'

# Create a new encrypted file
ansible-vault create vars/secrets.yml

# Run playbook with vault password
ansible-playbook site.yml --ask-vault-pass
ansible-playbook site.yml --vault-password-file ~/.vault_pass
# vars/secrets.yml (after encrypt)
$ANSIBLE_VAULT;1.1;AES256
66386439...

# Reference vault vars in playbook
vars_files:
  - vars/common.yml
  - vars/secrets.yml    # Ansible decrypts at runtime

tasks:
  - name: Configure database
    template:
      src: database.conf.j2
      dest: /etc/myapp/database.conf
    # Template uses {{ db_password }} from vault

Running Playbooks

# Run a playbook
ansible-playbook site.yml

# Dry run (check mode)
ansible-playbook site.yml --check

# Show diffs in changed files
ansible-playbook site.yml --check --diff

# Limit to specific hosts or groups
ansible-playbook site.yml --limit webservers
ansible-playbook site.yml --limit web1.example.com

# Run only tasks with specific tags
ansible-playbook site.yml --tags "nginx,ssl"

# Skip tasks with specific tags
ansible-playbook site.yml --skip-tags "setup"

# Increase verbosity for debugging
ansible-playbook site.yml -v
ansible-playbook site.yml -vvv    # Very verbose

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

# Step through tasks one by one
ansible-playbook site.yml --step
✅ Best Practice: Always run with --check --diff first in production to preview changes before applying them.
⚠️ Warning: Some modules (e.g. shell, command) may not support check mode and will be skipped. Use check_mode: false on tasks that must always run.

Real-World Example: Deploy a Web Application

---
# deploy-app.yml
- name: Deploy MyApp to production
  hosts: webservers
  become: true
  vars_files:
    - vars/common.yml
    - vars/secrets.yml

  pre_tasks:
    - name: Verify deployment target
      assert:
        that:
          - app_version is defined
          - env == "production"
        fail_msg: "app_version or env not set"

  tasks:
    - name: Create application directory
      file:
        path: /opt/myapp/releases/{{ app_version }}
        state: directory
        owner: ubuntu
        mode: '0755'

    - name: Upload application archive
      unarchive:
        src: "dist/myapp-{{ app_version }}.tar.gz"
        dest: /opt/myapp/releases/{{ app_version }}

    - name: Install Python dependencies
      pip:
        requirements: /opt/myapp/releases/{{ app_version }}/requirements.txt
        virtualenv: /opt/myapp/venv
        virtualenv_python: python3

    - name: Run database migrations
      command: /opt/myapp/venv/bin/python manage.py migrate --noinput
      args:
        chdir: /opt/myapp/releases/{{ app_version }}
      environment:
        DATABASE_URL: "{{ db_url }}"
      run_once: true         # Run only on first host

    - name: Update symlink to current release
      file:
        src: /opt/myapp/releases/{{ app_version }}
        dest: /opt/myapp/current
        state: link
      notify: Restart MyApp

    - name: Deploy Nginx configuration
      template:
        src: nginx-myapp.conf.j2
        dest: /etc/nginx/sites-available/myapp
      notify: Reload Nginx

    - name: Enable Nginx site
      file:
        src: /etc/nginx/sites-available/myapp
        dest: /etc/nginx/sites-enabled/myapp
        state: link
      notify: Reload Nginx

    - name: Keep only last 5 releases
      shell: |
        ls -dt /opt/myapp/releases/*/ | tail -n +6 | xargs rm -rf
      changed_when: false

  handlers:
    - name: Restart MyApp
      service:
        name: myapp
        state: restarted

    - name: Reload Nginx
      service:
        name: nginx
        state: reloaded

  post_tasks:
    - name: Verify application is responding
      uri:
        url: "http://localhost:{{ app_port }}/health"
        status_code: 200
      retries: 5
      delay: 5

Next Steps