AnsibleXLI · Service and Application DeploymentService and Application Deployment
What deploy means to a convergent tool
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
“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:
| Component | Converged by | The question it answers |
|---|---|---|
| Package or artefact | apt, dnf, get_url, unarchive | which version of the code is on disk |
| Configuration | template, copy | what the code is told to do |
| Service | systemd_service | whether it is running and enabled |
| Validation | uri, assert, command | whether 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.
- 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: falseRun 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.
# Not rerun-safe: appends unconditionally.
- name: Add the application to the startup script
ansible.builtin.shell: |
echo "/opt/myapp/bin/start" >> /etc/rc.local- name: Manage the application startup entry
ansible.builtin.blockinfile:
path: /etc/rc.local
marker: '# {mark} ANSIBLE MANAGED: myapp'
block: |
/opt/myapp/bin/start
create: falseThe 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.
- 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
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?
Q2. Why does ansible.builtin.command return changed: true on every successful execution?
Q3. Which of these correctly describe the difference between creates and changed_when on a command task? Select all that apply.
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.