Ansible Lesson 27 of 42

Ansible for Windows, In Depth: WinRM, Kerberos, ansible.windows, community.windows & win_dsc

In a nutshell

Imagine you already speak fluent Spanish (Linux + SSH) and you inherit a team that only speaks German (Windows). You do not need to learn a second grammar from scratch — you keep the same interpreter (Ansible) you already trust and teach it to route German through a different phone line. That phone line is WinRM instead of SSH, the interpreter shows its ID at the door using Kerberos instead of an SSH key, and once inside it speaks PowerShell instead of Bash. The playbooks, roles, inventory, variables, handlers, and idempotent mindset you already know are identical. Only the transport and the identity handshake change.

So the whole lesson is really four swaps: SSH → WinRM (the wire), SSH keys → Kerberos/NTLM/CredSSP/certs (the identity), Python → PowerShell (the on-host engine), and shell idempotence → DSC-backed idempotence (the optional convergence layer). Nail those four and a Linux engineer can manage 5,000 Windows servers from one RHEL box with no agent installed anywhere.

If you take one thing away: authentication is 90% of the work, and Kerberos is the answer for anything domain-joined. Everything else — copying files, installing IIS, disabling SMBv1 — is just picking the right win_* module and letting Ansible ship a PowerShell script over the WinRM wire.

Level: Advanced · Time: ~45 min · You’ll need: playbook fluency, one Windows Server 2019/2022 lab host, and a Linux control node.

Linux engineers often inherit Windows fleets they did not ask for: a few hundred file servers, a forest of domain controllers, IIS hosts, SQL boxes, and the inevitable jump servers. The good news is that Ansible — yes, the same Ansible you use for Linux — drives Windows extremely well. The bad news is that getting from “I can SSH to my Linux box” to “I can run a playbook against a domain-joined Windows Server 2022 with Kerberos auth and CredSSP delegation” involves real concepts: WinRM as the wire protocol, Kerberos as the identity layer, PowerShell as the execution engine, and DSC as the optional idempotence backend.

This lesson covers all of it the way EX374 and senior Windows-on-Ansible interviews actually grade it: WinRM listeners and authentication mechanisms, Kerberos vs NTLM vs Basic, the ansible.windows and community.windows module families, win_dsc as a generic bridge to any DSC resource, hardening WinRM-over-HTTPS with proper certificates, and patterns for credential management (gMSA, become, runas) on Windows from a Linux control node.

Learning Objectives

By the end you will be able to:

Prerequisites

Mental Model: How Ansible Talks to Windows

Five concepts unlock everything else.

1. WinRM is “SSH for Windows” — but it’s HTTP

WinRM (Windows Remote Management) is Microsoft’s implementation of WS-Management. From Ansible’s perspective it serves the same role as SSH: a remote shell, file transfer, and a stable transport for executing modules. But it is HTTP under the hood — listening on TCP/5985 (HTTP) or TCP/5986 (HTTPS) — and it speaks SOAP-over-HTTP to a Windows server’s WSMan listener. The control node uses the Python pywinrm library to speak this protocol.

2. Authentication is the hard part — pick once, configure forever

WinRM supports five auth mechanisms: Basic (cleartext password, dev-only), NTLM (works for local accounts and workgroup machines), Kerberos (the only sane choice for domain-joined fleets), CredSSP (required for the “double-hop” problem when your playbook needs to access a fileshare or another server from inside a target host), and Certificate (mTLS with a client cert, useful for fully unattended automation). Pick one per inventory group and configure it consistently — mixing auth modes per host is the #1 cause of “it worked yesterday” outages.

3. PowerShell is the execution engine — modules ship as .ps1

When you run ansible win01 -m ansible.windows.win_service -a "name=Spooler state=stopped", Ansible serializes a small bootstrap PowerShell script, ships it via WinRM, and executes it inside powershell.exe on the target. Each Windows module is a .ps1 file that returns JSON (just like Linux modules return JSON from Python). This is why ansible_python_interpreter doesn’t matter on Windows — the target doesn’t need Python; it needs PowerShell 5.1 or 7.x.

4. win_dsc is the universal idempotence escape hatch

Microsoft DSC (Desired State Configuration) ships with hundreds of resources covering everything from IIS to SQL Server to Active Directory. Rather than write an Ansible module for each of these, the ansible.windows.win_dsc module wraps any DSC resource as an Ansible task. If something is missing from ansible.windows or community.windows, it is almost certainly available as a DSC resource — and win_dsc brings it into your playbook with full idempotence and check-mode support.

5. The Linux control node never installs anything on Windows

Unlike Group Policy or SCCM, Ansible doesn’t deploy an agent on Windows. The only requirement on the target is that WinRM is configured and PowerShell 5.1+ is present (every supported Windows Server has both out of the box). This means you can manage 5,000 Windows servers from a single RHEL VM with no agent fleet to maintain — but it also means every authentication problem is a configuration problem on the target’s WinRM, not an “agent not phoning home” problem.

Ansible vs Group Policy, SCCM, and standalone DSC

