Ansible Lesson 30 – Application Deployment | Dataplexa
Section III · Lesson 30

Application Deployment

In this lesson

Deployment strategies Symlink release management Rolling deployments Blue-green deployments Automated rollback

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.

Strategy 1

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.

Strategy 2

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.

Strategy 3

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.

Strategy 4

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.

/var/www/app/ ├── current releases/20241201_143022 ← ACTIVE RELEASE ├── releases/ │ ├── 20241201_143022/ ← current (newest) │ ├── 20241130_091500/ ← previous (kept for rollback) │ └── 20241129_162340/ ← older (pruned after N releases) └── shared/ ├── config/ └── uploads/ ← persisted across releases

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.

Load Balancer traffic router LIVE deploy here Blue (v2.3 — LIVE) web01, web02, web03 serving 100% of traffic Green (v2.4 — STAGING) web04, web05, web06 deploying new version here
---
# 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

The symlink release pattern makes rollback instant and safe — each release lives in its own timestamped directory; switching current to a previous directory reverts the application in a single atomic operation.
Always health check after restart before declaring success — use 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.
Use 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.
Design migrations to be backwards-compatible — make schema changes additive only in the same deployment as the code change. Destructive changes belong in a follow-up migration after the new version is confirmed stable.

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.