AnsibleXLVIII · Maintenance Windows and RollbackMaintenance windows and rollback
Ansible has no undo
What you'll learn
- State accurately what re-running a previous revision of a role does and does not converge
- Identify the three classes of change that a git revert leaves behind entirely
- Explain why block/rescue is compensation you wrote rather than a transaction
- Place a proposed change on the reversibility spectrum before it is scheduled
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
Ansible provides no universal automatic rollback. There is no
transaction wrapping a play, no snapshot taken before it runs, no journal
of the previous state, and no --rollback flag. Nothing in ansible-doc,
nothing in ansible-config list, nothing planned.
This part opens with that sentence because the alternative belief is common, load-bearing, and discovered to be false at the worst possible moment. The belief sounds like this:
Rollback is easy. It is all in git. If the change goes wrong we revert the commit and re-run.
That sentence gets written into change plans. It gets accepted by change boards. And during an incident, at 02:40, someone runs it and finds that about sixty per cent of the change came back and the rest did not.
What re-running an older revision actually does
It is worth being precise, because “git revert does nothing” would be just as wrong as the belief this lesson is correcting.
A role describes desired state. Running the previous revision of that role converges the hosts toward the state that revision declares. For a large class of changes that genuinely is a rollback:
- A line in a config file changed from
4to16. The old role says4. Re-running it writes4. Correct. - A template gained a new stanza. The old template does not have it. Re-rendering writes the file without it. Correct.
- A systemd unit gained
Restart=always. The old unit file does not. Re-applying it plusdaemon_reloadrestores the old behaviour. Correct.
Every one of those works because the role declares the full content of the thing it manages, so re-declaring the old content is sufficient.
Where it stops working
The failure is structural, not a bug, and it has three shapes.
1. State the new revision created that the old revision never mentions
This is the big one, and it is invisible in a diff review because the diff only shows what was added.
# The change: v2 of the role added these three tasks.
- name: Create the service account the new component runs as
ansible.builtin.user:
name: appmetrics
system: true
state: present
- name: Install the new agent
ansible.builtin.apt:
name: metrics-agent
state: present
- name: Run the schema migration
ansible.builtin.command: /opt/app/bin/migrate --to 47
args:
creates: /opt/app/.migrated-47
Revert the commit. The old role does not contain those tasks. It
therefore does not say state: absent for the user, does not say
state: absent for the package, and has no concept of migration 47 at
all. Re-running it leaves:
- the
appmetricsuser, still present, still in/etc/passwd metrics-agent, still installed, still enabled, possibly still running- the database at schema 47, which the old application binary cannot read
The role reported ok on every task. The recap is green. The estate is
not back.
2. Changes the platform cannot reverse
Some things are gone.
| Change | Reversible by re-running the old role? | What it actually takes |
|---|---|---|
| Config value edited | Yes | Re-render the template |
| Package upgraded | Sometimes | An explicit version pin plus downgrade support |
| Package removed | Partly | Reinstall restores the binary, not the purged config |
| Kernel upgraded | No | Boot the previous entry, then remove the new one |
| Schema migrated forward | No | A down-migration, if one was written |
| Row deleted | No | Restore from backup |
| Filesystem reformatted | No | Restore from backup |
| Secret rotated at the provider | No | Rotate again to a third value |
The right-hand column is the honest one. Notice how much of it says “restore from backup” — which is why the backup lesson in this part is not an appendix.
3. Downgrade paths that are not supported
ansible.builtin.apt and ansible.builtin.dnf both take an
allow_downgrade option, and both document the same warning: setting it
true “can make this module behave in a non-idempotent way”, because
dependency resolution during a downgrade can drag other packages with it.
- name: Pin back to the previous application version
ansible.builtin.apt:
name: acme-app=2.4.1-1
state: present
allow_downgrade: trueEven where the module supports it, three things stand between that task and a rollback:
- The old version must still be in a repository you can reach. Many internal repositories keep only the current release. A vendor may have pulled the artefact.
- The package may not support being downgraded. Post-install scripts frequently migrate on-disk data forward and have no reverse.
- Dependency resolution may take other packages with it, which is precisely what the module documentation warns about.
block/rescue is compensation, not a transaction
The nearest thing Ansible offers to automatic recovery looks like this:
- name: Deploy with a compensating action
block:
- name: Swap the symlink to the new release
ansible.builtin.file:
src: /opt/app/releases/2.5.0
dest: /opt/app/current
state: link
- name: Confirm the application answers
ansible.builtin.uri:
url: http://localhost:8080/healthz
status_code: 200
rescue:
- name: Swap the symlink back
ansible.builtin.file:
src: "{{ previous_release_path }}"
dest: /opt/app/current
state: link
That is a real and useful pattern, and this part uses it. But read what it is: a second set of tasks that you wrote, that you have to keep correct, and that only runs if a task inside the block fails.
It is not a transaction, and the differences matter:
- It does not run if the play succeeds but the outcome is wrong. A
deploy that returns HTTP 200 from a broken build never enters
rescue. - It does not run if the controller dies, the SSH session drops, or the operator presses Ctrl-C.
- It does not know what the block changed. It only knows what you told it
to undo. Anything the block changed that
rescuedoes not mention stays changed. - On a host where the block half-completed,
rescueruns with the host in a state neither branch was written for.
The consequence for how changes are planned
If there is no automatic reverse, the reverse has to be built, and it has to be built before the change runs. That gives this part its structure:
- Write the reverse first. Lesson 2. A change with no written reverse does not get a window.
- Know which pattern applies. Lesson 3. Config file, package, kernel, service unit, snapshot — each answers a different failure and each costs a different amount.
- Run the window as a controlled sequence. Lesson 4.
- Expose progressively, with criteria that abort automatically. Lesson 5.
- Take the backup, and restore one to prove it. Lesson 6.
- Reconcile the fleet afterwards, because a partial rollback leaves more than one population. Lesson 7.
Knowledge check
Knowledge check · 4 questions
Q1. A role revision added a system user, installed a package, and ran a schema migration. The change goes badly, the commit is reverted, and the previous revision is re-run. It reports ok on every task and changed=0. What is the state of the fleet?
Q2. Which of these are true of a block/rescue pair used as a deployment rollback? Select all that apply.
Q3. Setting allow_downgrade: true on the apt module makes a package downgrade a reliable rollback mechanism, since the module then handles version regression the same way it handles an upgrade.
Q4. Why does backup: true exist on copy and template but not as a general facility across all modules?
Passing score: 75%. Answers are checked in this browser.