# Windows Server Patching with Ansible

Date: 2025-11-20

Windows patching through Ansible gets dismissed as complicated, then adopted by teams who realise the alternative is RDP sessions, manual Windows Update clicks, and no audit trail. WinRM is not SSH, and the setup has sharper edges—but once it is correctly configured and locked down, the automation model is identical to Linux: one playbook, rolling batches, post-reboot validation.

<!--more-->

## WinRM vs SSH

Ansible supports both WinRM (the traditional path) and OpenSSH (available since Windows Server 2019). For most enterprise environments WinRM is already present; OpenSSH is the cleaner long-term choice if you control the server builds.

| | WinRM | OpenSSH |
|---|---|---|
| **Available** | Windows Server 2008+ | Windows Server 2019+ |
| **Default port** | 5985 (HTTP) / 5986 (HTTPS) | 22 |
| **Auth options** | Basic, Kerberos, NTLM, Certificate | Key-based, password |
| **Encryption** | Requires HTTPS or message-level encryption | Always encrypted |
| **Recommendation** | Use HTTPS (5986) only | Preferred on modern builds |

---

## WinRM Setup on the Target (Run Once Per Host)

Run this on each Windows Server target in an elevated PowerShell session:

```powershell
# Enable and configure WinRM
winrm quickconfig -q

# Create a self-signed certificate for HTTPS (replace with a proper cert in production)
$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 HTTPS
New-NetFirewallRule -DisplayName "WinRM HTTPS" -Direction Inbound -LocalPort 5986 -Protocol TCP -Action Allow

# Set the authentication method
Set-Item WSMan:\localhost\Service\Auth\Basic -Value $true   # only if not using Kerberos/NTLM
```

For domain-joined servers, use Kerberos authentication instead of Basic—it doesn't transmit credentials over the wire.

---

## Security Warning: WinRM HTTP Is Not Safe

The most common WinRM misconfiguration is leaving HTTP (port 5985) open on the network with Basic authentication enabled. This transmits credentials in Base64 (effectively cleartext) and has been exploited in lateral movement attacks.

**Minimum safe configuration:**

- HTTPS only (port 5986, valid certificate)
- Basic auth disabled unless you have no Kerberos option
- WinRM firewall rule restricted to the Ansible control node IP
- AllowUnencrypted set to false:

```powershell
Set-Item WSMan:\localhost\Service\AllowUnencrypted -Value $false
```

---

## Required Collection

Install the `ansible.windows` collection before running any Windows playbooks:

```shell
ansible-galaxy collection install ansible.windows
```

Add it to `requirements.yml`:

```yaml
collections:
  - name: ansible.windows
    version: ">=2.0.0"
```

---

## Inventory for Windows Hosts

```yaml
# inventory/production/hosts.yml
all:
  children:
    windows_servers:
      hosts:
        win01.example.com:
        win02.example.com:
      vars:
        ansible_connection: winrm
        ansible_winrm_transport: ntlm          # or kerberos for domain-joined hosts
        ansible_winrm_scheme: https
        ansible_port: 5986
        ansible_winrm_server_cert_validation: validate   # set to ignore only in lab
        ansible_user: "DOMAIN\\ansible_svc"
        ansible_password: "{{ vault_windows_password }}"
```

---

## Core Modules: win_updates and win_reboot

**Apply all critical and security updates:**

```yaml
---
- name: Patch Windows servers
  hosts: windows_servers
  serial: 5

  tasks:
    - name: Install security and critical updates
      ansible.windows.win_updates:
        category_names:
          - SecurityUpdates
          - CriticalUpdates
        state: installed
        reboot: false           # handle reboot explicitly below
      register: update_result

    - name: Show update summary
      ansible.builtin.debug:
        msg: >
          {{ inventory_hostname }}: {{ update_result.installed_update_count }} updates installed,
          reboot required: {{ update_result.reboot_required }}

    - name: Reboot if required
      ansible.windows.win_reboot:
        reboot_timeout: 600
        post_reboot_delay: 30
      when: update_result.reboot_required | bool
```

---

## Update Categories

The `category_names` parameter maps to Windows Update categories:

| Category | Description |
|---|---|
| `SecurityUpdates` | Microsoft Security Response Center patches |
| `CriticalUpdates` | Non-security critical fixes |
| `UpdateRollups` | Cumulative rollup packages |
| `Updates` | General product updates |
| `DefinitionUpdates` | Defender / AV signature updates |
| `Drivers` | Hardware driver updates (usually excluded from server patching) |

For patch management, start with `SecurityUpdates` and `CriticalUpdates`. Add `UpdateRollups` for monthly cumulative patching cycles.

---

## Filtering to Specific KBs

```yaml
- name: Install a specific KB only
  ansible.windows.win_updates:
    whitelist:
      - KB5034441
    state: installed
    reboot: false
```

```yaml
- name: Exclude a known-bad KB
  ansible.windows.win_updates:
    category_names:
      - SecurityUpdates
    blacklist:
      - KB5034441   # blocked pending vendor compatibility test
    state: installed
    reboot: false
```

---

## Handling Long-Running Updates and Retries

Some cumulative updates take 20–40 minutes. Set `reboot_timeout` generously and implement retries for the update task:

```yaml
- name: Install updates with retry
  ansible.windows.win_updates:
    category_names:
      - SecurityUpdates
      - CriticalUpdates
    state: installed
    reboot: false
  register: update_result
  retries: 3
  delay: 60
  until: update_result is not failed

- name: Reboot with extended timeout
  ansible.windows.win_reboot:
    reboot_timeout: 1800      # 30 minutes for slow cumulative updates
    post_reboot_delay: 60
    test_command: whoami
  when: update_result.reboot_required | bool
```

---

## Post-Patch Service Validation

```yaml
- name: Verify critical Windows services are running
  ansible.windows.win_service:
    name: "{{ item }}"
    state: started
  loop:
    - W32Time
    - WinRM
    - EventLog
    - Dnscache
  register: svc_check

- name: Fail if services did not recover
  ansible.builtin.fail:
    msg: "{{ item.name }} is not running on {{ inventory_hostname }}"
  loop: "{{ svc_check.results }}"
  when: item.state != "running"
```

---

## Scheduling vs On-Demand

For scheduled patching, integrate with the CI/CD pipeline patterns from part four—a cron-triggered GitHub Actions or GitLab CI job that targets the `windows_servers` group on a defined cadence. On-demand patching (emergency CVE response) uses the same playbook with `--limit` to scope to affected hosts only:

```shell
ansible-playbook -i inventory/production/hosts.yml playbooks/patch-windows.yml \
  --limit "win01.example.com,win02.example.com" \
  --tags security
```

---

**Previous:** [Linux Server Patching with Ansible](/ansible/devops/2025/11/17/ansible-linux-server-patching.html)

**Next in the series:** [Day-to-Day Automation and Reporting](/ansible/devops/2025/11/24/ansible-automation-reporting.html) — scheduling with AWX, compliance reporting, SIEM integration, and drift detection.
