Windows

Windows Patch Management with Ansible: Automate win_updates at Scale

Teach me Ansible | 2026-08-21 | 21 min read

Windows patching doesn't have to mean RDP sessions, manual WSUS approvals, and late-night clicking through update wizards. With Ansible's agentless approach and the win_updates module, you can patch hundreds of Windows servers in controlled, auditable batches — complete with health checks, reboot handling, and reporting.

Why Automate Windows Patching with Ansible?

Ansible is best known for Linux automation, but its Windows support is mature and production-ready. The key advantage is that Ansible is agentless: it connects to Windows hosts over WinRM (or OpenSSH on Windows Server 2019+) and executes PowerShell remotely. There's nothing to install, license, or maintain on the managed nodes.

  • No agents - WinRM ships with every supported Windows Server version
  • Idempotent - win_updates only installs what's missing and reports accurately
  • Orchestration - serial batches, pre/post checks, and load balancer draining in one playbook
  • Auditability - every run produces structured output you can log and report on
  • Unified tooling - the same inventory, vault, and CI pipelines you already use for Linux

Prerequisites

Control Node Requirements

Your Ansible control node (Linux) needs the pywinrm Python library and the ansible.windows collection:

# Install pywinrm for WinRM connectivity
pip install "pywinrm>=0.4.1"

# For Kerberos authentication (domain environments)
pip install "pywinrm[kerberos]"

# For CredSSP authentication
pip install "pywinrm[credssp]"

# Install the Windows collections
ansible-galaxy collection install ansible.windows
ansible-galaxy collection install community.windows
ansible-galaxy collection install chocolatey.chocolatey

Managed Node Requirements

  • Windows Server 2016 or later (2012 R2 works but is EOL)
  • PowerShell 5.1+
  • WinRM service enabled and listening (HTTPS strongly recommended)
  • A local or domain account with administrator rights

Setting Up WinRM

On each Windows host, enable WinRM with an HTTPS listener. For lab environments you can use the quick setup; production should use proper certificates via Group Policy.

# Run in an elevated PowerShell session on the Windows host
# Enable WinRM with defaults
winrm quickconfig -q

# Create an HTTPS listener with a self-signed cert (lab only)
$cert = New-SelfSignedCertificate -DnsName $env:COMPUTERNAME -CertStoreLocation Cert:\LocalMachine\My
New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * -CertificateThumbPrint $cert.Thumbprint -Force

# Open the firewall for WinRM over HTTPS (5986)
New-NetFirewallRule -DisplayName "WinRM HTTPS" -Direction Inbound -LocalPort 5986 -Protocol TCP -Action Allow

Inventory and Connection Variables

Define your Windows hosts and connection settings. Keep credentials in Ansible Vault, never in plain text:

# inventory/windows.ini
[windows_web]
win-web-01.corp.example.com
win-web-02.corp.example.com

[windows_db]
win-sql-01.corp.example.com

[windows:children]
windows_web
windows_db

[windows:vars]
ansible_connection=winrm
ansible_port=5986
ansible_winrm_transport=ntlm
ansible_winrm_server_cert_validation=validate
ansible_user={{ vault_win_admin_user }}
ansible_password={{ vault_win_admin_password }}

Choosing an Authentication Method

TransportUse CaseNotes
ntlmWorkgroup or simple domain setupsWorks everywhere; no credential delegation
kerberosActive Directory domainsMost secure; requires pywinrm[kerberos] and a valid krb5.conf
credsspTasks needing a "double hop" (e.g., accessing file shares)Delegates credentials — use sparingly and only over HTTPS
basicLab/testing onlyLocal accounts only; never use over HTTP

For Kerberos in a domain, configure /etc/krb5.conf on the control node and switch the transport:

ansible_winrm_transport=kerberos
ansible_user=svc_ansible@CORP.EXAMPLE.COM

Verify Connectivity

# The Windows equivalent of the ping module
ansible windows -i inventory/windows.ini -m ansible.windows.win_ping

# Gather facts to confirm full functionality
ansible windows -i inventory/windows.ini -m ansible.windows.setup

