Ansible pre_tasks and post_tasks — Execute Before and After Roles

Introduction

Ansible plays execute sections in a fixed order: pre_tasks → roles → tasks → post_tasks. pre_tasks run before any roles — use them for load balancer removal, pre-flight checks, and setup. post_tasks run after everything else — use them for load balancer re-addition, notifications, and verification. Handlers are flushed between each section.

Execution Order

- name: Full play structure
  hosts: webservers
  
  pre_tasks:        # 1️⃣ Run first
    - name: Step 1
      ansible.builtin.debug:
        msg: "Pre-tasks execute first"
  # Handlers flush here
  
  roles:            # 2️⃣ Run second
    - nginx
    - myapp
  # Handlers flush here
  
  tasks:            # 3️⃣ Run third
    - name: Step 3
      ansible.builtin.debug:
        msg: "Tasks execute after roles"
  # Handlers flush here
  
  post_tasks:       # 4️⃣ Run last
    - name: Step 4
      ansible.builtin.debug:
        msg: "Post-tasks execute last"
  # Handlers flush here

The Classic Pattern: Load Balancer Management

- name: Zero-downtime deployment
  hosts: webservers
  serial: 2

  pre_tasks:
    - name: Remove from load balancer
      ansible.builtin.uri:
        url: "https://lb.example.com/api/pool/web/members/{{ inventory_hostname }}"
        method: DELETE
        headers:
          Authorization: "Bearer {{ lb_token }}"
      delegate_to: localhost
      no_log: true

    - name: Wait for connections to drain
      ansible.builtin.pause:
        seconds: 30

  roles:
    - common
    - nginx
    - myapp

  tasks:
    - name: Verify application health
      ansible.builtin.uri:
        url: "http://localhost:8080/health"
        status_code: 200
      register: health
      until: health.status == 200
      retries: 12
      delay: 5

  post_tasks:
    - name: Add back to load balancer
      ansible.builtin.uri:
        url: "https://lb.example.com/api/pool/web/members"
        method: POST
        body_format: json
        body:
          address: "{{ ansible_host }}"
          port: 8080
        headers:
          Authorization: "Bearer {{ lb_token }}"
      delegate_to: localhost
      no_log: true

    - name: Verify LB health check passes
      ansible.builtin.uri:
        url: "https://lb.example.com/api/pool/web/members/{{ inventory_hostname }}/health"
      delegate_to: localhost
      register: lb_health
      until: lb_health.json.status == 'up'
      retries: 12
      delay: 5

Pre-Flight Checks

  pre_tasks:
    - name: Check disk space
      ansible.builtin.command:
        cmd: df --output=avail / | tail -1
      register: disk
      changed_when: false

    - name: Fail if disk space too low
      ansible.builtin.fail:
        msg: "Only {{ (disk.stdout | int / 1024) | int }}MB free. Need 2048MB."
      when: (disk.stdout | int / 1024) < 2048

    - name: Verify connectivity to database
      ansible.builtin.wait_for:
        host: "{{ db_host }}"
        port: 5432
        timeout: 10

    - name: Check current version
      ansible.builtin.command:
        cmd: cat /opt/app/VERSION
      register: current_version
      changed_when: false
      failed_when: false

    - name: Log deployment start
      ansible.builtin.lineinfile:
        path: /var/log/deployments.log
        line: "{{ ansible_date_time.iso8601 }} START deploy {{ app_version }} (was {{ current_version.stdout | default('none') }})"
        create: true

Post-Deployment Actions

  post_tasks:
    - name: Log deployment complete
      ansible.builtin.lineinfile:
        path: /var/log/deployments.log
        line: "{{ ansible_date_time.iso8601 }} COMPLETE deploy {{ app_version }} on {{ inventory_hostname }}"

    - name: Send Slack notification
      ansible.builtin.uri:
        url: "{{ slack_webhook }}"
        method: POST
        body_format: json
        body:
          text: "✅ {{ inventory_hostname }} deployed v{{ app_version }}"
      delegate_to: localhost
      run_once: true

    - name: Update monitoring
      ansible.builtin.uri:
        url: "https://monitoring.example.com/api/deploy"
        method: POST
        body_format: json
        body:
          service: myapp
          version: "{{ app_version }}"
          host: "{{ inventory_hostname }}"
      delegate_to: localhost

    - name: Clear CDN cache
      ansible.builtin.uri:
        url: "https://cdn.example.com/api/purge"
        method: POST
      delegate_to: localhost
      run_once: true

Handler Flushing Between Sections

  pre_tasks:
    - name: Update package cache
      ansible.builtin.apt:
        update_cache: true
      notify: Log cache update
      # Handler "Log cache update" runs HERE (after pre_tasks, before roles)

  roles:
    - role: nginx
      # Handlers from nginx role run HERE (after roles, before tasks)

  tasks:
    - name: Deploy config
      ansible.builtin.template:
        src: app.conf.j2
        dest: /etc/myapp/app.conf
      notify: Restart myapp
      # Handler "Restart myapp" runs HERE (after tasks, before post_tasks)

  post_tasks:
    - name: Verify everything is running
      ansible.builtin.uri:
        url: "http://localhost:8080/health"
      # By this point, ALL handlers have run — safe to verify

Database Migration Pattern

  pre_tasks:
    - name: Backup database
      ansible.builtin.command:
        cmd: pg_dump myapp > /backups/pre-deploy-{{ ansible_date_time.epoch }}.sql
      run_once: true
      delegate_to: "{{ groups['database'][0] }}"

  roles:
    - myapp

  tasks:
    - name: Run database migration
      ansible.builtin.command:
        cmd: /opt/myapp/bin/migrate
      run_once: true

  post_tasks:
    - name: Verify migration
      ansible.builtin.command:
        cmd: /opt/myapp/bin/migrate --check
      run_once: true
      changed_when: false

Troubleshooting

IssueSolution
Handler runs too early/lateHandlers flush between sections; check which section triggers it
pre_task fails, roles still runUse any_errors_fatal: true to stop on pre_task failure
post_tasks skipped on failureUse --force-handlers or block/rescue for critical post_tasks
Order confusionRemember: pre_tasks → roles → tasks → post_tasks

Best Practices

  1. Load balancer out/in — pre_tasks to remove, post_tasks to add back
  2. Pre-flight checks in pre_tasks — fail early before roles run
  3. Verification in post_tasks — confirm everything works after deployment
  4. Notifications in post_tasks — Slack, email, monitoring after completion
  5. Use run_once in post_tasks — notifications should fire once, not per host
  6. Handler flushing is automatic — no need for meta: flush_handlers between sections

Conclusion

pre_tasks and post_tasks frame your role execution with preparation and cleanup. The classic pattern — remove from load balancer, deploy, add back — is the foundation of zero-downtime deployments. Combined with automatic handler flushing between sections, you get predictable execution order: checks → deploy → verify → notify.