Skip to main content
RunBook Academy

AnsibleXL · Patch and Reboot ManagementPatch and Reboot Management

Patching Debian and Ubuntu

Advanced⏱ ~26 minansible-playbookansible-doc

What you'll learn

  • Refresh the apt cache without refreshing it on every host on every run
  • Choose between upgrade safe, full and dist from what each is permitted to remove
  • Explain what the default dpkg_options decides about a locally modified configuration file
  • Diagnose and survive an apt lock held by unattended-upgrades

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.builtin.apt is a wrapper around a package manager that was designed for an interactive administrator, and the module’s job is to answer, in advance and without asking, every question apt would have put on the terminal.

Most of those answers are defaults you did not choose. One of them decides what happens to a configuration file you edited by hand. This lesson is about which defaults are load-bearing.

Everything below was read from ansible-doc ansible.builtin.apt in a virtualenv pinned to ansible-core 2.21.3, not from memory.

Read-only / Safereading the contract rather than assuming it
$ ansible-doc ansible.builtin.apt
   dpkg_options  Add `dpkg' options to `apt' command. Defaults to `-o
               "Dpkg::Options::=--force-confdef" -o
               "Dpkg::Options::=--force-confold"'.
      default: force-confdef,force-confold

 lock_timeout  How many seconds will this action wait to acquire a
               lock on the apt db.
      default: 60

 upgrade  If yes or safe, performs an aptitude safe-upgrade.
          If full, performs an aptitude full-upgrade.
          If dist, performs an apt-get dist-upgrade.
      choices: [dist, full, 'no', safe, 'yes']
      default: 'no'

 state   choices: [absent, build-dep, latest, present, fixed]
      default: present

The cache, and why refreshing it on every task is wrong

apt will not install a version it does not know exists, so the package lists have to be current. update_cache: true runs the equivalent of apt-get update.

The default is not to update the cache at all — the module leaves update_cache unset rather than defaulting it to false, which amounts to the same thing for a play but is worth knowing when you read the documentation.

The naive fix is to set update_cache: true on every apt task. On a 300-host fleet with six package tasks, that is 1800 metadata refreshes against your mirror inside one run, and a mirror that starts returning 503 halfway through.

Configuration changerefresh once, then reuse
- name: Refresh the package lists if they are older than an hour
ansible.builtin.apt:
  update_cache: true
  cache_valid_time: 3600

cache_valid_time is in seconds and defaults to 0, which means “always refresh”. Setting it makes the task idempotent in a useful sense: a rerun within the hour does nothing and reports ok.

The documentation also records a convenience that is easy to trip over: since Ansible 2.4, setting cache_valid_time explicitly implies update_cache: yes. So this is a cache refresh even though it does not say so:

- name: This refreshes the cache, despite appearances
  ansible.builtin.apt:
    name: nginx
    state: present
    cache_valid_time: 3600

state: latest versus upgrade

These are different operations and the difference is what gets touched.

state: latest with a name upgrades the named packages and their dependencies. Nothing else on the host moves.

upgrade ignores name entirely and upgrades everything the system has an update for. The documentation is explicit: “This does not upgrade a specific package, use state=latest for that.”

For a patch run you almost always want upgrade, because the point of patching is the set of packages you did not enumerate.

What each upgrade mode is permitted to remove

This is the distinction that matters, and the parameter names do not convey it.

ValueUnderlying operationMay remove installed packages?
safe (and its alias yes)aptitude safe-upgradeNo
fullaptitude full-upgradeYes
distapt-get dist-upgradeYes
no (default)nothing

safe-upgrade upgrades packages only where doing so requires no removals. If a package can only be upgraded by removing something else, safe leaves it at the old version — which is conservative, and which also means a safe upgrade can silently leave a security update unapplied.

full-upgrade and dist-upgrade will resolve those cases by removing whatever is in the way. On a well-maintained host that is usually an obsolete transitional package. On a host that someone has been hand- editing for four years it can be your monitoring agent.

Service impact possiblea defensible fleet upgrade task
- name: Apply all available updates
ansible.builtin.apt:
  upgrade: safe
  autoremove: false
  fail_on_autoremove: true
  lock_timeout: 300
