Skip to main content
RunBook Academy

AnsibleXLVIII · Maintenance Windows and RollbackMaintenance windows and rollback

Ansible has no undo

Advanced⏱ ~26 minansible-playbook

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

Not yet marked complete on this device.

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 4 to 16. The old role says 4. Re-running it writes 4. 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 plus daemon_reload restores 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 appmetrics user, 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.

ChangeReversible by re-running the old role?What it actually takes
Config value editedYesRe-render the template
Package upgradedSometimesAn explicit version pin plus downgrade support
Package removedPartlyReinstall restores the binary, not the purged config
Kernel upgradedNoBoot the previous entry, then remove the new one
Schema migrated forwardNoA down-migration, if one was written
Row deletedNoRestore from backup
Filesystem reformattedNoRestore from backup
Secret rotated at the providerNoRotate 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.

Service impact possiblea downgrade is a change, not an undo
- name: Pin back to the previous application version
ansible.builtin.apt:
  name: acme-app=2.4.1-1
  state: present
  allow_downgrade: true

Even where the module supports it, three things stand between that task and a rollback:

  1. 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.
  2. The package may not support being downgraded. Post-install scripts frequently migrate on-disk data forward and have no reverse.
  3. 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 rescue does not mention stays changed.
  • On a host where the block half-completed, rescue runs 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

  1. 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?

  2. Q2. Which of these are true of a block/rescue pair used as a deployment rollback? Select all that apply.

  3. 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.

  4. 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.