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
| Issue | Solution |
|---|---|
| Handler runs too early/late | Handlers flush between sections; check which section triggers it |
| pre_task fails, roles still run | Use any_errors_fatal: true to stop on pre_task failure |
| post_tasks skipped on failure | Use --force-handlers or block/rescue for critical post_tasks |
| Order confusion | Remember: pre_tasks → roles → tasks → post_tasks |
Best Practices
- Load balancer out/in —
pre_tasksto remove,post_tasksto add back - Pre-flight checks in
pre_tasks— fail early before roles run - Verification in
post_tasks— confirm everything works after deployment - Notifications in
post_tasks— Slack, email, monitoring after completion - Use
run_oncein post_tasks — notifications should fire once, not per host - Handler flushing is automatic — no need for
meta: flush_handlersbetween 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.