Ansible Course
Application Deployment
In this lesson
Application deployment is the set of Ansible patterns that take a new version of your application from a build artifact to a running service on your servers — safely, repeatably, and with a clear path back if something goes wrong. Deployment is where the stakes are highest: it is the step closest to users and the step most likely to cause an outage if executed incorrectly. Ansible is well suited to deployment automation because it can orchestrate multi-step workflows across multiple servers in a defined order, enforce health checks between steps, and execute rollback logic automatically when anything fails. This lesson covers the four deployment patterns you will encounter in production and builds a complete, production-grade deployment playbook for a Python web application.
Deployment Strategies
Four deployment strategies cover the full spectrum from simplest to most sophisticated. The right choice depends on how much downtime is acceptable, how large your fleet is, and how quickly you need to roll back a bad release.
In-place deployment
Deploy the new version directly to the running server. Brief downtime during the restart. Simplest to implement but slowest to roll back — requires re-deploying the previous version. Acceptable for non-critical services or maintenance windows.
Symlink release management
Each version
is deployed to a timestamped directory. A current symlink points
to the active release. Rollback is a symlink swap — instant and safe. The
Capistrano pattern, adapted for Ansible. The most widely used approach.
Rolling deployment
Update one
server at a time (or a percentage at a time). The rest stay live throughout.
Zero downtime for the fleet as a whole. Requires a load balancer to distribute
traffic away from servers being updated. Uses Ansible's serial.
Blue-green deployment
Two identical environments — blue (live) and green (staging). Deploy to green, test, then switch the load balancer from blue to green. Instant rollback by switching back. Most expensive in infrastructure but the safest possible strategy.
Strategy 2 — Symlink Release Management
The symlink release pattern is the foundation of most Ansible deployment playbooks. It gives you instant rollback, keeps the last N releases available on disk, and makes the deployment atomic — the switch from old to new happens in a single symlink operation.
The TV Channel Switcher Analogy
The symlink pattern is like a TV channel
switcher. Multiple channels (release directories) exist simultaneously — each fully
loaded and ready to broadcast. The current symlink is the selector
knob. Switching to a new channel (new release) takes a fraction of a second.
If the new channel has static (broken release), switching back to the previous
channel is equally instant. The content of each channel was prepared in advance —
the switch itself causes zero preparation time.
Complete symlink release deployment playbook
---
# deploy.yml — symlink release management pattern
# Usage: ansible-playbook deploy.yml -e "version=20241201_143022"
- name: Deploy application release
hosts: appservers
become: true
vars:
deploy_dir: /var/www/app
release_dir: "{{ deploy_dir }}/releases/{{ version }}"
shared_dir: "{{ deploy_dir }}/shared"
current_link: "{{ deploy_dir }}/current"
keep_releases: 5 # prune everything older than the last 5 releases
tasks:
# Phase 1 — prepare the release directory
- name: Create release directory
ansible.builtin.file:
path: "{{ release_dir }}"
state: directory
owner: "{{ app_user }}"
mode: "0755"
- name: Extract application archive to release directory
ansible.builtin.unarchive:
src: "{{ release_archive }}" # e.g. dist/app-{{ version }}.tar.gz
dest: "{{ release_dir }}"
owner: "{{ app_user }}"
remote_src: false
# Phase 2 — link shared files (config, uploads, logs)
- name: Link shared config directory into release
ansible.builtin.file:
src: "{{ shared_dir }}/config"
dest: "{{ release_dir }}/config"
state: link
force: true
- name: Link shared uploads directory into release
ansible.builtin.file:
src: "{{ shared_dir }}/uploads"
dest: "{{ release_dir }}/public/uploads"
state: link
force: true
# Phase 3 — run pre-release tasks (migrations, asset compilation)
- name: Run database migrations
ansible.builtin.command:
cmd: python manage.py migrate --no-input
chdir: "{{ release_dir }}"
become_user: "{{ app_user }}"
register: migration_result
changed_when: "'No migrations to apply' not in migration_result.stdout"
- name: Collect static assets
ansible.builtin.command:
cmd: python manage.py collectstatic --no-input
chdir: "{{ release_dir }}"
become_user: "{{ app_user }}"
changed_when: true
# Phase 4 — atomic cutover
- name: Update current symlink to new release
ansible.builtin.file:
src: "{{ release_dir }}"
dest: "{{ current_link }}"
state: link
force: true
notify: Restart application
# Phase 5 — force handler to run now, then health check
- name: Flush handlers to restart service before health check
ansible.builtin.meta: flush_handlers
- name: Wait for application to become healthy
ansible.builtin.uri:
url: "http://{{ ansible_default_ipv4.address }}:{{ app_port }}/health"
status_code: 200
register: health
retries: 10
delay: 6
until: health.status == 200
# Phase 6 — clean up old releases
- name: List all release directories
ansible.builtin.find:
paths: "{{ deploy_dir }}/releases"
file_type: directory
recurse: false
register: releases_found
- name: Remove releases beyond the keep_releases limit
ansible.builtin.file:
path: "{{ item.path }}"
state: absent
loop: >-
{{ releases_found.files
| sort(attribute='mtime')
| list
| reverse
| list
| skip(keep_releases | int) }}
loop_control:
label: "{{ item.path | basename }}"
handlers:
- name: Restart application
ansible.builtin.service:
name: "{{ app_service }}"
state: restarted
PLAY [Deploy application release] ********************************************* TASK [Create release directory] *********************************************** changed: [appserver01] TASK [Extract application archive] ******************************************** changed: [appserver01] TASK [Link shared config directory] ******************************************* changed: [appserver01] TASK [Run database migrations] ************************************************ changed: [appserver01] <-- 3 new migrations applied TASK [Collect static assets] ************************************************** changed: [appserver01] TASK [Update current symlink to new release] ********************************** changed: [appserver01] <-- ATOMIC CUTOVER RUNNING HANDLERS [Deploy application release] ********************************* changed: [appserver01] <-- application restarted TASK [Wait for application to become healthy] ********************************* ok: [appserver01] <-- healthy on first attempt TASK [Remove releases beyond keep_releases limit] ***************************** changed: [appserver01] <-- pruned 20241128_103200 PLAY RECAP ******************************************************************** appserver01 : ok=9 changed=8 unreachable=0 failed=0 skipped=0
What just happened?
The deployment followed six phases: directory
creation → archive extraction → shared file linking → database migrations →
atomic symlink cutover → health check → old release pruning. The cutover was
a single file: state: link task — the moment it completed, all new
requests were served from the new release. The health check confirmed the application
was running correctly before the playbook completed. If the health check had failed,
the handler would have left the service in a failed state triggering the rollback
pattern shown next.
Automated Rollback
The symlink pattern makes rollback trivial
— point current at the previous release directory and restart the service.
Combined with block/rescue from Lesson 21, rollback can be triggered
automatically whenever any deployment step fails.
tasks:
- name: Deploy with automatic rollback on failure
block:
- name: Extract application archive
ansible.builtin.unarchive:
src: "{{ release_archive }}"
dest: "{{ release_dir }}"
- name: Run database migrations
ansible.builtin.command:
cmd: python manage.py migrate --no-input
chdir: "{{ release_dir }}"
- name: Update current symlink
ansible.builtin.file:
src: "{{ release_dir }}"
dest: "{{ current_link }}"
state: link
force: true
notify: Restart application
- name: Flush handlers and verify health
ansible.builtin.meta: flush_handlers
- name: Health check — new release must respond
ansible.builtin.uri:
url: "http://{{ ansible_default_ipv4.address }}:{{ app_port }}/health"
status_code: 200
retries: 5
delay: 6
until: health.status == 200
register: health
rescue:
# Any task above failing triggers this section
- name: Log rollback trigger
ansible.builtin.debug:
msg: "Deployment of {{ version }} failed — rolling back to previous release"
- name: Find previous release
ansible.builtin.find:
paths: "{{ deploy_dir }}/releases"
file_type: directory
recurse: false
register: all_releases
- name: Restore previous release symlink
ansible.builtin.file:
src: >-
{{ (all_releases.files
| sort(attribute='mtime')
| list)[-2].path }}
dest: "{{ current_link }}"
state: link
force: true
- name: Restart application on previous release
ansible.builtin.service:
name: "{{ app_service }}"
state: restarted
- name: Fail the play after rollback (surface the error clearly)
ansible.builtin.fail:
msg: "Deployment FAILED and ROLLED BACK. Check migration logs."
always:
- name: Record deployment result
ansible.builtin.lineinfile:
path: /var/log/deploy_history.log
line: >-
{{ ansible_date_time.iso8601 }} | {{ version }} |
{{ 'SUCCESS' if ansible_failed_task is not defined else 'ROLLED BACK' }}
create: true
Strategy 3 — Rolling Deployment
Rolling deployments combine the symlink
pattern with Ansible's serial attribute to update one server at a time.
The load balancer continues routing traffic to healthy servers while each individual
server is updated and verified.
---
- name: Rolling application deployment
hosts: appservers
become: true
serial: 1 # one server at a time — adjust to "25%" for larger fleets
max_fail_percentage: 0 # abort the entire rolling update if any server fails
tasks:
- name: Remove server from load balancer before deployment
community.general.haproxy:
state: disabled
host: "{{ inventory_hostname }}"
socket: /var/run/haproxy/admin.sock
wait: true
wait_interval: 5
- name: Deploy new release (symlink pattern)
ansible.builtin.include_tasks: tasks/deploy_release.yml
# Contains the full symlink deployment sequence from above
- name: Run smoke test against this server directly
ansible.builtin.uri:
url: "http://{{ ansible_default_ipv4.address }}:{{ app_port }}/health"
status_code: 200
retries: 5
delay: 6
until: smoke.status == 200
register: smoke
- name: Re-add server to load balancer after successful deployment
community.general.haproxy:
state: enabled
host: "{{ inventory_hostname }}"
socket: /var/run/haproxy/admin.sock
wait: true
Strategy 4 — Blue-Green Deployment
Blue-green deployment runs two identical environments simultaneously. Traffic goes to blue (current live). Deploy to green, run full acceptance tests, then switch the load balancer. If green has problems, switching back to blue takes seconds. The only downside is the infrastructure cost of running two environments.
---
# blue_green_deploy.yml
# Phase 1 — deploy new version to green (inactive) environment
- name: Deploy to green environment
hosts: green_servers
become: true
vars:
version: "{{ new_version }}"
tasks:
- ansible.builtin.include_tasks: tasks/deploy_release.yml
- name: Run full acceptance test suite against green
ansible.builtin.uri:
url: "http://{{ ansible_default_ipv4.address }}:{{ app_port }}/health"
status_code: 200
# Phase 2 — switch load balancer from blue to green
- name: Cut over load balancer to green
hosts: load_balancers
become: true
tasks:
- name: Switch backend pool from blue to green
ansible.builtin.template:
src: haproxy.cfg.j2
dest: /etc/haproxy/haproxy.cfg
validate: haproxy -c -f %s
vars:
active_backend: green_servers
notify: Reload HAProxy
- name: Flush handlers to apply cutover immediately
ansible.builtin.meta: flush_handlers
- name: Verify green is now receiving traffic
ansible.builtin.uri:
url: "http://{{ lb_vip }}/health"
status_code: 200
handlers:
- name: Reload HAProxy
ansible.builtin.service:
name: haproxy
state: reloaded
# Rollback: switch back to blue (run with -e "rollback=true")
- name: Rollback to blue if needed
hosts: load_balancers
become: true
when: rollback | default(false) | bool
tasks:
- name: Switch backend pool back to blue
ansible.builtin.template:
src: haproxy.cfg.j2
dest: /etc/haproxy/haproxy.cfg
validate: haproxy -c -f %s
vars:
active_backend: blue_servers
notify: Reload HAProxy
Database Migrations Are the Hardest Part of Any Deployment
Application code is easy to roll back — point the symlink at the previous release and restart. Database schema changes are not. A migration that adds a column is reversible; a migration that drops a column or renames one is not — the previous application version will not work against the migrated schema. Always design migrations to be backwards-compatible with the previous release (additive only) and keep destructive schema changes in a separate, later migration that runs only after the new version has been live for at least one full deployment cycle.
Key Takeaways
current to a previous directory reverts the application in
a single atomic operation.
ansible.builtin.uri with retries and
until to poll the application's health endpoint rather than
assuming a successful service restart means the application is working.
meta: flush_handlers before health checks
— handlers run at the end of a play by default; flushing them immediately
after the symlink cutover ensures the service is restarted and ready before
the health check task runs.
block/rescue automates rollback — wrap the
entire deployment sequence in a block and put the rollback
logic in rescue. Any failure anywhere in the deployment
triggers automatic rollback without manual intervention.
Teacher's Note
Implement the symlink release
pattern for any application you currently deploy. Run two full deployments — the
second one with a deliberately broken health endpoint — and verify that the
rollback logic triggers automatically and the previous version is restored. Seeing
automatic rollback work in practice is the moment the block/rescue
pattern from Lesson 21 becomes indispensable rather than theoretical.
Practice Questions
1. After the symlink cutover task notifies a restart handler, which task must run immediately afterwards to ensure the service is restarted before the health check executes?
2. Which play-level attribute controls how many hosts are updated simultaneously during a rolling deployment?
3. In the symlink release pattern, where are files that must persist across releases — such as uploaded user files and application config — stored?
Quiz
1. In the symlink release pattern, what exactly happens during the cutover step?
2. A team requires zero-downtime deployments with the ability to revert in under 30 seconds if the new version has problems. They have budget for double infrastructure. Which strategy fits?
3. A new application version requires renaming a database column. The old code uses the old column name. What is the safe migration approach?
Up Next · Lesson 31
Server Hardening
Learn to automate security hardening across your fleet — SSH configuration, firewall rules, kernel hardening, user access controls, and CIS benchmark compliance using Ansible.