Windows admins already own configuration tools, so before writing a single playbook it is worth being honest about where Ansible fits — because “but we already have GPO” is the first objection you will hear. These tools are not mutually exclusive; the mature answer is usually “GPO for the domain-wide security floor, Ansible for orchestration and everything imperative or cross-platform.”

Tool Model Push / Pull Idempotent Best at Weakness
Group Policy (GPO) Declarative, AD-scoped Pull (gpupdate) Yes (reapplies on refresh) Domain-wide security baselines, user & machine settings Domain-joined only; slow feedback loop; no cross-host ordering; hard to unit-test
SCCM / MECM Imperative + packaged Pull (agent) Partial Massive patch & software distribution Heavy agent + site infra; costly; Windows-only
PowerShell DSC (standalone) Declarative Pull (LCM) or push Yes Per-host desired state, drift correction Drift returns if LCM is off; MOF authoring is clunky; weak multi-host orchestration
Ansible Declarative tasks, imperative ordering Push (WinRM/PSRP/SSH) Yes (per module) Orchestrating mixed Linux+Windows fleets, ad-hoc and repeatable runs, cross-tier workflows Needs WinRM/SSH reachability; no continuous enforcement between runs

The key distinction: GPO and DSC continuously enforce state (good for drift, bad for “do this exact sequence right now”), while Ansible runs when you tell it to and orchestrates across a whole fleet in one ordered pass. win_dsc even lets you keep your DSC resources and drive them on Ansible’s schedule — you get DSC’s convergence guarantee with Ansible’s orchestration and reporting.

Setting Up the Linux Control Node

The control node needs three Python libraries beyond Ansible itself:

# Install ansible-core and Windows-specific dependencies
python3 -m pip install --user 'ansible-core>=2.16' 'pywinrm[kerberos]' 'pykrb5'

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

pywinrm is the Python WinRM client. The [kerberos] extra pulls in pykrb5 and the GSSAPI bindings needed to do Kerberos auth. On RHEL 9 you also need krb5-workstation and krb5-devel for the system Kerberos client.

# RHEL 9 / Rocky / Alma
sudo dnf install -y krb5-workstation krb5-devel python3-devel gcc

# Ubuntu 22.04
sudo apt-get install -y krb5-user libkrb5-dev python3-dev gcc

If you plan to use Kerberos auth (you should, for domain-joined targets) edit /etc/krb5.conf and add your AD realm:

[libdefaults]
default_realm = CORP.EXAMPLE.COM
dns_lookup_realm = false
dns_lookup_kdc = true

[realms]
CORP.EXAMPLE.COM = {
    kdc = dc01.corp.example.com
    admin_server = dc01.corp.example.com
}

[domain_realm]
.corp.example.com = CORP.EXAMPLE.COM
corp.example.com = CORP.EXAMPLE.COM

Test the Kerberos config with kinit administrator@CORP.EXAMPLE.COM — if you get a TGT back without errors, your control node can talk to AD.

Setting Up a Windows Target — WinRM-over-HTTPS

The default WinRM listener (HTTP/5985) accepts only NTLM/Kerberos and is acceptable for domain-joined hosts behind a firewall. For everything else, configure HTTPS/5986 with a real certificate.

Option A: Quick start with self-signed (lab only)

Microsoft maintains a ConfigureRemotingForAnsible.ps1 script that creates a self-signed cert and an HTTPS listener:

# Run on the Windows target, as Administrator
$url = "https://raw.githubusercontent.com/ansible/ansible-documentation/devel/examples/scripts/ConfigureRemotingForAnsible.ps1"
$file = "$env:TEMP\ConfigureRemotingForAnsible.ps1"
Invoke-WebRequest -Uri $url -OutFile $file
powershell.exe -ExecutionPolicy ByPass -File $file -CertValidityDays 3650 -ForceNewSSLCert -EnableCredSSP

This sets up WinRM on 5986 with a self-signed cert. Adequate for a lab, never for production — the control node will have to set ansible_winrm_server_cert_validation: ignore, which defeats the purpose of HTTPS.

Option B: Production-grade with a real certificate

In production, use a certificate issued by your enterprise CA (or ACME via win-acme). The cert’s Subject CN or SAN must match the FQDN your inventory uses.

# On the target, after you've imported a real cert into LocalMachine\My
$thumbprint = (Get-ChildItem Cert:\LocalMachine\My |
    Where-Object {$_.Subject -like "*CN=win01.corp.example.com*"}).Thumbprint

# Create the HTTPS listener
winrm create winrm/config/Listener?Address=*+Transport=HTTPS `
    "@{Hostname=`"win01.corp.example.com`";CertificateThumbprint=`"$thumbprint`"}"

# Disable HTTP listener
winrm delete winrm/config/Listener?Address=*+Transport=HTTP

# Disable unencrypted (defense-in-depth)
winrm set winrm/config/service '@{AllowUnencrypted="false"}'

