Windows Patch Management with Ansible: Automate win_updates at Scale
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
| Transport | Use Case | Notes |
|---|---|---|
ntlm | Workgroup or simple domain setups | Works everywhere; no credential delegation |
kerberos | Active Directory domains | Most secure; requires pywinrm[kerberos] and a valid krb5.conf |
credssp | Tasks needing a "double hop" (e.g., accessing file shares) | Delegates credentials — use sparingly and only over HTTPS |
basic | Lab/testing only | Local 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 fixesUpdateRollups- cumulative rollupsDefinitionUpdates- Defender/antimalware definitionsUpdates- general updatesFeaturePacks,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:
- Patch in small batches with
serialso most of the fleet stays up - Health-check before and after each host, and drain it from load balancers first
- Stop early on failure with
max_fail_percentageso 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.COMworks 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
ntlmorkerberos
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, deleteC:\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
serialand guard withmax_fail_percentage - Let
reboot: truehandle restart loops, but verify services afterwards - Audit with
state: searchedweekly, 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.