# Linux Server Patching with Ansible

Date: 2025-11-17

Linux patching looks deceptively simple until you have a mixed estate—Ubuntu LTS boxes, RHEL subscriptions, the odd SLES node in a regulated environment—each with different package managers, different security-update semantics, and different opinions on what "reboot required" means. A robust playbook handles all of them without becoming a conditional maze.

<!--more-->

## Package Manager Differences

| Distro Family | Manager | Ansible Module | Security-only flag |
|---|---|---|---|
| Debian / Ubuntu | apt | `ansible.builtin.apt` | `-security` pocket via `default_release` |
| RHEL / CentOS 8+ | dnf | `ansible.builtin.dnf` | `security: true` |
| RHEL / CentOS 7 | yum | `ansible.builtin.yum` | `security: true` |
| SUSE / SLES | zypper | `community.general.zypper` | `type: patch` |

Use `ansible_os_family` and `ansible_distribution` facts to branch cleanly rather than maintaining separate playbooks per distro.

---

## Security-Only vs Full Upgrade

**Debian / Ubuntu — security updates only:**

```yaml
- name: Apply security updates (Debian/Ubuntu)
  ansible.builtin.apt:
    upgrade: dist
    update_cache: true
    default_release: "{{ ansible_distribution_release }}-security"
  when: ansible_os_family == "Debian"
```

**RHEL / CentOS — security updates only:**

```yaml
- name: Apply security updates (RHEL/CentOS)
  ansible.builtin.dnf:
    name: "*"
    state: latest
    security: true
    update_cache: true
  when: ansible_os_family == "RedHat"
```

**Full upgrade (all packages):**

```yaml
- name: Full upgrade (Debian/Ubuntu)
  ansible.builtin.apt:
    upgrade: dist
    update_cache: true
  when: ansible_os_family == "Debian"

- name: Full upgrade (RHEL/CentOS)
  ansible.builtin.dnf:
    name: "*"
    state: latest
  when: ansible_os_family == "RedHat"
```

---

## Kernel Patching and Reboot Detection

Kernel updates require a reboot to take effect. Detect this reliably rather than rebooting unconditionally.

**Debian / Ubuntu — check `/run/reboot-required`:**

```yaml
- name: Check if reboot is required (Debian/Ubuntu)
  ansible.builtin.stat:
    path: /run/reboot-required
  register: reboot_required_file
  when: ansible_os_family == "Debian"

- name: Set reboot flag (Debian/Ubuntu)
  ansible.builtin.set_fact:
    reboot_required: "{{ reboot_required_file.stat.exists }}"
  when: ansible_os_family == "Debian"
```

**RHEL / CentOS — use `needs-restarting`:**

```yaml
- name: Check if reboot is required (RHEL/CentOS)
  ansible.builtin.command: needs-restarting -r
  register: reboot_check
  failed_when: reboot_check.rc not in [0, 1]
  changed_when: false
  when: ansible_os_family == "RedHat"

- name: Set reboot flag (RHEL/CentOS)
  ansible.builtin.set_fact:
    reboot_required: "{{ reboot_check.rc == 1 }}"
  when: ansible_os_family == "RedHat"
```

---

## Controlled Reboot with Service Validation

```yaml
- name: Reboot if required and allowed
  ansible.builtin.reboot:
    reboot_timeout: 600       # wait up to 10 minutes for the host to come back
    pre_reboot_delay: 30      # wait 30s after sending reboot command
    post_reboot_delay: 30     # wait 30s after host returns before continuing
    test_command: uptime      # confirm the host is responsive
  when:
    - reboot_required | bool
    - patch_reboot_allowed | bool   # set per group/host in group_vars

- name: Validate critical services are running post-reboot
  ansible.builtin.service:
    name: "{{ item }}"
    state: started
  loop: "{{ critical_services }}"   # defined in group_vars
  register: service_check

- name: Fail if any service did not recover
  ansible.builtin.fail:
    msg: "Service {{ item.name }} is not running after reboot on {{ inventory_hostname }}"
  loop: "{{ service_check.results }}"
  when: item.state != "started"
```

---

## Handling Package Locks and Failures

**Debian — held packages:**

```yaml
- name: List held packages before patching
  ansible.builtin.command: apt-mark showhold
  register: held_packages
  changed_when: false

- name: Warn if held packages exist
  ansible.builtin.debug:
    msg: "Held packages on {{ inventory_hostname }}: {{ held_packages.stdout_lines }}"
  when: held_packages.stdout != ""
```

**RHEL — excluded packages in dnf.conf:**

```yaml
- name: Show dnf exclusions
  ansible.builtin.command: grep -i exclude /etc/dnf/dnf.conf
  register: dnf_exclusions
  changed_when: false
  failed_when: false

- name: Report exclusions
  ansible.builtin.debug:
    var: dnf_exclusions.stdout_lines
```

---

## Complete Rolling Patch Workflow

A full playbook combining detection, conditional reboot, and service validation:

```yaml
---
- name: Linux rolling patch
  hosts: linux_servers
  become: true
  serial: "{{ patch_batch_size | default(10) }}"
  vars:
    critical_services:
      - nginx
      - sshd

  tasks:
    - name: Update package cache
      ansible.builtin.package:
        update_cache: true
      when: ansible_os_family in ["Debian", "RedHat"]

    - name: Apply security patches
      ansible.builtin.dnf:
        name: "*"
        state: latest
        security: true
      when: ansible_os_family == "RedHat"
      register: patch_result_rhel

    - name: Apply security patches
      ansible.builtin.apt:
        upgrade: dist
        default_release: "{{ ansible_distribution_release }}-security"
      when: ansible_os_family == "Debian"
      register: patch_result_debian

    - name: Check reboot required (Debian/Ubuntu)
      ansible.builtin.stat:
        path: /run/reboot-required
      register: reboot_file
      when: ansible_os_family == "Debian"

    - name: Check reboot required (RHEL/CentOS)
      ansible.builtin.command: needs-restarting -r
      register: reboot_cmd
      failed_when: reboot_cmd.rc not in [0, 1]
      changed_when: false
      when: ansible_os_family == "RedHat"

    - name: Set unified reboot fact
      ansible.builtin.set_fact:
        reboot_required: >-
          {{
            (ansible_os_family == "Debian" and reboot_file.stat.exists) or
            (ansible_os_family == "RedHat" and reboot_cmd.rc == 1)
          }}

    - name: Reboot if required
      ansible.builtin.reboot:
        reboot_timeout: 600
        pre_reboot_delay: 30
        post_reboot_delay: 30
      when:
        - reboot_required | bool
        - patch_reboot_allowed | default(true) | bool

    - name: Validate services
      ansible.builtin.service:
        name: "{{ item }}"
        state: started
      loop: "{{ critical_services }}"
```

---

**Previous:** [Advanced Ansible Usage for Enterprise Environments](/ansible/devops/2025/11/13/ansible-advanced-enterprise.html)

**Next in the series:** [Windows Server Patching with Ansible](/ansible/devops/2025/11/20/ansible-windows-server-patching.html) — WinRM setup, `win_updates`, update categories, and the security risks of misconfigured Windows remoting.