register: apt_upgrade

- name: Record what changed, for the audit trail
ansible.builtin.debug:
  var: apt_upgrade.stdout_lines
when: apt_upgrade.changed

autoremove: false is explicit rather than redundant. The default is already false, and writing it down states that removing orphaned dependencies is a separate, deliberate maintenance action rather than something a patch run does on the way past.

only_upgrade: patch what is there, install nothing new

only_upgrade: true upgrades a named package only if it is already installed, and does nothing if it is not.

Configuration changethe same task against a heterogeneous fleet
- name: Upgrade the packages we own, where they are installed
ansible.builtin.apt:
  name:
    - nginx
    - openssl
    - openssh-server
  state: latest
  only_upgrade: true

Without only_upgrade, state: latest on a package that is absent installs it. Run that task against a group that turned out to include the database tier and you have just installed nginx on your databases — a change that will pass every health check and confuse somebody in four months.

The parameter that decides what happens to your edited config files

dpkg_options defaults to force-confdef,force-confold, and that default answers the most consequential question a Debian upgrade asks.

When a package upgrade ships a new version of a configuration file that you have modified locally, dpkg normally stops and asks. In a non-interactive run it cannot ask, so the answer is pre-selected:

  • force-confold — keep the version currently on disk. Your modified file survives; the maintainer’s new file is written alongside it as .dpkg-dist and ignored.
  • force-confdef — where the package provides a default action, take it, and only fall back on confold when there is no default.

Together they mean: your local edits win, silently, every time.

The deeper fix is the one the configuration-management parts of this course argue for throughout: if a file matters, Ansible should own it as a template, and the .dpkg-dist question stops being interesting because the desired content is asserted on every run rather than inherited from whatever the package left behind.

The lock, and the neighbour that holds it

On Ubuntu and Debian, unattended-upgrades is enabled by default on many installations, and it does exactly what you are trying to do, on its own timer, holding the same dpkg lock.

Read-only / Safewhat the failure looks like
$ ansible-playbook -i inventories/production patch.yml --limit webservers
fatal: [web07.example.com]: FAILED! => {
  "changed": false,
  "msg": "Failed to lock apt for exclusive operation: Failed to lock directory /var/lib/dpkg/: E:Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 2841 (unattended-upgr)"
}

The obvious diagnosis is that something is broken. Nothing is broken. Another patching mechanism got there first, and on a fleet it will hit a different subset of hosts every month, which is why the failure looks random.

Three responses, in increasing order of correctness:

Wait longer. lock_timeout defaults to 60 seconds. An unattended-upgrades run that is downloading a 200 MB kernel will hold the lock for considerably longer than that. Raising it to 300 turns most of these into a slow success.

Wait for the lock explicitly, so the run log says what it waited for:

Read-only / Safechecking for the neighbour before starting
- name: Check whether unattended-upgrades holds the apt lock
ansible.builtin.command:
  argv:
    - fuser
    - /var/lib/dpkg/lock-frontend
register: dpkg_lock
changed_when: false
failed_when: false
retries: 30
delay: 10
until: dpkg_lock.rc != 0

- name: Refuse to patch a host whose lock is still held
ansible.builtin.fail:
  msg: >-
    {{ inventory_hostname }} still has the dpkg lock held after five
    minutes. Another package operation is in progress; this host is
    excluded from the run rather than fought with.
when: dpkg_lock.rc == 0

Stop competing. The correct long-term answer is that one thing patches a host. If Ansible does it, unattended-upgrades should be configured for security-only or disabled, and that decision should be recorded in the same role that does the patching.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A security update ships a new /etc/ssh/sshd_config that disables a weak cipher. The host has a locally edited sshd_config. The patch play uses ansible.builtin.apt with default settings. What happens?

  2. Q2. What is the practical difference between upgrade: safe and upgrade: full?

  3. Q3. A play sets update_cache: true on all six of its apt tasks against a 300-host fleet. Which criticisms are valid? Select all that apply.

  4. Q4. A patch task fails with "Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 2841 (unattended-upgr)" on 12 of 40 hosts. The correct diagnosis is that another patching mechanism is running on those hosts, not that the module or the mirror is broken.

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