Patching Windows Servers at Scale with Ansible


Patching hundreds of Windows servers can be a daunting task. My personal favorite patching solution has been the Remote Monitoring and Management (RMM) platform Action1. It automates the entire lifecycle—from discovery to reboot—with minimal hands‑on effort and it just works. However, many organizations understandably balk at paying for an RMM tool, especially when it means deploying yet another agent alongside existing monitoring and security solutions.


Enter Ansible

With Ansible you can orchestrate Windows patching end‑to‑end—at no license cost—while keeping the process fully controlled, auditable, and repeatable. The playbooks handle everything from inventory discovery to post‑patch reboots, giving you a single source of truth for compliance reporting. The only prerequisite is a user account that can remotely access the client machines using a supported remote‑execution protocol (SSH for Linux or WinRM for Windows). Once achieved, Ansible playbooks can take care of the rest.

TIP: As a rule I avoid community modules in ansible and utilize only built-in modules. This ensures I do not run into compatability issues as updates come out.
The "ansible.windows.win_shell" and "ansible.builtin.shell" modules can be used to accomplish pretty much anything community modules do.


Patching Cycle Summary

First thing we want is a monthly change ticket to be created. It will need to contain:

  • Latest Windows Update KB numbers
  • All Windows servers to be patched

The latest Article ID / KB values will be retrieved from the Microsoft Security Response Center (MSRC) API.
We will obtain a list of Windows Servers to patch by enumerating a group from your ansible inventory, assuming the group is named "windows_servers".

After creating the change we perform patching in a 3 phased execution:

  1. Download Updates
  2. Install OS Updates and Reboot
  3. Install SQL and other Remaining Updates and Reboot if needed.

API Request or Send an Email

The playbook in my example creates a Change Request using the FreshService API utilizing the ansible.builtin.uri module to handle the HTTP communication. If you’re using a different platform like ServiceNow, you can simply replace the URLs and POST data with values from your system’s API documentation.

Another option is to create a ticket by sending an email. While simpler, this approach has a downside.

  • API: Only requires the ticketing service to be available.
  • Email: Depends on both your email service and the ticketing system being operational.

When reliability matters, the API is the better choice because it eliminates an extra point of failure.


Future Proofing and Scaling

I recommend setting up Semaphore UI or Red Hat Ansible Automation Platform (AAP) to schedule and run your playbooks. This ensures future hires in your department can operate the solution easily.

Ansible is written in Python. If you have ever used someone else's Python script before you are familiar with issues versioning can bring. Utilize only built-in ansible modules and avoid community modules whenever possible to avoid having to navigate those issues.


In the actual patching playbooks we use strategy: free and forks (-f) to run playbook tasks across the exact number of servers we want simultaneously. This makes the output slightly messier, but the faster completion time is worth it.

Downloading updates uses network bandwidth.
Installing updates uses processing power on local machines.
The real impact in Windows patching comes during and after reboots to finalize updates.

By determining the maximum number of servers on which you can simultaneously perform the Download, Install, and Reboot cycles, you can identify your network's capacity limit and calculate the total time required to complete the process. If your network expands in the future, this information can help you determine if you need to upgrade to higher-speed interfaces or purchase additional internet bandwidth. Watching the playbook completion time will help clue you in to your networks abilities.


NOTE: If you decide to install optional updates, you will want to exclude the Azure Connected Machine Agent from patching. Only update this agent inside maintenance windows as its updates will cause outages. In this three phase design you can include patching "optional updates", (which includes the "Azure Connected Machine Agent") by setting "skip_optional: false" in our third phase playbook which installs SQL updates.


Automate the Change Request

Before any patching can begin we need to schedule when we will do it. We automatically discover all available updates for the current month's "Patch Tuesday", utilizing the Microsoft Security Response Center (MSRC) CVRF API. This ensures we have a complete, authoritative list of KB articles, release notes, and download links — specific to the current month and year.

This dedicated playbook must run on any Windows host with internet access. (I originally wrote a PowerShell script for this purpose, or it could run entirely from a Linux-based Ansible control node.) Since the script already exists, there was no need for me to duplicate the effort. Schedule this task to run every Wednesday afternoon or evening following Patch Tuesday (the second Tuesday of each month). This timing ensures the automation isn’t impacted by delays in Microsoft publishing updated patch information.

This playbook will:

  • Calculate the "Start Date and Time" and "End Date and Time" for your patching window; assuming 6 hours on the last Thursday of the month. Modify yours to whatever you want.
  • Enumerate the hosts in your ansible inventory group windows_servers
  • Query the MSRC API for the current month’s security bulletin
  • Generate an HTML table with all patches (includes KB numbers, support article URLs, and download links)
  • Use the FreshService API to create the Change Request.

Unfortunately for us, FreshService API only allows updating the Subject and Description in the Change Request. It does not allow you to fill out other fields in the Change Request such as Impact, Rollout Plan, Rollback Plan, and any other text sections you add. I have submitted a request with FreshService support to add this to their API's capabilities.


Submit Change Request Playbook

In the playbook below you will need to define the email address of a user account in Freshservice that can create Change Requests. You also need to define valid FreshService ID values which I have defined in the "vars" section.

Below is a working playbook after you discover and define your FreshService instances values. There is however one variable, "freshservice_api_key which should included using an anisble vault.
ansible-playbook -i windows_inventory.yml create_windows_patching_fscr.yml --ask-vault-pass
# or
ansible-playbook -i windows_inventory.yml create_windows_patching_fscr.yml --vault-password-file ~/.vault_pass.txt

---
- name: Submit Windows Server Patching Change Request Playbook
  hosts: server01
  gather_facts: false
  tasks:
    - name: Get the Latest Windows Updates
      ansible.windows.win_shell: |
        Function Get-DayOfTheWeeksNumber {
            [CmdletBinding()]
                param(
                    [Parameter(
                        Position=0,
                        Mandatory=$True,
                        ValueFromPipeline=$False,
                        HelpMessage="Define the day of the week you want: `nEXAMPLE: Tuesday"
                    )]
                    [ValidateSet('Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday')]
                    [String]$DayOfWeek,
                    [Parameter(
                        Position=1,
                        Mandatory=$True,
                        ValueFromPipeline=$False,
                        HelpMessage="Identify which week of the month you want: `nEXAMPLE: 2"
                    )]
                    [ValidateRange(1,6)]
                    [Int32]$WhichWeek,
                    [Parameter(
                        Position=2,
                        Mandatory=$False,
                        ValueFromPipeline=$False,
                        HelpMessage="Identify  which week of the month you want: `nEXAMPLE: 2"
                    )]
                    [ValidateSet('January','February','March','April','May','June','July','August','September','October','November','December')]
                    [String]$Month = $((Get-Culture).DateTimeFormat.GetMonthName((Get-Date).Month)),
                    [Parameter(
                        Position=3,
                        Mandatory=$False,
                        ValueFromPipeline=$False,
                        HelpMessage="Identify  which week of the month you want: `nEXAMPLE: 2"
                    )]
                    [ValidateScript({$_ -match '(\d\d\d\d)'})]
                    [Int32]$Year = (Get-Date).Year
                )
            $Today = Get-Date -Date "$Month $Year"
            $Subtract = $Today.Day - 1
            [DateTime]$MonthStart = $Today.AddDays(-$Subtract)
            While ($MonthStart.DayOfWeek -ne $DayOfWeek) {
                $MonthStart = $MonthStart.AddDays(1)
            }
            Return $MonthStart.AddDays(7*($WhichWeek - 1))
        }
        Function Get-WindowsUpdateKBs {
        [CmdletBinding()]
            param()
            [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls13
            $UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0'
            Write-Information -MessageData "[i] $(Get-Date -Format 'MM-dd-yyyy hh:mm:ss') Getting $(Get-Date -UFormat '%B %Y') Windows Updates"
            $MsrcUri = "https://api.msrc.microsoft.com/cvrf/v2.0/updates('$(Get-Date -UFormat %Y-%b)')"
            Try {
                $MicrosoftSecUpdateLink = (Invoke-RestMethod -UseBasicParsing -Method GET -ContentType 'application/json' -UserAgent $UserAgent -Uri $MsrcUri).Value.CvrfUrl
            } Catch {
                $MicrosoftSecUpdateLink = (Invoke-RestMethod -UseBasicParsing -Method GET -ContentType 'application/json' -UserAgent $UserAgent -Uri "https://api.msrc.microsoft.com/cvrf/v2.0/updates('$(Get-Date -Date (Get-Date).AddMonths(-1) -UFormat %Y-%b)')").Value.CvrfUrl
            }
            Write-Information -MessageData "[i] $(Get-Date -Format 'MM-dd-yyyy hh:mm:ss') Getting $(Get-Date -UFormat '%B %Y') Windows Update Article ID values"
            $MicrosoftSecInfo = Invoke-RestMethod -UseBasicParsing -Method GET -ContentType 'application/json' -UserAgent $UserAgent -Uri $MicrosoftSecUpdateLink -Verbose:$False | Select-Object -ExpandProperty cvrfdoc
            $ArticleIDs = $MicrosoftSecInfo.Vulnerability | ForEach-Object -Process { $_ | Select-Object -ExpandProperty Remediations -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Remediation -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Url -Unique -ErrorAction SilentlyContinue } | Where-Object -FilterScript { $_ -like "*catalog.update.microsoft.com*" -and $_ -notlike "*/help/*" } | Select-Object -Unique
            $PatchTuesday = Get-DayOfTheWeeksNumber -DayOfWeek Tuesday -WhichWeek 2 -Verbose:$False
            $Output = ForEach ($ArticleID in $ArticleIDs) {
                $KB = $ArticleID.Split('=')[-1]
                New-Object -TypeName PSCustomObject -Property @{
                    KB=$KB;
                    ReleaseNotes="https://support.microsoft.com/help/$($KB.Replace('KB', ''))";
                    Download=$ArticleID
                }
            }
            Return $Output
        }
        $UpdateKBs = Get-WindowsUpdateKBs | Select-Object -Property KB,ReleaseNotes,Download
        $UpdateKBs | ConvertTo-Html -Fragment
      register: latest_windows_patches

    - name: Save latest windows patches to a file
      ansible.builtin.copy:
        content: "{{ latest_windows_patches.stdout_lines | join('\n') }}"
        dest: "/tmp/.latest_windows_patches.txt"
      delegate_to: localhost

- name: Submit Server Patching Change Request
  hosts: localhost 
  gather_facts: true
  vars:
    freshservice_domain: YOURDOMAIN.freshservice.com
    fs_requester_email: freshservice-requester@osbornepro.com
    agent_id: 00000000001           # Unique identifier of the agent to whom the change is assigned.
    group_id: 00000000002           # Unique identifier of the agent group to which the change is assigned.
    requester_id: 00000000003       # Unique identifier of the initiator of the change. (Mandatory)
    department_id: 00000000004      # Unique ID of the department initiating the change.
    cab_name: "IT CAB"              # This playbook will translate your FreshService CABs name to an ID value
    category: Security              # Category of the change
    risk: 1                         # 1 is Low and 4 is Highest
    impact: 1                       # 1 is Low and 3 is Highest
    priority: 1                     # 1 is Low and 4 is Highest
    change_type: 1                  # 1 is Minor through 4 which is Emergency
    status: 4                       # 4 translates to Open
    latest_windows_patches_from_file: "{{ lookup('file', '/tmp/.latest_windows_patches.txt') }}"
  tasks:
    - name: Get Planned Start Date (last Thursday of the month at 22:00 ET)
      ansible.builtin.shell: |
        # 1. Current year / month
        year=$(date +%Y)
        month=$(date +%m)

        # 2. Last day of the month (using GNU date)
        last_day=$(date -d "$year-$month-01 +1 month -1 day" +%d)

        # 3. Weekday of that day (Mon=1 … Sun=7)
        wd_last=$(date -d "$year-$month-$last_day" +%u)

        # 4. How many days back to the previous Saturday (Saturday = 6)
        days_back=$(( (wd_last - 6 + 7) % 7 ))

        # 5. The last Saturday
        last_sat=$(date -d "$year-$month-$last_day - $days_back days" +%F)

        # 6. Full ISO-8601 timestamp in ET
        planned_start_date="${last_sat}T22:00:00-04:00"

        echo "$planned_start_date"
      register: planned_start_date_result

    - name: Set planned start date fact
      ansible.builtin.set_fact:
        planned_start_date: "{{ planned_start_date_result.stdout }}"

    - name: Get Planned End Date (6 hours after planned start date)
      ansible.builtin.shell: |
        planned_start_date="{{ planned_start_date }}"
        planned_start_timestamp=$(date -d "$planned_start_date" +%s)
        planned_end_timestamp=$((planned_start_timestamp + 6 * 3600))
        planned_end_date=$(/usr/bin/date -d @$planned_end_timestamp +"%Y-%m-%dT%H:%M:%S-04:00")
        /usr/bin/echo $planned_end_date
      register: planned_end_date_result

    - name: Set planned end date fact
      ansible.builtin.set_fact:
        planned_end_date: "{{ planned_end_date_result.stdout }}"

    - name: Build server list from ansible inventory
      ansible.builtin.add_host:
        name: "{{ item }}"
      loop: "{{ groups['windows_servers'] | unique }}"
      changed_when: false

    - name: Set fact with HTML list of hosts
      ansible.builtin.set_fact:
        html_table: >
          <ul>
          {% for host in groups['windows_servers'] | unique %}
            <li>{{ host }}</li>
          {% endfor %}
          </ul>

    - name: Call Freshservice API to get CABs
      ansible.builtin.shell: |
        curl -s -u {{ freshservice_api_key }}:X -X GET "https://{{ freshservice_domain }}/api/v2/cabs" | jq '.cabs[] | select(.name == "{{ cab_name }}") | .id'
      register: cab_id

    - name: Show the returned CAB ID
      ansible.builtin.debug:
        msg: "CAB ID for 'IT CAB' is {{ cab_id.stdout }}"

    - name: Set POST Data for Change Request
      ansible.builtin.set_fact:
        change_payload:
          change:
            email: "{{ fs_requester_email }}"
            agent_id: "{{ agent_id }}"
            group_id: "{{ group_id }}"
            subject: "{{ lookup('pipe', \"date '+%B %Y'\") }} - Windows Server Patching Request"
            description: "Windows Servers Being Patched {{ html_table | join('') }}"
            risk: "{{ risk }}"
            impact: "{{ impact }}"
            priority: "{{ priority }}"
            change_type: "{{ change_type }}"
            status: "{{ status }}"
            category: "{{ category }}"
            requester_id: "{{ requester_id }}"
            department_id: "{{ department_id }}"
            planned_start_date: "{{ planned_start_date }}"
            planned_end_date: "{{ planned_end_date }}"

    - name: Create Patching Change Request
      ansible.builtin.uri:
        url: "https://{{ freshservice_domain }}/api/v2/changes"
        method: POST
        user: "{{ freshservice_api_key }}"
        password: "X"
        force_basic_auth: yes
        headers:
          Content-Type: "application/json"
        body_format: json
        body: "{{ change_payload }}"
        status_code: 201
      register: create_change_response

    - name: Getting the change number created
      ansible.builtin.set_fact:
        change_id: "{{ create_change_response.json.change.id }}"

    - name: Print the change ID
      ansible.builtin.debug:
        msg: "Change ID created is CHN-{{ change_id }}"

    - name: Get CAB Members for Approval
      ansible.builtin.uri:
        url: "https://{{ freshservice_domain }}/api/v2/cabs/{{ cab_id.stdout }}"
        method: GET
        user: "{{ freshservice_api_key }}"
        password: "X"
        force_basic_auth: yes
        headers:
          Content-Type: "application/json"
        return_content: yes
      register: cab_info

    - name: List the approver IDs being requested
      ansible.builtin.debug:
        msg: "Sending email approval request to user IDs - {{ approver_user_ids }}"

    - name: Cleanup the generated Windows updates file
      ansible.builtin.file:
        path: "/tmp/.latest_windows_patches.txt"
        state: absent

The below screenshot shows an example of what your FreshService Change Request will look like.

Example FreshService Change Request Created

The change includes:

  • Scheduled last Saturday of the month at 22:00 ET (6-hour maintenance window)
  • Full HTML list of hosts being patched
  • CAB approval workflow

With our Change Request now submitted, we need playbooks scheduled to execute the change.


Why Use a 3-Phase Approach?

My previous design was this:

  1. Phase 1 – Download all updates (async)
  2. Phase 2 – Install everything (Windows + .NET but not SQL) using async and (reboot: false)
  3. Phase 3 – Perform ordered reboots, wait for hosts to come back, then run win_updates again to pick up anything that required a reboot (SQL updates)

The great thing about this was Phase 2 could run with async + poll. Even if an admin RDP’d in and clicked "Check for updates", Windows would drop the existing session, but Ansible would simply reconnect it.

The problem – Microsoft now enforces reboot: true for Cumulative Updates At some point in 2023 Microsoft started rejecting Cumulative Update installation unless reboot: true is explicitly set on the win_updates module. This created issues.

  • reboot: true and async are mutually exclusive – you cannot use async when reboot: true is set.
  • Without async, a single slow or heavily outdated server can block the entire play for extended periods of time.
  • If an admin manually clicks "Check for updates" while the synchronous win_updates task is running, Windows aborts the Ansible session and the task fails if errors are not ignored (no automatic reconnection possible).

My new three-phase flow (forced by the above):

  1. Phase 1 – Download updates (still async)
  2. Phase 2 – In maintenance window, Install Windows updates with reboot: true → immediate reboot when updates require it (synchronous with high fork count, no async possible)
  3. Phase 3 – Install SQL Server updates (and any other post-reboot updates) and reboot again if required.

This new layout gives us back reliable SQL patching (SQL updates almost always need the OS reboot to have completed first) while accepting that Phase 2 is now slower and more fragile to manual administrator intervention – an unfortunate but unavoidable trade-off caused by Microsoft’s module restriction.

NOTE: This playbook does not perform any pre-patching tasks for Windows Servers that are clustered. If you have clustered servers you probably want to add database health checks to be performed as well as a strictly defined reboot order.

Phase Playbook Duration Purpose
1. Download download-windows-updates.yml 2 hours Download all approved updates in parallel as your bandwidth allows
2. Install Updates with Reboot install-windows-updates.yml Maintenance window Install Operating System updates and reboot to finish installing updates.
3. Install SQL and Missing Updates with Reboot finalize-windows-updates.yml Maintenance window Then install SQL and any still missing updates. Reboot again if needed.

Key Benefits of This Strategy

  • Speed: strategy: free + -f 50 = 50 servers patched simultaneously. Use more or less to optmize in your environment
  • Safety: No reboots or outages until the maintenance window
  • Visibility: Full update list logged before download
  • Resilience: (In the Download phase only) Async tasks with retries and polling
  • Auditability: Logs per host, per phase

Phase 1: Download Updates (2 Hours)

Run our first ansible playbook from the command line by doing:
ansible-playbook -i windows_inventory.yml download-windows-updates.yml -f 50

The "-f 50" means this will run against all 50 servers in the "windows_servers" group simultaneously. If we do not define this, the default value used is 5 which would take way too long to complete. Define as many forks as you are able without negatively impacting your bandwidth. This simply downloads updates so they are ready to install in the next phase. In my experience you need about 45 minutes for the playbook to finish downloading updates for each scope or fork grouping. Your environment may be different of course.


Fake Microsoft Updates

In the playbooks below notice I have a variable named "phantom_guids". The reason for this is a bug in the Microsoft API. The cache folders can return GUID values for updates that do not exist. The way to clear these out is to delete the "SoftwareDistribution" and "catroot2" directories and let the next WSUS scan rebuild the directory contents. Not accounting for these will impact reporting results, making it seem like servers are missing updates despite being fully patched. It can be simpler to just ignore the GUID values you come across to prevent false positives in your reporting. Add any new GUID values to the "phantom_guid" variable list in these playbooks to exclude them.

---
- name: Windows Server Download Updates Playbook
  hosts: windows_servers
  gather_facts: false
  any_errors_fatal: false
  strategy: free
  vars:
    skip_optional_updates: false
    phantom_guids:
      - "686561C1-487F-41E6-851E-343B499Cd77B"
      - "069a8283-ad7c-4a4c-9a0c-a77de1424c93"
  tasks:
    - name: Search for Approved Updates
      ansible.windows.win_updates:
        category_names:
          - SecurityUpdates
          - CriticalUpdates
          - UpdateRollups
          - DefinitionUpdates
          - ServicePacks
          - Application
          - Updates
          - SQL Server
          - '.NET Framework'
        state: searched
        skip_optional: "{{ skip_optional_updates | default(false) }}"
      register: search
      until: search is successful
      retries: 5
      delay: 60

    - name: Build Update KB List
      ansible.builtin.set_fact:
        accept_list: >-
          {{
            search.updates | default({})
            | dict2items
            | rejectattr('key', 'in', phantom_guids)
            | map(attribute='value.kb')
            | flatten
            | select
            | map('trim')
            | map('regex_replace', '^(KB)?0*(\d+)$', 'KB\2')
            | unique
            | list
          }}

    - name: Show what will be downloaded
      ansible.builtin.debug:
        msg: |
          {{ inventory_hostname }} → {{ accept_list | length }} real update(s) to download:
          {{ accept_list | default(['(none)']) | join(', ')

    - name: Downloading All Approved Updates
      ansible.windows.win_updates:
        state: downloaded
        accept_list: "{{ accept_list }}"
        log_path: "{{ update_log_path }}"
        skip_optional: "{{ skip_optional_updates | default(false) }}"
      register: dl
      when: accept_list | length > 0
      until: dl is successful
      retries: 5
      delay: 180
      async: 9000
      poll: 120

    - name: Show Download Results
      ansible.builtin.debug:
        msg: |
          {{ inventory_hostname }} - DOWNLOAD COMPLETE
          {% if accept_list | length == 0 %}
          Nothing to download (fully patched or only phantoms)
          {% else %}
          Downloaded {{ dl.found_update_count }} updates
          {% endif %}

Phase 2: Install Updates and Reboot

Run using the command-line:
ansible-playbook -i windows_inventory.yml install-windows-updates.yml -f 50

This will install the previously downloaded updates, excluding SQL Server updates and reboot the servers to complete their installation. SQL updates will be installed in the third phase after the Operating System updates complete. Oftentimes SQL updates will be unable to install until the Operating System is up to date. We want patching to be completed when our ansible tasks have executed, we do not want to have to run them all over again because SQL updates failed to install everywhere.

---
- name: Install All Non-SQL Approved Windows Server Updates Playbook
  gather_facts: false
  any_errors_fatal: false
  strategy: free
  hosts: windows_servers
  vars:
    skip_optional_updates: true
    update_log_path: C:\\Windows\\Logs\\ansible_wu.log
    phantom_guids:
      - "686561C1-487F-41E6-851E-343B499Cd77B"
      - "069a8283-ad7c-4a4c-9a0c-a77de1424c93"
  tasks:
    - name: Search for Available Updates (No SQL)
      ansible.windows.win_updates:
        category_names:
          - CriticalUpdates
          - SecurityUpdates
          - UpdateRollups
          - Updates
          - DefinitionUpdates
          - ServicePacks
          - Application
          - '.NET Framework'
        state: searched
      register: search
      until: search is successful
      retries: 5
      delay: 60

    - name: Build KB List (No SQL)
      ansible.builtin.set_fact:
        accept_list: >-
          {{
            search.updates | default({})
            | dict2items
            | rejectattr('key', 'in', phantom_guids)
            | map(attribute='value.kb')
            | flatten
            | select
            | map('trim')
            | map('regex_replace', '^(KB)?0*(\d+)$', 'KB\2')
            | unique
            | list
          }}

    - name: Install Approved Available Updates and Reboot (No SQL)
      ansible.windows.win_updates:
        state: installed
        accept_list: "{{ accept_list }}"
        reboot: true
        reboot_timeout: 3600
        log_path: "{{ update_log_path }}"
      register: install
      retries: 5
      delay: 300
      until: install is successful
      when: accept_list | length > 0

    - name: Show Install Results (No SQL)
      ansible.builtin.debug:
        msg: |
          {{ inventory_hostname }}
          • Updates installed: {{ install.found_update_count | default(0) }}


- name: Install All Updates and Reboot (Primary DC)
  hosts: primary_dc
  gather_facts: false
  strategy: linear
  serial: 1
  any_errors_fatal: true
  vars:
    phantom_guids:
      - "686561C1-487F-41E6-851E-343B499Cd77B"
      - "069a8283-ad7c-4a4c-9a0c-a77de1424c93"
  tasks:
    - name: Search for Approved Updates (Primary DC)
      ansible.windows.win_updates:
        category_names:
          - CriticalUpdates
          - SecurityUpdates
          - UpdateRollups
          - Updates
          - DefinitionUpdates
          - ServicePacks
          - Application
          - '.NET Framework'
        state: searched
      register: search
      until: search is successful
      retries: 5
      delay: 60

    - name: Build KB List (Primary DC)
      ansible.builtin.set_fact:
        accept_list: >-
          {{
            search.updates | default({})
            | dict2items
            | rejectattr('key', 'in', phantom_guids)
            | map(attribute='value.kb')
            | flatten
            | select
            | map('trim')
            | map('regex_replace', '^(KB)?0*(\d+)$', 'KB\2')
            | unique
            | list
          }}

    - name: Install Available Updates and Reboot (Primary DC)
      ansible.windows.win_updates:
        state: installed
        accept_list: "{{ accept_list }}"
        reboot: true
        reboot_timeout: 3600
        log_path: "{{ update_log_path }}"
      register: install
      retries: 5
      delay: 300
      until: install is successful
      when: accept_list | length > 0

    - name: Show Install Results (Primary DC)
      ansible.builtin.debug:
        msg: |
          PRIMARY DC {{ inventory_hostname }}
          • Updates installed: {{ install.found_update_count | default(0) }}

Phase 3: Install SQL patches and any Still Missing Updates

We also filter out phantom GUIDs via the phantom_guids variable to avoid false 'missing update' alerts.
Run using the command line:
ansible-playbook -i windows_inventory.yml finalize-windows-updates.yml -f 25

This will install any SQL patches and still missing updates with reboots when required. You are able to exclude servers from this run if that saves time for your environment by appending the "hosts:" line with ":!inventory_hostname_to_exclude". A separate inventory hostgroup can also be utilized.

---
- name: Install All Missing Updates with Reboot Playbook
  hosts: windows_servers:!primary_dc:!tertiary_dc
  gather_facts: false
  any_errors_fatal: false
  strategy: free
  vars:
    skip_optional_updates: false
    update_log_path: C:\\Windows\\Logs\\ansible-win-updates.log
    phantom_guids:
      - "686561C1-487F-41E6-851E-343B499Cd77B"
      - "069a8283-ad7c-4a4c-9a0c-a77de1424c93"
  tasks:
    - name: Search for SQL and Leftover Updates
      ansible.windows.win_updates:
        category_names:
          - CriticalUpdates
          - SecurityUpdates
          - UpdateRollups
          - Updates
          - DefinitionUpdates
          - ServicePacks
          - SQL Server
          - Application
          - '.NET Framework'
        state: searched
        skip_optional: "{{ skip_optional_updates }}"
      register: sql_search
      until: sql_search is successful
      retries: 5
      delay: 60

    - name: Build KB List
      ansible.builtin.set_fact:
        accept_list: >-
          {{
            search.updates | default({})
            | dict2items
            | rejectattr('key', 'in', phantom_guids)
            | map(attribute='value.kb')
            | flatten
            | select
            | map('trim')
            | map('regex_replace', '^(KB)?0*(\d+)$', 'KB\2')
            | unique
            | list
          }}

    - name: Show Updates About to Install
      ansible.builtin.debug:
        msg: >-
          {{ inventory_hostname }}:
          {% if accept_list | length == 0 %}
          No updates to install
          {% else %}
          {{ accept_list | length }} updates to install:
          {% for kb in accept_list %}
          • {{ kb }}
          {% endfor %}
          {% endif %}

    - name: Install SQL Updates and Leftover Updates
      ansible.windows.win_updates:
        state: installed
        accept_list: "{{ accept_list }}"
        skip_optional: "{{ skip_optional_updates | default(false) }}"
        reboot: true
        reboot_timeout: 3600
        log_path: "{{ update_log_path }}"
      register: sql_install
      retries: 5
      delay: 300
      until: sql_install is successful
      when: accept_list | length > 0

    - name: Show Install Results
      ansible.builtin.debug:
        msg: >-
          {{ inventory_hostname }}:
          • Final updates installed: {{ sql_install.found_update_count | default(0) }}

Clear Out Phantom Updates

If you have completed patching and wish to clear out the phantom updates you can run the below playbook on any servers that need it. This playbook can also be used to repair issues when Windows Updates fail to install.

---
- name: Rebuild Windows Update Cache (Clear Phantom Updates)
  hosts: windows_servers
  gather_facts: false
  vars:
    reboot_timeout: 600
    service_stop_timeout: 120
    rename_ext: "_old_{{ ansible_date_time.iso8601_basic_short }}"
    folder_renames:
      - name: Catroot2
        src: C:\Windows\System32\catroot2
        dest: C:\Windows\System32\catroot2{{ rename_ext }}
      - name: SoftwareDistribution
        src: C:\Windows\SoftwareDistribution
        dest: C:\Windows\SoftwareDistribution{{ rename_ext }}
    update_services:
      - { name: wuauserv, display: "Windows Update" }
      - { name: BITS, display: "Background Intelligent Transfer Service" }
      - { name: CryptSvc, display: "Cryptographic Services" }
      - { name: msiserver, display: "Windows Installer" }

  tasks:
    - name: Stop Windows Update services with retry
      ansible.windows.win_service:
        name: "{{ item.name }}"
        state: stopped
        timeout: "{{ service_stop_timeout }}"
      loop: "{{ update_services }}"
      loop_control:
        label: "{{ item.display }} ({{ item.name }})"
      register: service_stop_result
      retries: 3
      delay: 10
      until: service_stop_result is succeeded
      ignore_errors: false

    - name: Ensure old renamed folders do not exist (cleanup previous runs)
      ansible.windows.win_file:
        path: "{{ item.dest }}"
        state: absent
      loop: "{{ folder_renames }}"
      loop_control:
        label: "Cleanup {{ item.name }} old folder"
      ignore_errors: yes

    - name: Rename update cache folders
      ansible.windows.win_file:
        src: "{{ item.src }}"
        dest: "{{ item.dest }}"
        state: moved
      loop: "{{ folder_renames }}"
      loop_control:
        label: "{{ item.src }} → {{ item.dest }}"
      register: rename_result
      failed_when: rename_result.failed and 'already exists' not in rename_result.msg | default('')

    - name: Set fact - reboot required if any folder was renamed
      ansible.builtin.set_fact:
        reboot_required: true
      when: rename_result.results | selectattr('changed', 'equalto', true) | list | length > 0

    - name: Reboot if cache folders were renamed
      ansible.windows.win_reboot:
        msg: "Rebooting to regenerate Windows Update cache directories."
        reboot_timeout: "{{ reboot_timeout }}"
        post_reboot_delay: 30
        connect_timeout: 300
      when: reboot_required | default(false)

    - name: Start Windows Update services after reboot
      ansible.windows.win_service:
        name: "{{ item.name }}"
        state: started
      loop: "{{ update_services }}"
      loop_control:
        label: "Start {{ item.display }}"
      when: reboot_required | default(false)

    - name: Wait for SoftwareDistribution and catroot2 to be recreated
      ansible.windows.win_stat:
        path: "{{ item.src }}"
      loop: "{{ folder_renames }}"
      register: folder_check
      until: folder_check.stat.exists
      retries: 6
      delay: 20
      when: reboot_required | default(false)

    - name: Confirm new folders are present
      ansible.builtin.debug:
        msg: "✓ {{ item.item.name }} recreated at {{ item.item.src }}"
      loop: "{{ folder_check.results }}"
      when: 
        - reboot_required | default(false)
        - item.stat.exists

  post_tasks:
    - name: Reminder - Old folders can be deleted after validation
      ansible.builtin.debug:
        msg: |
          Old folders renamed with suffix: {{ rename_ext }}
          You may delete them after confirming updates work:
          {% for item in folder_renames %}
          - {{ item.dest }}
          {% endfor %}
      when: reboot_required | default(false)

When to Use Multiple Tasks with Separate Forks

While -f sets a global fork limit for the entire playbook, some tasks benefit from different parallelism levels. Use forks per task when:

Task Type Recommended Forks Why
Download Updates forks: 100 High bandwidth, low CPU. Maximize network utilization.
Install Updates forks: 30 High CPU/disk I/O. Prevent server overload.
Reboot Servers forks: 10 Controlled outage. Avoid network storm or monitoring flood.

Use Cases for Per-Task Forks

  • Mixed workloads: Some tasks are I/O-bound, others CPU-bound
  • Resource isolation: Prevent one task from starving others
  • Staggered rollouts: Perform tasks in waves (e.g., 10 at a time)
  • Monitoring integration: Schedule Downtime and disable Event Handlers to avoid overwhelming Nagios/PRTG/Zabbix with 500 reboots at once

Establish Time Periods for a Pretend Environment

Keep in mind that SQL updates are released once a quarter not once a month. Your most accurate results for testing are going to occur in a month SQL updates are released.

Knowing the above information, pretend we have 200 Windows servers in our environment. In my experience estimating 45 minutes per fork grouping will achieve accurate estimations for your server count. Lets be frugal and use groups of 70 forks/servers to run update tasks simultaneously on at the end of a normal workday. 200 servers divided by three 45 minute windows is 2 hours and 15 minutes. Lets call it an even 2.5 hours for our Download phase. That equates to 2.5 hours of higher bandwidth usage before the maintenance window where we will plan to disrupt services and operation with reboots.

Lets now schedule our 3 Phase Playbook Executions:

  1. 7:30 PM: download-windows-updates.yml -f 70 → 2.5 hours until the install task
  2. 10:00 PM (Maintenance Window): install-windows-updates.yml -f 70 → determine how long this task takes to complete
  3. 12:00 PM (Maintenance Window): finalize-windows-updates.yml -f 70 → execute this manually the first time so you know how long after the above task to schedule this one.

Adjustment Logic:
On an actual patching night, watch how long each task takes to complete. Maybe it finishes in less time or you still have plenty of bandwidth on your ISP connection to add more simultaneous connections. See if you can lengthen or condense your scheduled playbook execution times.

Review your reboot logic. Split your inventory into separate groups based on risk and criticality. Let stage 1 contain non-critical servers and stage 2 contain servers with critical functionality.


Forks vs. Job Slicing

If you determine that running against ~70 servers at once exceeds your available network bandwidth, you may need to adjust how your automation is executed.

Forks:
Forks control how many hosts Ansible attempts to run tasks against simultaneously within a single playbook run. If you're using Red Hat Ansible Automation Platform (AAP), check the Instance configuration for each node type.
Your control node may have a maximum fork limit (for example, 68), while your execution node may support a higher limit (for example, 168).
These limits determine which node should run your job based on the concurrency it can handle.

  • You may run with 68 forks on the control node
  • And run a second parallel job on an execution node using 168 forks, if needed

Job Slicing:
Job Slicing is used when you need to divide a large inventory into multiple, smaller, parallel playbook runs.
Instead of increasing concurrency (forks) within a single job, slicing breaks the workload into multiple distributed jobs.

How it works:

  • Enable slicing by setting job_slice_count on the Job Template (e.g., 3)
  • Example: 200 hosts sliced into 3 slices ≈ 67 hosts per slice
  • Each slice runs as an independent, parallel ansible-playbook run
  • AAP schedules slices across available cluster nodes for load balancing and better resource utilization
  • The result is a workflow-like execution, not a single monolithic job

When to Use Which?

Situation Use Forks Use Job Slicing
You want to tune concurrency inside a single job ✔️
You exceed controller/execution node fork limits ✔️
You have multiple nodes available and want to distribute load ✔️
Network bandwidth limits prevent high concurrency ✔️ (lower forks) ✔️ (smaller slices reduce per-job load)
You need faster total runtime across large inventories Maybe ✔️

Simple Guideline

  • Start by adjusting forks to match system and network capacity.
  • If forks alone are not enough — due to bandwidth, node limits, or cluster scaling — use job slicing to break the workload into parallel jobs.

Why strategy: free and -f?

  • strategy: free: Tasks run as fast as possible — no waiting for slowest host
  • -f 70: 70 parallel forks = 70 servers downloading/installing at once
  • Result: 70 servers patched in ~2 hours instead of 70+ hours

Final Thoughts

This three-phase, parallel, scheduled patching strategy gives you:

  • Speed with strategy: free and dynamic forks
  • Safety with no reboots until maintenance
  • Control with full visibility into every update
  • Reliability with retries, async, and logging

Automate it. Schedule it. Sleep through Patch Tuesday. Well maybe not sleep, you still need to respond if errors occur but you can maybe put your feet up most nights.


Tags: Ansible, Windows Patching, Automation, DevOps, Semaphore, AAP, strategy free, forks per task, Azure Arc

Published: November 15, 2025
Author: Robert H. Osborne

🛸