The win_updates Module Deep Dive

ansible.windows.win_updates drives the Windows Update Agent directly. It searches, downloads, and installs updates, and can handle reboots — all idempotently.

Basic Usage

- name: Install security and critical updates
  ansible.windows.win_updates:
    category_names:
      - SecurityUpdates
      - CriticalUpdates
    state: installed
    reboot: true
  register: update_result

Update Categories

The category_names parameter accepts these values:

  • SecurityUpdates - security fixes (patch these first, always)
  • CriticalUpdates - critical non-security fixes
  • UpdateRollups - cumulative rollups
  • DefinitionUpdates - Defender/antimalware definitions
  • Updates - general updates
  • FeaturePacks, ServicePacks, Tools, Drivers
  • * - everything (use with caution)

Search-Only Mode (Audit Without Installing)

Use state: searched to report what's missing without changing anything — perfect for compliance scans:

- name: Audit missing updates
  ansible.windows.win_updates:
    category_names: '*'
    state: searched
  register: missing_updates

- name: Show pending update count
  ansible.builtin.debug:
    msg: "{{ inventory_hostname }} is missing {{ missing_updates.found_update_count }} update(s)"

Reboot Handling

Updates frequently require reboots, and pending reboots block further installs. The module can manage the whole cycle:

- name: Install updates and reboot as many times as needed
  ansible.windows.win_updates:
    category_names:
      - SecurityUpdates
      - CriticalUpdates
      - UpdateRollups
    reboot: true                # Reboot automatically if required
    reboot_timeout: 1800        # Wait up to 30 min for host to return
    state: installed
  register: update_result

With reboot: true, win_updates will reboot, wait for the host, and continue installing until no more updates require restarts. If you prefer to control reboots yourself, set reboot: false and check the result:

- name: Install updates without automatic reboot
  ansible.windows.win_updates:
    category_names:
      - SecurityUpdates
    reboot: false
  register: update_result

- name: Reboot during the maintenance window only
  ansible.windows.win_reboot:
    reboot_timeout: 1800
    post_reboot_delay: 60
  when: update_result.reboot_required

Blacklisting and Whitelisting Updates

Exclude known-problematic KBs or preview releases, or target a single specific patch:

- name: Install updates, excluding known-bad KBs and previews
  ansible.windows.win_updates:
    category_names:
      - SecurityUpdates
      - CriticalUpdates
    reject_list:
      - KB5034441          # Known WinRE partition failure
      - Preview            # Regex match: skip all preview updates
    reboot: true

- name: Install only one specific emergency patch
  ansible.windows.win_updates:
    category_names: '*'
    accept_list:
      - KB5035849
    reboot: true

Watch Out

accept_list and reject_list entries are treated as regular expressions matched against both the KB number and the update title. KB5034441 also matches KB50344411 — anchor with ^KB5034441$ if you need an exact match.

Scheduling Patch Windows

Never patch production outside an agreed window. Enforce the window in the playbook itself so an accidental ad-hoc run can't cause an outage:

- name: Enforce the maintenance window
  ansible.builtin.assert:
    that:
      - lookup('pipe', 'date +%u') | int in [6, 7]   # Saturday or Sunday
      - lookup('pipe', 'date +%H') | int >= 22 or lookup('pipe', 'date +%H') | int < 4
    fail_msg: "Outside patch window (Sat/Sun 22:00-04:00). Use -e force_patching=true to override."
  when: not (force_patching | default(false) | bool)

Then schedule the run itself with cron on the control node, a CI/CD pipeline, or AWX/AAP schedules:

# Cron on the control node: Saturdays at 22:00
0 22 * * 6 ansible-playbook -i inventory/windows.ini patch-windows.yml >> /var/log/ansible/patching.log 2>&1

Patching a Fleet Safely

The difference between a patch run and an outage is orchestration. Three rules keep fleets safe:

  1. Patch in small batches with serial so most of the fleet stays up
  2. Health-check before and after each host, and drain it from load balancers first
  3. Stop early on failure with max_fail_percentage so a bad patch doesn't take out everything