# Open Windows Firewall for 5986
New-NetFirewallRule -DisplayName "WinRM HTTPS" -Direction Inbound -Protocol TCP `
    -LocalPort 5986 -Action Allow -RemoteAddress 10.0.0.0/8

The -RemoteAddress scoping is critical — only allow your control nodes’ subnet, never 0.0.0.0/0.

Inventory & Connection Variables

A Windows-only inventory:

# inventories/prod/hosts.yml
all:
  children:
    windows_servers:
      hosts:
        win01.corp.example.com:
        win02.corp.example.com:
        win03.corp.example.com:
      vars:
        ansible_connection: winrm
        ansible_port: 5986
        ansible_winrm_transport: kerberos
        ansible_winrm_scheme: https
        ansible_winrm_server_cert_validation: validate
        ansible_user: "ansible-svc@CORP.EXAMPLE.COM"

Note ansible_user uses UPN format (user@REALM) for Kerberos. NTLM would use DOMAIN\user.

If you must use NTLM (workgroup hosts, no AD), it looks like:

ansible_winrm_transport: ntlm
ansible_user: "Administrator"
ansible_password: "{{ vault_admin_password }}"

Running tasks as another user: become and runas on Windows

On Linux, become means sudo. On Windows there is no sudo — the equivalent is runas, and Ansible ships a dedicated become plugin for it (become_method: runas). You reach for become on Windows for three distinct reasons, and none of them are “just to be admin”:

  1. Escape the WinRM privilege sandbox. A WinRM session runs under a network logon token, which deliberately strips some privileges and, crucially, carries no cached credentials — that is the root of the double-hop wall. Running a task under become performs a fresh interactive/batch logon with the full privilege set.
  2. Run as a different identitySYSTEM, a service account, or a gMSA — without changing the connection user.
  3. Reach the user profile / registry hive that only exists once an interactive logon has occurred.
- name: Run a task as the local SYSTEM account (no password needed)
  ansible.windows.win_whoami:
  become: true
  become_method: runas
  become_user: SYSTEM

- name: Run an installer as a domain service account
  ansible.windows.win_package:
    path: C:\pkgs\app.msi
    state: present
  become: true
  become_method: runas
  become_user: "CORP\\deploy-svc"
  vars:
    ansible_become_password: "{{ vault_deploy_svc_password }}"

- name: Run as a gMSA (AD supplies the password, so you omit it)
  ansible.windows.win_shell: whoami /all
  become: true
  become_method: runas
  become_user: "CORP\\web-gmsa$"
  become_flags: "logon_type=service"

Three things to internalise:

You can set ansible_become_method: runas once at the inventory-group level so every play inherits it.

The ansible.windows Collection — Module-by-Module

ansible.windows is the supported core Windows collection. It covers ~50 modules. The most-used ones, grouped by purpose:

File and template management

Module Purpose Key parameters
win_copy Copy files from controller to Windows src, dest, force, backup
win_file Manage paths, dirs, attributes path, state (file/directory/absent/touch)
win_template Render Jinja2 templates to Windows src, dest, backup, newline_sequence
win_acl NTFS / registry / share ACLs path, user, rights, type, state
win_owner Take ownership of a file/dir path, user, recurse
win_share Manage SMB shares name, path, full, read, state

win_template defaults to newline_sequence: '\r\n' (Windows line endings) — leave that alone unless you’re rendering a file that must have Unix endings.

Services and Scheduled Tasks

Module Purpose Key parameters
win_service Manage Windows services name, state, start_mode, username, password
win_service_info Read service config (gather facts) name (optional, returns all if omitted)
win_scheduled_task (community) Manage scheduled tasks name, actions, triggers, username, state
- name: Ensure print spooler is disabled (CIS hardening)
  ansible.windows.win_service:
    name: Spooler
    state: stopped
    start_mode: disabled

Packages and Features

Module Purpose Key parameters
win_feature Install Windows roles/features name, state, include_management_tools
win_package Install MSI/EXE installers path, product_id, state, arguments
win_chocolatey (community) Chocolatey package manager name, state, version, source
win_psmodule (community) PowerShell Gallery modules name, state, repository
win_updates Windows Update category_names, state, reboot
- name: Install IIS with management tools
  ansible.windows.win_feature:
    name:
      - Web-Server
      - Web-Mgmt-Tools
      - Web-Asp-Net45
    state: present
    include_management_tools: true
  register: iis_install

- name: Reboot if required
  ansible.windows.win_reboot:
  when: iis_install.reboot_required

Users, Groups, and Local Security

Module Purpose Key parameters
win_user Local user accounts name, password, state, groups, password_never_expires
win_group Local groups name, description, state
win_group_membership Add/remove members in a group name, members, state
win_user_right Local user rights (sec policy) name (e.g. SeServiceLogonRight), users, action
win_security_policy Local security policy editor section, key, value

Registry, Environment, and System Tweaks

Module Purpose Key parameters
win_regedit Manage registry keys/values path, name, data, type, state
win_environment Environment variables name, value, level (machine/user)
win_path Manage PATH entries elements, state, scope
win_hosts Manage hosts file state, ip_address, canonical_name, aliases
win_dns_client DNS client settings adapter_names, dns_servers, log_path
- name: Disable SMBv1 (CVE remediation)
  ansible.windows.win_regedit:
    path: HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters
    name: SMB1
    data: 0
    type: dword
    state: present

Command Execution

Module Purpose Key parameters
win_command Run a single .exe (no shell parsing) cmd (or free-form), chdir, creates, removes
win_shell Run via PowerShell (pipes, redirection) cmd, executable, chdir, creates
win_powershell First-class PowerShell with structured I/O script, parameters, executable_type
win_dsc Run any DSC resource resource_name, plus DSC resource params

win_powershell (added in ansible.windows 1.5+) is the modern way to run PowerShell — it returns structured output, host_out, error, verbose, etc., separately:

- name: Get top 5 processes by working set
  ansible.windows.win_powershell:
    script: |
      param([int]$Top = 5)
      Get-Process | Sort-Object -Property WS -Descending |
        Select-Object -First $Top Name, Id, WS |
        ConvertTo-Json
    parameters:
      Top: 10
  register: procs

- debug:
    var: procs.output

The community.windows Collection — Higher-level Modules

community.windows extends ansible.windows with hundreds of modules for IIS, AD, Hyper-V, Chocolatey, scheduled tasks, certificate management, DSC, and more. The most-used ones:

Module Purpose
win_scheduled_task Create/update scheduled tasks (most-used module in this collection)
win_iis_website, win_iis_webbinding, win_iis_webapplication, win_iis_webapppool, win_iis_virtualdirectory Full IIS lifecycle
win_chocolatey, win_chocolatey_source, win_chocolatey_config Chocolatey package management
win_domain, win_domain_controller, win_domain_user, win_domain_group, win_domain_membership, win_domain_object_info, win_domain_ou Active Directory
win_certificate_store, win_certificate_info Certificate store management
win_xml, win_lineinfile, win_inifile Text-file manipulation
win_hyperv_* Hyper-V VM lifecycle (less common since Azure took over)
win_firewall, win_firewall_rule Windows Firewall config
win_pssession_configuration Custom PowerShell session configurations
- name: Create a daily backup scheduled task
  community.windows.win_scheduled_task:
    name: "Daily DB Backup"
    description: "Backs up the local SQL DB to a fileshare"
    actions:
      - path: powershell.exe
        arguments: "-NoProfile -File C:\\scripts\\backup.ps1"
    triggers:
      - type: daily
        start_boundary: "2025-01-01T02:00:00"
    username: "CORP\\sql-backup-svc"
    logon_type: password
    password: "{{ vault_sql_backup_password }}"
    state: present

win_dsc — The Universal Bridge

DSC ships hundreds of community modules: xWebAdministration, xActiveDirectory, SqlServerDsc, NetworkingDsc, ComputerManagementDsc, plus your own custom DSC resources. ansible.windows.win_dsc lets you call any of them as a regular Ansible task with full idempotence and check-mode support.

- name: Ensure xWebAdministration is installed
  community.windows.win_psmodule:
    name: xWebAdministration
    state: present

- name: Configure an IIS site via DSC
  ansible.windows.win_dsc:
    resource_name: xWebSite
    Name: "Default Web Site"
    PhysicalPath: "C:\\inetpub\\wwwroot"
    State: Started
    BindingInfo:
      - Protocol: https
        Port: 443
        CertificateThumbprint: "{{ iis_cert_thumbprint }}"
        CertificateStoreName: "MY"

The shape of win_dsc is mechanical: resource_name is the DSC resource (capitalization matters), and every other key is a property of that DSC resource. Get-DscResource -Name xWebSite -Syntax on the target will print the full schema.

When win_dsc shows changed: false, it ran DSC’s Test-TargetResource and the resource is in the desired state. When it shows changed: true, it ran Set-TargetResource to converge. This is true idempotence — no “did the task run? maybe?” guessing.

Beyond WinRM: PSRP and SSH-on-Windows

WinRM is the default, but it is not the only way in. Two alternatives matter once you are past the basics.

PSRP — PowerShell Remoting Protocol

PSRP (ansible_connection: psrp) rides the same WinRM listener and ports (5985/5986) and uses the same auth mechanisms, but speaks Microsoft’s richer PowerShell Remoting Protocol (MS-PSRP) instead of raw WS-Man SOAP. It is backed by the pypsrp Python library rather than pywinrm.

Why switch:

python3 -m pip install --user pypsrp
windows_servers:
  vars:
    ansible_connection: psrp
    ansible_port: 5986
    ansible_psrp_auth: kerberos
    ansible_psrp_protocol: https
    ansible_psrp_cert_validation: validate
    ansible_user: "ansible-svc@CORP.EXAMPLE.COM"

SSH on Windows

Windows Server 2019+ and Windows 10/11 ship Microsoft’s OpenSSH server as an optional feature. Ansible can drive it with the ordinary ssh connection plugin — you simply tell Ansible the remote shell is PowerShell:

windows_ssh:
  vars:
    ansible_connection: ssh
    ansible_shell_type: powershell
    ansible_user: ansible-svc
    # key-based auth, exactly like Linux

That gives you SSH-key auth and one transport for both Linux and Windows — attractive for greenfield fleets. The caveats are real, though: SSH-on-Windows is younger, environment-variable and console-encoding behaviour differs subtly from WinRM, and there is no CredSSP, so double-hop scenarios still need become or Kerberos delegation. win_dsc and every win_* module work identically over all three transports, because they are just modules — the transport only changes how the payload gets there.

Transport Library Ports Auth When to choose
WinRM pywinrm 5985 / 5986 Basic / NTLM / Kerberos / CredSSP / Cert Default; maximum compatibility, richest error semantics
PSRP pypsrp 5985 / 5986 Same as WinRM Large file copies, heavy fact gathering, performance
SSH OpenSSH 22 Keys / password Greenfield, key-based auth, unify Linux+Windows on one transport

Hands-on Free Lab: WinRM-over-HTTPS with Kerberos

This lab takes you from “fresh Windows Server 2022 evaluation VM” to “production-grade Ansible-managed host” without spending money. Microsoft’s evaluation Windows Server 2022 ISO is free for 180 days.

# On your Linux control node
mkdir -p ~/ansible-windows-lab && cd ~/ansible-windows-lab

# Create inventory
cat > inventory.yml <<'EOF'
all:
  children:
    windows_lab:
      hosts:
        win01.lab.local:
      vars:
        ansible_connection: winrm
        ansible_port: 5985
        ansible_winrm_transport: ntlm  # NTLM for the lab; switch to kerberos after AD setup
        ansible_user: "Administrator"
        ansible_password: "{{ lab_admin_password }}"
        ansible_winrm_server_cert_validation: ignore
EOF

# Create the playbook
cat > site.yml <<'EOF'
---
- hosts: windows_lab
  gather_facts: true
  tasks:

    - name: Install Web Server role with mgmt tools
      ansible.windows.win_feature:
        name:
          - Web-Server
          - Web-Mgmt-Console
          - Web-Asp-Net45
        state: present
        include_management_tools: true
      register: iis

    - name: Reboot if Web Server install requires it
      ansible.windows.win_reboot:
      when: iis.reboot_required

    - name: Render an IIS welcome page from a template
      ansible.windows.win_template:
        src: welcome.html.j2
        dest: 'C:\inetpub\wwwroot\index.html'

    - name: Open firewall for HTTP
      community.windows.win_firewall_rule:
        name: "HTTP-In"
        localport: 80
        action: allow
        direction: in
        protocol: tcp
        state: present
        enabled: true

    - name: Disable SMBv1 (defense-in-depth)
      ansible.windows.win_regedit:
        path: HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters
        name: SMB1
        data: 0
        type: dword
        state: present

    - name: Create a service account for app deployments
      ansible.windows.win_user:
        name: app-deploy-svc
        password: "{{ vault_deploy_svc_password }}"
        password_never_expires: true
        state: present
        groups:
          - Administrators

    - name: Grant the deploy account 'log on as service' right
      ansible.windows.win_user_right:
        name: SeServiceLogonRight
        users:
          - app-deploy-svc
        action: add
EOF

# Create the template
cat > welcome.html.j2 <<'EOF'
<!DOCTYPE html>
<html>
<head><title>{{ ansible_hostname }}</title></head>
<body>
  <h1>Welcome to {{ ansible_hostname }}</h1>
  <p>OS: {{ ansible_distribution }} {{ ansible_distribution_version }}</p>
  <p>Provisioned by Ansible at {{ ansible_date_time.iso8601 }}</p>
</body>
</html>
EOF

# Run the playbook
ansible-playbook -i inventory.yml site.yml \
  -e "lab_admin_password=YourLabPassw0rd! vault_deploy_svc_password=YourDeployPassw0rd!"

# Verify
curl -s http://win01.lab.local/ | grep "Welcome"

After this works, switch to Kerberos auth:

  1. Promote win01 to a Domain Controller (or join an existing AD).
  2. Create a service account ansible-svc@LAB.LOCAL and grant it admin on the target.
  3. Update inventory:
ansible_winrm_transport: kerberos
ansible_user: "ansible-svc@LAB.LOCAL"
# remove ansible_password — kinit handles credentials
  1. On the control node: kinit ansible-svc@LAB.LOCAL
  2. Re-run the playbook — same playbook, no password in inventory, ticket-based auth.

Common Mistakes & Troubleshooting

1. “WinRM authentication failed” on a domain-joined host with NTLM Group Policy may have disabled NTLM for inbound auth. Switch to Kerberos (the right answer anyway) or check Computer Configuration\Policies\Windows Settings\Security Settings\Local Policies\Security Options\Network security: Restrict NTLM.

2. “Server not found in Kerberos database” Either the host’s SPN is missing or your /etc/krb5.conf realm is wrong. Run setspn -L <hostname> on the DC — there must be HTTP/win01.corp.example.com registered. If missing: setspn -A HTTP/win01.corp.example.com win01.

3. “Clock skew too great” Kerberos rejects tickets if client and KDC clocks differ by more than 5 minutes. Run chronyc sources -v (Linux) and w32tm /query /status (Windows) to verify both are syncing to the same NTP source.

4. The “double-hop” problem Your playbook accesses \\fileserver\share from win01 and gets “access denied” even though the user has rights. This is by design — NTLM/Kerberos won’t forward credentials past one hop. Solution: enable CredSSP (ansible_winrm_transport: credssp) which performs delegation, OR use become: runas with a password (a full logon whose credentials can reach the second hop), OR avoid the hop entirely by copying via win_copy (which uses the WinRM session, not the user’s identity).

5. “Certificate verify failed” with HTTPS listener The cert is self-signed or its chain isn’t trusted on the control node. Either set ansible_winrm_server_cert_validation: ignore (lab only), or copy the CA cert to /etc/pki/ca-trust/source/anchors/ and run update-ca-trust.

6. win_updates runs forever win_updates waits for the entire update cycle including reboots and re-runs of pending updates. Set category_names: ['SecurityUpdates'] to scope it, and state: searched to just enumerate without installing.

7. win_dsc says “resource not found” The DSC module isn’t installed on the target. community.windows.win_psmodule: name=xWebAdministration state=present first, then call win_dsc.

Best Practices

Security Notes

Going deeper

The sections above get you running. This one is for the reader who has to run Windows automation at scale, safely, and predictably — where the WinRM wire protocol, quota model, and Kerberos internals stop being trivia and start being the difference between a clean 500-host run and intermittent 3 a.m. failures.

What actually crosses the wire

A single win_service task is not one HTTP request — it is a short WS-Man shell lifecycle. The control node sends a SOAP envelope to create a remote shell (wsman:Shell), a second call to run the bootstrap PowerShell command carrying the module’s Base64-encoded payload, one or more Receive calls to stream stdout/stderr back, and a final call to delete the shell. That is why WinRM feels chattier than SSH: every task pays this create → run → receive → delete tax over HTTP.

For NTLM, Kerberos, and CredSSP the payload is encrypted at the message layer using the negotiated session key, even on port 5985 — so “HTTP” WinRM is not plaintext for those mechanisms. Basic auth is the exception: it has no session key, so the payload (and your password) travels in clear text. That single fact is the entire reason Basic auth must run over HTTPS and Kerberos does not have to.

WinRM quotas bite you at scale

WinRM ships conservative quotas that a busy Ansible run trips into. The classics:

Setting Typical default Symptom when too low
MaxMemoryPerShellMB 1024 (older builds 150–512) Modules die with “Out of memory” / truncated output
MaxConcurrentOperationsPerUser 1500 “The WS-Management service cannot process the request” under high forks
MaxShellsPerUser 30 (older builds 5) New connections refused mid-run
MaxConnections 300 Same, at the listener level

Bump one with winrm set winrm/config/winrs '@{MaxMemoryPerShellMB="2048"}'. If you run Ansible with forks: 50 against Windows, size these deliberately — an undersized MaxShellsPerUser against a high fork count is the #1 cause of intermittent, host-count-dependent failures that look like flakiness but are pure arithmetic.

Kerberos delegation vs CredSSP — the double-hop, precisely

The double-hop wall exists because a WinRM network logon receives a token with no cached credentials — it can prove who you are to the first host, but has nothing to present onward to a second host. Three ways through, from bluntest to safest:

Idempotence and check mode, under the hood

A well-written Windows module reports changed: true only when it mutated state — win_service diffs the current start mode against the requested one before touching it. win_dsc is the gold standard: it calls the DSC resource’s read-only Test-TargetResource first, reports changed: false if already converged, and only runs Set-TargetResource when it must. Under --check, modules that support check mode report what would change without doing it; win_command/win_shell cannot know their own side effects, so you must guard them (see check mode, diff & debugging):

- name: Only report changed when the script actually did work
  ansible.windows.win_shell: C:\scripts\reindex.ps1
  register: reindex
  changed_when: "'REINDEXED' in reindex.stdout"
  check_mode: false   # this script has no dry-run; never let --check pretend it ran

Long-running tasks: async, win_updates, and win_reboot

win_updates and win_reboot are special. win_reboot deliberately tears down its own WinRM connection, then polls the listener until the host answers again and a test command succeeds — that is how a reboot in the middle of a play does not break the play. For genuinely long jobs, fire-and-poll with async so you are not holding a shell open for an hour:

- name: Kick off a long patch run without holding the connection open
  ansible.windows.win_updates:
    category_names: [SecurityUpdates, CriticalUpdates]
    reboot: true
  async: 3600
  poll: 0
  register: patch_job

- name: Wait for patching to finish
  ansible.builtin.async_status:
    jid: "{{ patch_job.ansible_job_id }}"
  register: patch_result
  until: patch_result.finished
  retries: 60
  delay: 60

Performance notes

Collection & version caveats

Common beginner mistakes

Distinct from the symptom → fix troubleshooting table above, these are the misconceptions that send beginners down the wrong path — the wrong mental model, not just the wrong command.

Q&A — 14 Questions

Q1. Why does Ansible use WinRM instead of SSH for Windows? Windows ships WinRM out of the box on every Server SKU since 2008 R2. SSH for Windows (OpenSSH on Server 2019+) is supported by Ansible too (ansible_connection: ssh works on Windows), but WinRM has years of operational maturity, deeper Kerberos integration, and richer error semantics for PowerShell-driven modules.

Q2. What is the [kerberos] extra in pywinrm[kerberos]? It pulls in pykrb5 and the GSSAPI Python bindings so the control node can do Kerberos auth. Without it, ansible_winrm_transport: kerberos fails with “kerberos extension not available.”

Q3. Why does kinit work but Ansible still fails with auth errors? Probably an SPN mismatch. Run klist after kinit, then check the service principal — it must be HTTP/<fqdn>@REALM. If the SPN registered in AD is HTTP/<short-hostname> instead, Kerberos rejects the ticket.

Q4. What’s the difference between win_command, win_shell, and win_powershell? win_command runs an executable directly with no shell — no pipes, no redirection (use this for safety). win_shell runs via PowerShell, allowing pipes and redirection. win_powershell is the modern, structured-output equivalent of win_shell — returns separate output, error, verbose, host_out channels.

Q5. Can I manage Windows from a Linux control node without any AD/Kerberos? Yes — use NTLM auth with local accounts. ansible_winrm_transport: ntlm, ansible_user: "Administrator", ansible_password: "...". Works fine for workgroup machines or break-glass scenarios.

Q6. What is CredSSP and when do I need it? CredSSP (Credential Security Support Provider) is a Windows auth mechanism that performs credential delegation. You need it when your playbook on host A needs to authenticate to host B as the original user — e.g., a script that copies from a fileshare. NTLM and Kerberos won’t forward creds past the first hop; CredSSP will (and so, often, will become: runas).

Q7. Why does win_updates take so long? Because it runs Windows Update synchronously: search, download, install, reboot if required, re-search for pending updates. A fresh Server 2022 with 6 months of pending updates can run for 90+ minutes. Scope it with category_names: ['SecurityUpdates', 'CriticalUpdates'] and consider using a separate WSUS server.

Q8. What does win_dsc give me that the regular Windows modules don’t? Coverage. ansible.windows and community.windows have ~250 modules combined; DSC has thousands. If you need to manage IIS request filtering, SQL Server replication, or AD recycle bin — there’s a DSC resource for it, and win_dsc brings it into Ansible.

Q9. How do gMSAs work with Ansible? gMSAs (group Managed Service Accounts) are AD accounts whose password rotates automatically every 30 days. To use one with Ansible: install the gMSA on the host (Install-ADServiceAccount), grant it admin rights, then either set ansible_user: "gmsa-name$@CORP.EXAMPLE.COM" (note the $ suffix) or run tasks under become_user: "CORP\\gmsa-name$" with become_flags: "logon_type=service" — and let Kerberos handle the keytab. No password ever in the vault.

Q10. Should I use SSH on Windows instead of WinRM? For greenfield environments, SSH is a viable choice — Microsoft’s OpenSSH is solid, and one transport for Linux+Windows is appealing. But the Windows module ecosystem assumes WinRM, SSH-on-Windows has subtle differences in environment variables and console encodings, and there is no CredSSP. Stick with WinRM unless you have a strong reason.

Q11. What’s the difference between ansible.windows.win_feature and community.windows.win_chocolatey? win_feature installs Windows roles and features that ship with Windows itself (IIS, AD-DS, Hyper-V, .NET 3.5). win_chocolatey installs third-party software via the Chocolatey package manager (Notepad++, 7-Zip, Git, JDK). Use the right tool for the right thing — don’t try to install IIS via Chocolatey.

Q12. Can I run Ansible on a Windows control node? Officially no — Ansible requires a Unix-like control node. Use WSL2 if you must run from a Windows engineer’s laptop (ansible-core runs fine in WSL2 Ubuntu and can drive Windows targets via WinRM).

Q13. How do I rotate the WinRM listener cert without downtime? Issue the new cert into LocalMachine\My, then winrm set winrm/config/Listener?Address=*+Transport=HTTPS '@{CertificateThumbprint="<new-thumbprint>"}'. WinRM picks up the new cert on the next connection — no service restart needed.

Q14. Why doesn’t my playbook see Windows facts (ansible_distribution, ansible_hostname)? You probably set gather_facts: false at the play level, or the connection is failing before facts run. Set gather_facts: true and verify the auth works with ansible win01 -m ansible.windows.win_ping before debugging fact-related issues.

Quick Check

  1. Which auth transport is the only sane choice for a domain-joined fleet?
  2. What port does WinRM-over-HTTPS listen on by default?
  3. What does ansible.windows.win_dsc do?
  4. How do you grant the SeServiceLogonRight to a local account?
  5. What’s the difference between ansible_user: user@REALM and ansible_user: DOMAIN\user?
  6. Why must clocks be in sync within 5 minutes for Kerberos auth?
  7. Which collection contains win_scheduled_taskansible.windows or community.windows?
  8. What’s the canonical way to check if a win_feature install requires reboot?
  9. Which become method does Windows use, and why is it not the same as Linux sudo?
  10. Name two ways to solve the double-hop problem without CredSSP.

Exercise

Build a complete role windows_iis_baseline that:

  1. Installs IIS with management tools (use win_feature).
  2. Installs the xWebAdministration DSC module (use win_psmodule).
  3. Renders a custom applicationHost.config snippet via win_template.
  4. Configures a default site via win_dsc + xWebSite.
  5. Creates a dedicated app pool service account (gMSA preferred, fallback to local user).
  6. Opens the firewall for HTTPS only (drop HTTP).
  7. Includes a validate.yml task list that uses win_uri to confirm the site responds with HTTP 200.

Test it with both NTLM (workgroup target) and Kerberos (domain-joined target) and confirm both code paths work without changing the role.

Practice challenges

Six graded exercises, beginner → advanced. Try each before opening the solution; each solution ends with a one-line why.

Challenge 1 — Prove the connection (beginner)

Configure an inventory for a single workgroup host using NTLM over HTTP, then confirm Ansible can reach it with a single ad-hoc command — no playbook.

<details><summary>Solution</summary>

# inventory.yml
windows_lab:
  hosts:
    win01:
  vars:
    ansible_connection: winrm
    ansible_port: 5985
    ansible_winrm_transport: ntlm
    ansible_winrm_server_cert_validation: ignore
    ansible_user: Administrator
    ansible_password: "{{ vault_admin_password }}"
ansible win01 -i inventory.yml -m ansible.windows.win_ping

Why: win_ping is the Windows smoke test — it proves transport + auth before you waste time debugging a module. </details>

Challenge 2 — Idempotent service state (beginner)

Write a task that guarantees the Print Spooler is stopped and disabled, and reason about why it reports changed: false on the second run.

<details><summary>Solution</summary>

- name: Spooler stopped and disabled (CIS)
  ansible.windows.win_service:
    name: Spooler
    state: stopped
    start_mode: disabled

Why: win_service diffs current vs desired state and start mode, so a converged host returns changed: false — idempotence you get for free from a real module (unlike win_shell). </details>

Challenge 3 — Feature install with conditional reboot (intermediate)

Install the DNS Server role including management tools, and reboot only if Windows says a reboot is required.

<details><summary>Solution</summary>

- name: Install DNS role
  ansible.windows.win_feature:
    name: DNS
    state: present
    include_management_tools: true
  register: dns

- name: Reboot only if required
  ansible.windows.win_reboot:
  when: dns.reboot_required

Why: win_feature returns reboot_required; gating win_reboot on it keeps the play from bouncing a host that did not need it. </details>

Challenge 4 — Make a raw script idempotent (intermediate)

You must run C:\scripts\seed-db.ps1, but it should run only once and must not always report changed. Do it two different ways.

<details><summary>Solution</summary>

# Way 1 — marker file: skip the run entirely once seeded
- name: Seed the DB once
  ansible.windows.win_shell: C:\scripts\seed-db.ps1
  args:
    creates: C:\scripts\.seeded

# Way 2 — changed_when: tie change status to the script's own output
- name: Seed the DB and report change honestly
  ansible.windows.win_shell: C:\scripts\seed-db.ps1
  register: seed
  changed_when: "'SEEDED' in seed.stdout"

Why: win_shell always reports changed: true on its own; creates skips the run on repeat, and changed_when ties change status to what the script actually did. </details>

Challenge 5 — Beat the double-hop without CredSSP (advanced)

A task on win01 must read \\fileserver\deploy\app.zip and fails with “access denied” even though the user has share rights. Fix it using become — not CredSSP.

<details><summary>Solution</summary>

- name: Copy an app package that lives on a remote share
  ansible.windows.win_copy:
    src: \\fileserver\deploy\app.zip
    dest: C:\deploy\app.zip
    remote_src: true
  become: true
  become_method: runas
  become_user: "CORP\\deploy-svc"
  vars:
    ansible_become_password: "{{ vault_deploy_svc_password }}"

Why: the default WinRM network logon carries no credentials to a second hop; runas performs a full logon whose cached credentials can authenticate outbound to the fileshare — solving the double-hop with no CredSSP delegation risk. </details>

Challenge 6 — Bridge a DSC resource and dry-run it (advanced)

There is no native module for the setting you need, but a DSC resource exists. Install the DSC module, converge the resource with win_dsc, and prove the play is safe by running it in check mode first.

<details><summary>Solution</summary>

- name: Ensure the DSC module is present
  community.windows.win_psmodule:
    name: NetworkingDsc
    state: present

- name: Set the DNS client server addresses via DSC
  ansible.windows.win_dsc:
    resource_name: DnsServerAddress
    AddressFamily: IPv4
    InterfaceAlias: Ethernet
    Address:
      - 10.0.0.10
      - 10.0.0.11
ansible-playbook -i inventory.yml dns.yml --check

Why: win_dsc runs the resource’s read-only Test-TargetResource under --check, so you see whether it would converge without mutating the host — genuine dry-run idempotence for any DSC resource that implements Test. </details>

Cert Mapping

Glossary

Next Steps

You can now bring Windows fleets into the same playbook ecosystem as your Linux fleet, with proper Kerberos auth and DSC-backed idempotence. The next lesson covers Ansible for Kubernetes — the kubernetes.core collection, k8s module, manifest templating, Helm chart lifecycle, and the patterns that let you treat clusters as configuration-managed targets rather than imperative scripts.

ansiblewindowswinrmkerberosdscansible-windowscommunity-windowswin-dscwindows-serveractive-directorypowershellkloudvin
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments