Skip to main content
RunBook Academy

AnsibleXLI · Service and Application DeploymentService and Application Deployment

What deploy means to a convergent tool

Intermediate⏱ ~23 minansible-playbook

What you'll learn

  • Define the deployable unit as a converged state rather than a sequence of steps
  • Explain why a non-rerun-safe deploy task compromises every later run of the role
  • Identify which parts of a deployment Ansible converges and which it merely performs
  • Structure a deployment role so a partial run can be resumed by running it again

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.

“Deploy” is a word borrowed from tools that do not work the way Ansible works, and the borrowing causes real problems.

To a deployment tool built around artefacts and releases, a deploy is an event: version 4.2 replaces version 4.1 at 14:07, atomically, with a before and an after. To Ansible, a deploy is a convergence: the host is brought to a described state, and if it is already in that state nothing happens.

Those are different enough that a role written with the first model in mind and executed by the second produces the characteristic failure of this part — a deployment that works the first time and is a gamble every time after.

The unit is a state, not a sequence

A deployable unit in this course is four things converged together:

ComponentConverged byThe question it answers
Package or artefactapt, dnf, get_url, unarchivewhich version of the code is on disk
Configurationtemplate, copywhat the code is told to do
Servicesystemd_servicewhether it is running and enabled
Validationuri, assert, commandwhether it is actually working

All four, in one role, converged in one run. Not a package role and a config role and a “deploy” playbook someone runs afterwards — because the moment they are separate, there is a window in which the host has the new code and the old configuration, and somebody will find that window.

Service impact possiblethe shape of a deployable unit
- name: Install the pinned application version
ansible.builtin.apt:
  name: 'myapp={{ myapp_version }}'
  state: present
notify: Restart myapp

- name: Write the application configuration
ansible.builtin.template:
  src: myapp.conf.j2
  dest: /etc/myapp/myapp.conf
  owner: myapp
  group: myapp
  mode: '0640'
  backup: true
  validate: '/usr/bin/myapp --check-config %s'
notify: Restart myapp

- name: Ensure the service is enabled and running
ansible.builtin.systemd_service:
  name: myapp
  state: started
  enabled: true

- name: Apply any pending restart before validating
ansible.builtin.meta: flush_handlers

- name: Prove the deployed version is the version serving
ansible.builtin.uri:
  url: 'http://{{ ansible_host | default(inventory_hostname) }}:8080/healthz'
  return_content: true
register: health
retries: 20
delay: 3
until: health.json.version | default('') == myapp_version
changed_when: false

Run that against a host already at myapp_version and every task reports ok, no handler fires, and the validation confirms what was already true. Run it against a host at the previous version and it converges. Run it against a host where the previous run died halfway and it finishes the job.

That last property is the one worth paying for.

Rerun safety is what makes a partial run recoverable

Consider a deployment role of twelve tasks where task seven is not rerun-safe — it appends to a file, or it runs a command that assumes the previous version is present.

The first run works. Nothing is wrong. The problem only appears the first time a run does not complete: a connection drops at task nine, a max_fail_percentage stops the play, somebody presses Ctrl-C.

Now the host is in a state where tasks one to eight have been applied. The obvious recovery — run it again — re-executes task seven, and task seven is the one that cannot survive that.

Service impact possiblethe task that quietly breaks rerun safety
# Not rerun-safe: appends unconditionally.
- name: Add the application to the startup script
ansible.builtin.shell: |
  echo "/opt/myapp/bin/start" >> /etc/rc.local
Configuration changethe same intent, converged
- name: Manage the application startup entry
ansible.builtin.blockinfile:
  path: /etc/rc.local
  marker: '# {mark} ANSIBLE MANAGED: myapp'
  block: |
    /opt/myapp/bin/start
  create: false

The second version is not merely tidier. It is the difference between a role you can rerun and a role you cannot.

What Ansible converges and what it merely performs

Being honest about this line is what stops the model becoming a superstition.

Converged — the module inspects the current state and acts only if it differs. apt, dnf, template, copy, file, systemd_service, user, lineinfile, blockinfile. These report changed truthfully and are safe to rerun by construction.

Performed — the module runs a thing. command, shell, script, raw. Ansible has no idea what they do, cannot tell whether they have already been done, and reports changed by default because it must assume the worst.

Ambiguous — modules whose idempotence depends on how you call them. get_url is converged when the destination exists and matches the checksum, and a fresh download otherwise. unarchive is converged only if you give it creates.

Configuration changemaking a performed step behave like a converged one
- name: Run the one-time data migration for this release
ansible.builtin.command:
  argv: [/opt/myapp/bin/migrate, '--to', '{{ myapp_version }}']
  creates: '/var/lib/myapp/.migrated-{{ myapp_version }}'

creates is the cheapest idempotence available for a command task: when the named path exists, the task is skipped entirely. The requirement is that the command itself creates that path — which means the sentinel must be written by the migration, not by a following file task, or a migration that fails halfway leaves a sentinel saying it succeeded.

Where the convergence model genuinely does not fit

Three parts of a deployment resist it, and pretending otherwise is worse than acknowledging it.

Schema migrations. Applying a migration twice is not idempotent in the way a file is; the second application either errors or does damage. Migration frameworks maintain their own state table for exactly this reason. Ansible’s job is to invoke the framework, not to reimplement it. This gets a whole lesson later in this part.

Anything with an external side effect. Sending a deployment notification, incrementing a release counter, posting to a chat channel. Rerunning duplicates it. These are usually harmless and should be recognised as performed rather than converged.

Traffic cut-over. Ansible can tell a load balancer to change a backend’s state, and that call is idempotent. What it cannot do is make the cut-over atomic across the fleet, and that limit is the subject of the last lesson in this part.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A deployment role has twelve tasks, eleven of which are idempotent. Task seven appends a line to a file unconditionally. What is the practical consequence?

  2. Q2. Why does ansible.builtin.command return changed: true on every successful execution?

  3. Q3. Which of these correctly describe the difference between creates and changed_when on a command task? Select all that apply.

  4. Q4. A deployment role is also a drift-remediation role, because every run asserts the entire described state rather than only what changed in this release.

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