- name: Rolling patch of web tier
  hosts: windows_web
  serial: "25%"                 # Patch a quarter of the fleet at a time
  max_fail_percentage: 10       # Abort the run if >10% of a batch fails

  pre_tasks:
    - name: Verify host is healthy before patching
      ansible.windows.win_uri:
        url: "http://{{ inventory_hostname }}/healthz"
        status_code: 200
      delegate_to: localhost

    - name: Drain node from the load balancer
      community.windows.win_iis_website:
        name: Default Web Site
        state: stopped

  tasks:
    - name: Apply security updates
      ansible.windows.win_updates:
        category_names:
          - SecurityUpdates
          - CriticalUpdates
        reboot: true
        reboot_timeout: 1800
      register: patch_result

  post_tasks:
    - name: Return node to service
      community.windows.win_iis_website:
        name: Default Web Site
        state: started

    - name: Verify application health after patching
      ansible.windows.win_uri:
        url: "http://{{ inventory_hostname }}/healthz"
        status_code: 200
      delegate_to: localhost
      retries: 10
      delay: 15
      register: health
      until: health.status_code == 200

Application Updates with win_package and Chocolatey

Windows Update covers the OS, but third-party apps need their own pipeline. win_package handles MSI/EXE installers, while Chocolatey gives you apt-style package management on Windows:

- name: Install or upgrade an MSI package
  ansible.windows.win_package:
    path: '\\fileserver\software\7z2409-x64.msi'
    product_id: '{23170F69-40C1-2702-2409-000001000000}'
    state: present

- name: Ensure Chocolatey is installed
  chocolatey.chocolatey.win_chocolatey:
    name: chocolatey
    state: present

- name: Keep common tools patched via Chocolatey
  chocolatey.chocolatey.win_chocolatey:
    name: "{{ item }}"
    state: latest
  loop:
    - git
    - 7zip
    - notepadplusplus

Reporting Patch Status

Every win_updates run returns structured data: installed_update_count, found_update_count, reboot_required, and a full updates dictionary with KB numbers and titles. Turn that into a fleet-wide report:

- name: Generate patch report on the control node
  hosts: windows
  gather_facts: false
  tasks:
    - name: Audit update status
      ansible.windows.win_updates:
        category_names: '*'
        state: searched
      register: audit

    - name: Write per-host report line
      ansible.builtin.lineinfile:
        path: /var/log/ansible/patch-report-{{ '%Y-%m-%d' | strftime }}.csv
        line: "{{ inventory_hostname }},{{ audit.found_update_count }},{{ audit.reboot_required }}"
        create: true
      delegate_to: localhost
      throttle: 1

Complete End-to-End Patch Management Playbook

Putting it all together — window enforcement, batching, health checks, patching, verification, and reporting:

---
# patch-windows.yml
- name: Windows fleet patch management
  hosts: windows
  serial: "20%"
  max_fail_percentage: 15
  vars:
    patch_categories:
      - SecurityUpdates
      - CriticalUpdates
      - UpdateRollups
    blocked_kbs:
      - Preview
    report_file: "/var/log/ansible/patch-report-{{ '%Y-%m-%d' | strftime }}.csv"

  pre_tasks:
    - name: Confirm we are inside the patch window
      ansible.builtin.assert:
        that: lookup('pipe', 'date +%u') | int in [6, 7]
        fail_msg: "Outside patch window. Override with -e force_patching=true"
      when: not (force_patching | default(false) | bool)
      run_once: true
      delegate_to: localhost

    - name: Check free disk space on C:\
      ansible.windows.win_shell: |
        (Get-PSDrive C).Free / 1GB
      register: free_gb
      changed_when: false

    - name: Abort host if less than 10 GB free
      ansible.builtin.fail:
        msg: "Only {{ free_gb.stdout | trim | float | round(1) }} GB free on C:\\"
      when: free_gb.stdout | trim | float < 10

    - name: Create pre-patch restore checkpoint marker
      ansible.windows.win_file:
        path: C:\patch_in_progress.flag
        state: touch

  tasks:
    - name: Apply Windows updates
      ansible.windows.win_updates:
        category_names: "{{ patch_categories }}"
        reject_list: "{{ blocked_kbs }}"
        reboot: true
        reboot_timeout: 1800
      register: patch_result

    - name: Final reboot if still pending
      ansible.windows.win_reboot:
        reboot_timeout: 1800
      when: patch_result.reboot_required

  post_tasks:
    - name: Wait for WinRM to stabilise
      ansible.builtin.wait_for_connection:
        timeout: 600

    - name: Verify critical services are running
      ansible.windows.win_service_info:
        name: "{{ item }}"
      register: svc
      failed_when: svc.services[0].state != 'started'
      loop:
        - W3SVC
        - WinRM

    - name: Remove in-progress marker
      ansible.windows.win_file:
        path: C:\patch_in_progress.flag
        state: absent

    - name: Record result in fleet report
      ansible.builtin.lineinfile:
        path: "{{ report_file }}"
        line: "{{ inventory_hostname }},{{ patch_result.installed_update_count }},{{ patch_result.failed_update_count | default(0) }},{{ patch_result.reboot_required }}"
        create: true
      delegate_to: localhost
      throttle: 1

