Ansible Playbooks: Task Automation

Ansible playbooks are the foundation of infrastructure automation, turning repetitive manual tasks into reproducible, version-controlled configurations. Unlike ad-hoc commands that execute single...

Key Insights

  • Ansible playbooks transform infrastructure management from imperative scripts to declarative, idempotent configurations that describe desired state rather than sequential commands
  • Proper playbook organization using roles, variables, and templates creates reusable automation that scales from single servers to thousands of nodes without code duplication
  • Handlers, conditionals, and error handling blocks turn basic task lists into production-grade automation that responds intelligently to system state and failures

Ansible playbooks are the foundation of infrastructure automation, turning repetitive manual tasks into reproducible, version-controlled configurations. Unlike ad-hoc commands that execute single modules, playbooks orchestrate complex multi-step workflows across your infrastructure. They’re written in YAML, making them human-readable while remaining powerful enough to manage enterprise-scale deployments.

Introduction to Ansible Playbooks

Playbooks describe your infrastructure’s desired state. Ansible reads these declarations and makes whatever changes necessary to achieve that state, skipping actions that are already complete. This idempotency means running the same playbook multiple times produces identical results without breaking your systems.

Here’s a minimal playbook that demonstrates the basic structure:

---
- name: My first playbook
  hosts: webservers
  become: yes
  
  tasks:
    - name: Ensure a file exists
      ansible.builtin.file:
        path: /tmp/hello.txt
        state: touch
        mode: '0644'
    
    - name: Display a message
      ansible.builtin.debug:
        msg: "Playbook executed successfully"

This playbook targets hosts in the webservers group, escalates privileges with become, and executes two tasks. Each task uses a module (file and debug) with specific parameters.

Playbook Anatomy and Core Components

A playbook contains one or more “plays,” each targeting specific hosts and defining tasks to execute. Understanding the execution flow and component interaction is crucial for building reliable automation.

---
- name: Complete component demonstration
  hosts: appservers
  become: yes
  vars:
    app_port: 8080
    app_user: apprunner
  
  tasks:
    - name: Install application dependencies
      ansible.builtin.package:
        name:
          - python3
          - python3-pip
        state: present
    
    - name: Create application user
      ansible.builtin.user:
        name: "{{ app_user }}"
        system: yes
        create_home: no
    
    - name: Deploy application configuration
      ansible.builtin.template:
        src: app.conf.j2
        dest: /etc/app/app.conf
        owner: "{{ app_user }}"
        mode: '0640'
      notify: Restart application service
  
  handlers:
    - name: Restart application service
      ansible.builtin.systemd:
        name: myapp
        state: restarted

Tasks execute sequentially. The vars section defines playbook-level variables accessible to all tasks. Handlers are special tasks that only run when notified by other tasks, typically used for service restarts. This playbook only restarts the service if the configuration file actually changes.

Building a Practical Web Server Deployment

Let’s build a complete playbook that deploys a production-ready Nginx web server. This demonstrates how multiple tasks combine to achieve a complex outcome.

---
- name: Deploy Nginx web server
  hosts: webservers
  become: yes
  vars:
    nginx_port: 80
    document_root: /var/www/html
    site_name: example.com
  
  tasks:
    - name: Install Nginx
      ansible.builtin.package:
        name: nginx
        state: present
    
    - name: Configure firewall for HTTP
      ansible.posix.firewalld:
        service: http
        permanent: yes
        state: enabled
        immediate: yes
      when: ansible_os_family == "RedHat"
    
    - name: Create document root
      ansible.builtin.file:
        path: "{{ document_root }}"
        state: directory
        owner: nginx
        group: nginx
        mode: '0755'
    
    - name: Deploy website content
      ansible.builtin.copy:
        src: files/index.html
        dest: "{{ document_root }}/index.html"
        owner: nginx
        group: nginx
        mode: '0644'
    
    - name: Deploy Nginx configuration
      ansible.builtin.template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/nginx.conf
        validate: 'nginx -t -c %s'
      notify: Reload Nginx
    
    - name: Ensure Nginx is running
      ansible.builtin.systemd:
        name: nginx
        state: started
        enabled: yes
  
  handlers:
    - name: Reload Nginx
      ansible.builtin.systemd:
        name: nginx
        state: reloaded

This playbook installs Nginx, configures the firewall, creates directory structures, deploys content and configuration, then ensures the service runs. The validate parameter in the template task prevents deploying broken configurations.

Variables, Templates, and Dynamic Configuration

Variables make playbooks reusable across different environments. Combine them with Jinja2 templates to generate configuration files dynamically.

Create a directory structure:

inventory/
  group_vars/
    webservers.yml
  host_vars/
    web01.yml
templates/
  nginx.conf.j2

In group_vars/webservers.yml:

nginx_worker_processes: auto
nginx_worker_connections: 1024
nginx_keepalive_timeout: 65

In host_vars/web01.yml:

server_name: web01.example.com
nginx_worker_processes: 4

The template templates/nginx.conf.j2:

user nginx;
worker_processes {{ nginx_worker_processes }};
error_log /var/log/nginx/error.log;

