Skip to main content
RunBook Academy

AnsibleXLI · Service and Application DeploymentService and Application Deployment

Deploying a version, not latest

Intermediate⏱ ~24 minansible-playbook

What you'll learn

  • Explain how state: latest makes two hosts built a week apart run different code
  • Write version-pinned installs for apt and dnf using each manager version syntax
  • Fetch an artifact idempotently with get_url and a checksum
  • Record the deployed version as evidence rather than inferring it from the run log

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

state: latest in a deployment role means “whatever the repository has right now”. It is a statement about a moment, and every host that runs the role at a different moment gets a different answer.

That is fine for a fleet that is deployed once. Real fleets are deployed continuously — a host replaced on Tuesday, three added by autoscaling on Thursday, one rebuilt after a disk failure in March — and the role that “converges” them produces a version distribution rather than a version.

What latest actually produces

Read-only / Safea fleet converged by a role that pins nothing
$ ansible appservers -i inventories/production -m package_facts -a 'manager=auto'
web01.example.com : myapp 4.2.1     # built in March
web02.example.com : myapp 4.2.1
web03.example.com : myapp 4.3.0     # replaced in May
web04.example.com : myapp 4.3.0
web05.example.com : myapp 4.4.2     # autoscaled in July
web06.example.com : myapp 4.4.2
web07.example.com : myapp 4.4.3     # rebuilt after a disk failure last week

Nothing failed. Every run of the role reported success. Each host is running “the latest version” as of the day it was built, and the fleet is running four different versions of the application behind one load balancer.

The consequences are not evenly distributed:

Debugging becomes unreliable. “It works on web01 and not on web07” now has a boring explanation that nobody checks first, because the repository says one version.

A rollback has no target. Rolling back to “the previous version” means seven different previous versions.

Reproduction is impossible. A bug reported against production cannot be reproduced in staging, because staging was built last Tuesday and production is a museum.

Version syntax, per manager

The version specifier is a package-manager property, and it differs.

ManagerExact versionRange
aptmyapp=4.4.3-1ubuntu1myapp>=4.4.0
dnfmyapp-4.4.3-1.el9myapp >= 4.4.0 (spaces required)

The dnf documentation is explicit about the spacing: comparison operators are valid in name, and “Spaces around the operator are required”. The apt documentation is equally explicit in the other direction: “Do not use single or double quotes around the version when referring to the package name with a specific version”.

Configuration changea version pinned per family, from one variable
# group_vars/appservers.yml
myapp_version: '4.4.3'
myapp_release_debian: '4.4.3-1ubuntu1'
myapp_release_redhat: '4.4.3-1.el9'

# roles/myapp/tasks/install.yml
- name: Install the pinned application version (Debian family)
ansible.builtin.apt:
  name: 'myapp={{ myapp_release_debian }}'
  state: present
when: ansible_facts.os_family == 'Debian'
notify: Restart myapp

- name: Install the pinned application version (RHEL family)
ansible.builtin.dnf:
  name: 'myapp-{{ myapp_release_redhat }}'
  state: present
when: ansible_facts.os_family == 'RedHat'
notify: Restart myapp

state: present with an exact version is the correct combination. state: latest with an exact version is contradictory and the manager resolves it in ways that vary; do not write it.

The upgrade path is then a one-line change to group_vars, which is a diff in a pull request with an author, a reviewer and a date. That is the whole point: an upgrade should be a reviewable change to the repository, not an emergent property of when a play happened to run.

Artifacts: get_url with a checksum

For applications shipped as a tarball or a binary rather than a package, get_url plus a checksum gives you the same guarantee, and the checksum is what makes it converged.

The checksum parameter format is documented as `<algorithm>:<checksum|url>` — either a literal digest or a URL to a checksum file:

Configuration changean artifact fetch that is both pinned and idempotent
- name: Fetch the release artifact
ansible.builtin.get_url:
  url: 'https://artifacts.example.com/myapp/{{ myapp_version }}/myapp-{{ myapp_version }}.tar.gz'
  dest: '/opt/myapp/releases/myapp-{{ myapp_version }}.tar.gz'
  checksum: 'sha256:{{ myapp_sha256 }}'
  owner: root
  group: root
  mode: '0644'
register: artifact

- name: Unpack the release, once
ansible.builtin.unarchive:
  src: '/opt/myapp/releases/myapp-{{ myapp_version }}.tar.gz'
  dest: '/opt/myapp/releases/'
  remote_src: true
  creates: '/opt/myapp/releases/myapp-{{ myapp_version }}/bin/myapp'
notify: Restart myapp

The checksum parameter is doing two jobs at once, and the second is the one people miss. From the module documentation: if a checksum is supplied and the file already exists at dest, the destination’s checksum is computed, and if they match the download is skipped; if they do not match the destination is replaced.

So checksum converts get_url from “download if absent” into “converge on this content”. Without it, force: false — the default — only checks whether the path exists, so a truncated or corrupted previous download is never noticed and never repaired.

unarchive needs creates for the same reason: without it the module re-extracts on every run, which is slow, produces a permanently changed task, and notifies the restart handler on every play.

Recording what was deployed

The run log records what the play tried to do. It is transient, it lives on whoever ran the play, and it is not available to the person debugging at 03:00 six weeks later.

Configuration changeleaving the evidence on the host
- name: Record what this host is running
ansible.builtin.copy:
  dest: /etc/myapp/deployed-version
  owner: root
  group: root
  mode: '0644'
  content: |
    version={{ myapp_version }}
    artifact_sha256={{ myapp_sha256 }}
    deployed_by=ansible
    source_ref={{ deploy_source_ref | default('unknown') }}

Deliberately not in that file: a timestamp. A {{ ansible_date_time }} value changes on every run, so the task reports changed every time, notifies handlers it should not, and destroys the role’s idempotence to record something the file’s own mtime already carries.

The best version evidence is the one the application reports about itself — a /healthz endpoint returning its version — because it describes the running process rather than what was installed on disk. The health gate from the previous lesson asserts on exactly that, which is what makes it a deployment check rather than a liveness check.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A deployment role uses state: latest. Hosts have been built and replaced over eight months. What does the fleet look like?

  2. Q2. What does supplying a checksum to get_url change about the module behaviour when the destination file already exists?

  3. Q3. Which of these are genuine problems with a version-recording task that writes a file containing ansible_date_time? Select all that apply.

  4. Q4. Pinning an exact version makes a rollback safe, because the previous version can always be reinstalled from the repository.

Passing score: 75%. Answers are checked in this browser.