Run it with vault-encrypted credentials:

ansible-playbook -i inventory/windows.ini patch-windows.yml \
  --ask-vault-pass \
  -e force_patching=false

Common WinRM Errors and Fixes

Connection Refused / Timeout

# Symptom: "ssl: HTTPSConnectionPool ... Connection refused"
# Fix on the Windows host: verify the listener and firewall
winrm enumerate winrm/config/Listener
Test-NetConnection -ComputerName localhost -Port 5986

401 Unauthorized

  • NTLM with a local account: ensure the account is in the local Administrators group
  • Kerberos: check kinit user@REALM.COM works from the control node, realm is UPPERCASE in krb5.conf, and clocks are within 5 minutes (Kerberos is clock-sensitive)
  • Basic auth fails by design for domain accounts — switch to ntlm or kerberos

Certificate Validation Errors

# Lab environments with self-signed certs only:
ansible_winrm_server_cert_validation=ignore
# Production: deploy real certs via AD CS / Group Policy and keep validation on

The "Double Hop" Problem

A task fails accessing a network share (Access is denied) even though the account has rights. WinRM doesn't delegate credentials to a second hop by default. Fix by using ansible_winrm_transport=credssp for that play, or better, use become:

- name: Copy installer from a file share (second hop)
  ansible.windows.win_copy:
    src: \\fileserver\software\app.msi
    dest: C:\Temp\app.msi
    remote_src: true
  become: true
  become_method: runas
  become_user: "{{ ansible_user }}"
  vars:
    ansible_become_password: "{{ ansible_password }}"

win_updates Hangs or Fails with 0x80240032

  • Restart the Windows Update service: ansible.windows.win_service: name=wuauserv state=restarted
  • Clear the update cache: stop wuauserv, delete C:\Windows\SoftwareDistribution, restart
  • Verify the host can reach Windows Update or your WSUS server
  • win_updates cannot run over a CredSSP/become-less session in some older Ansible versions — upgrade to ansible.windows ≥ 1.12 which runs updates via a scheduled task automatically

Conclusion

Ansible turns Windows patching from a manual chore into a repeatable, low-risk pipeline. Start with a search-only audit to understand your patch debt, wire up rolling batches with health checks for one non-critical tier, then expand until your entire Windows estate patches itself inside its maintenance window — with a CSV report waiting for you in the morning.

  • Use WinRM over HTTPS with Kerberos in domain environments
  • Patch security and critical categories first, previews never
  • Always batch with serial and guard with max_fail_percentage
  • Let reboot: true handle restart loops, but verify services afterwards
  • Audit with state: searched weekly, even between patch windows

Pro Tip

Run the audit playbook (state: searched) as a scheduled job every Monday and diff the CSV against last week's. A sudden jump in missing updates on one host usually means its Windows Update service is broken — catch it days before the patch window instead of during it.