events {
    worker_connections {{ nginx_worker_connections }};
}

http {
    keepalive_timeout {{ nginx_keepalive_timeout }};
    
    server {
        listen 80;
        server_name {{ server_name }};
        root {{ document_root }};
        
        location / {
            index index.html;
        }
    }
}

Variables follow a precedence hierarchy: host_vars override group_vars, which override playbook vars. This allows environment-specific customization without duplicating playbooks.

Handlers, Conditionals, and Loops

Real-world automation requires responding to different conditions and processing multiple items efficiently.

---
- name: Advanced control structures
  hosts: all
  become: yes
  vars:
    packages_to_install:
      - git
      - curl
      - vim
      - htop
  
  tasks:
    - name: Install packages on Debian-based systems
      ansible.builtin.apt:
        name: "{{ packages_to_install }}"
        state: present
        update_cache: yes
      when: ansible_os_family == "Debian"
    
    - name: Install packages on RedHat-based systems
      ansible.builtin.yum:
        name: "{{ packages_to_install }}"
        state: present
      when: ansible_os_family == "RedHat"
    
    - name: Configure service files
      ansible.builtin.template:
        src: "{{ item.src }}"
        dest: "{{ item.dest }}"
      loop:
        - { src: 'app.service.j2', dest: '/etc/systemd/system/app.service' }
        - { src: 'app-worker.service.j2', dest: '/etc/systemd/system/app-worker.service' }
      notify: Reload systemd
    
    - name: Enable memory-intensive service only on large instances
      ansible.builtin.systemd:
        name: analytics
        enabled: yes
      when: ansible_memtotal_mb > 8000
  
  handlers:
    - name: Reload systemd
      ansible.builtin.systemd:
        daemon_reload: yes

Conditionals use the when clause to check facts or variables. Loops iterate over lists or dictionaries, executing the task once per item. Handlers trigger only once even if notified multiple times, preventing unnecessary service restarts.

Roles and Playbook Organization

As automation grows, roles provide structure and reusability. A role packages related tasks, handlers, templates, and variables into a portable unit.

Directory structure for a database role:

roles/
  postgresql/
    tasks/
      main.yml
    handlers/
      main.yml
    templates/
      postgresql.conf.j2
      pg_hba.conf.j2
    defaults/
      main.yml
    vars/
      main.yml
    files/
      init_script.sql

In roles/postgresql/tasks/main.yml:

---
- name: Install PostgreSQL
  ansible.builtin.package:
    name: postgresql-server
    state: present

- name: Initialize database
  ansible.builtin.command:
    cmd: postgresql-setup --initdb
    creates: /var/lib/pgsql/data/PG_VERSION

- name: Deploy configuration
  ansible.builtin.template:
    src: postgresql.conf.j2
    dest: /var/lib/pgsql/data/postgresql.conf
  notify: Restart PostgreSQL

In roles/postgresql/defaults/main.yml:

postgresql_max_connections: 100
postgresql_shared_buffers: 128MB

Use the role in a playbook:

---
- name: Setup database servers
  hosts: dbservers
  become: yes
  roles:
    - postgresql

Roles make automation modular. Share them via Ansible Galaxy or internal repositories.

Error Handling and Testing Strategies

Production playbooks need robust error handling and validation mechanisms.

---
- name: Deployment with error handling
  hosts: appservers
  become: yes
  
  tasks:
    - name: Risky deployment block
      block:
        - name: Stop application
          ansible.builtin.systemd:
            name: myapp
            state: stopped
        
        - name: Deploy new version
          ansible.builtin.copy:
            src: /tmp/app-v2.jar
            dest: /opt/app/app.jar
            backup: yes
        
        - name: Start application
          ansible.builtin.systemd:
            name: myapp
            state: started
      
      rescue:
        - name: Rollback on failure
          ansible.builtin.copy:
            src: /opt/app/app.jar.backup
            dest: /opt/app/app.jar
            remote_src: yes
        
        - name: Restart with old version
          ansible.builtin.systemd:
            name: myapp
            state: started
      
      always:
        - name: Log deployment attempt
          ansible.builtin.lineinfile:
            path: /var/log/deployments.log
            line: "Deployment attempted at {{ ansible_date_time.iso8601 }}"
            create: yes
    
    - name: Validate deployment
      ansible.builtin.uri:
        url: http://localhost:8080/health
        status_code: 200
      tags: ['validate']

Test playbooks with check mode before applying changes:

ansible-playbook deploy.yml --check --diff

Use tags for selective execution:

ansible-playbook deploy.yml --tags validate

The block, rescue, and always structure provides try-catch-finally semantics. Blocks execute tasks sequentially; if any fail, rescue tasks run. Always tasks execute regardless of success or failure, perfect for cleanup or logging.

Ansible playbooks transform infrastructure management from manual processes to reliable, repeatable automation. Start with simple playbooks, organize them into roles as complexity grows, and leverage handlers, conditionals, and error handling for production-grade deployments. The investment in well-structured playbooks pays dividends in reduced deployment time, fewer errors, and infrastructure that’s truly treated as code.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.