Servers Automation

Configuration Management for Windows Server with PowerShell DSC and Ansible

Two tools dominate declarative Windows Server configuration, and most teams pick one for reasons that don’t survive contact with an incident. PowerShell DSC gives you an on-box agent — the Local Configuration Manager (LCM) — that re-applies a compiled desired state on a schedule with no controller required, so a fleet self-heals even when the orchestrator is unreachable. Ansible gives you agentless push from a control node, an enormous module library, and orchestration across mixed Linux/Windows estates from a single inventory. Neither is strictly “better”; they solve different halves of the problem. The mature pattern in a real datacenter is Ansible as the orchestration plane that ships and triggers DSC exactly where DSC’s always-on remediation earns its keep, while Ansible owns breadth, sequencing, and everything that needs cross-host coordination.

This is the architect’s walkthrough of both, side by side, at Advanced depth. You will build the same Windows Server baseline — an IIS role, a guaranteed service, a hardened registry value — three ways: as a DSC configuration compiled to MOF and enforced by the LCM, as an idempotent Ansible role using the ansible.windows collection, and as the combined model where Ansible drives DSC resources through win_dsc. Along the way we enumerate every moving part that bites in production: the Get/Test/Set resource contract, MOF compilation, the three ConfigurationMode values, push versus pull versus Azure Machine Configuration (the artist formerly known as guest configuration), the split between Windows PowerShell 5.1 DSC and the cross-platform DSC v3 runtime, WinRM versus PSRP versus SSH transport, the win_ versus ansible.windows module naming migration, what idempotency actually costs to guarantee, how each tool handles secrets, and how each scales to hundreds of nodes.

By the end you will stop treating “DSC or Ansible” as a religious war and start treating it as a routing decision: which settings must never drift even when the network is down (DSC’s LCM), which need orchestrated, sequenced, cross-host changes (Ansible), and which niche resource is simply better in DSC and worth invoking from an Ansible play. You will also have a Pester-and-ansible-lint-gated CI pipeline so neither a broken MOF nor a non-idempotent task ever reaches a node.

What problem this solves

Windows Server drifts. Someone RDPs into a box during an incident and flips a registry key, disables a service “just to test,” or installs a feature by hand — and it stays that way, silently, until an audit or an outage finds it. Multiply that across 50, 200, or 800 hosts across regions and “what is the actual configuration of production right now?” becomes unanswerable. Imperative scripting doesn’t fix this: a .ps1 that installs IIS is not idempotent, is not safe to re-run, and tells you nothing about whether the box is currently correct. Click-ops via Server Manager doesn’t scale and leaves no reviewable artifact. Group Policy handles a slice (see Group Policy as Code) but can’t install roles, manage arbitrary services, or lay down application config, and it’s domain-bound.

What breaks without declarative config management: a security baseline that silently regresses (SMBv1 quietly re-enabled by a manual “hotfix”), a fleet where no two “identical” web servers are actually identical (snowflakes), patch and hardening state that nobody can prove for a compliance auditor, and a rebuild-a-lost-node process that lives in one senior engineer’s head. When a host dies you want to stamp out its replacement from a reviewed, version-controlled definition in minutes, not reverse-engineer it.

Who hits this: anyone running more than a handful of Windows Servers — IIS/middleware fleets, file and print, AD infrastructure, SQL hosts, RDS/AVD session hosts. It bites hardest on teams with mixed Linux/Windows estates (they want one control plane), regulated shops (they must prove state, not assert it), and anyone whose control node or network is not guaranteed reachable 24/7 (a partition must not mean a compliance gap). The fix is to define the desired state as code, enforce it continuously, and detect and remediate drift automatically — and to know precisely which tool does which job.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable on Windows Server 2019/2022/2025: installing roles and features, reading and setting registry values, Get-Service/Set-Service, and the basics of PowerShell (functions, parameters, modules, Install-Module from the PowerShell Gallery). You should know what WinRM is (the WS-Management listener on TCP 5985 HTTP / 5986 HTTPS) even if you’ve only ever used Enable-PSRemoting. For the Ansible half you need a Linux (or WSL) control node with a recent ansible-core, and familiarity with YAML, inventories, and playbooks. For the Azure section, an Azure subscription and a passing familiarity with Azure Arc helps but isn’t required to follow the concepts.

This sits in the Servers → Automation track and is upstream of most other Windows automation you’ll do. It pairs directly with Group Policy as Code (the GPO slice of the same drift problem), Windows Server hardening and the WSUS patch pipeline (what you’re often enforcing is a hardening baseline), and gMSA, Windows LAPS and service-account credential hardening (how the automation authenticates without plaintext passwords). On the Ansible side, the deeper collection and testing mechanics live in idempotent Ansible collections and Molecule testing, inventory and secrets in Ansible dynamic inventory for AWS/Azure with secrets, and controller-scale operations in Ansible Automation Platform at enterprise scale. If you manage headless boxes, managing Server Core with Windows Admin Center is the interactive counterpart to this code-driven approach.

A quick map of which tool owns which layer, so you route work correctly from the start:

Layer / concern PowerShell DSC (5.1 + LCM) Ansible Best owner
On-box self-healing when control node is down Native (ApplyAndAutoCorrect) None — converges only when it runs DSC
Cross-host orchestration & sequencing Weak (per-node MOF) Native (plays, serial, handlers) Ansible
Mixed Linux + Windows from one inventory Windows-only (5.1) Native Ansible
Agentless (no software on target) No (LCM is always present) Yes (WinRM/SSH only) Ansible
Rich niche Windows resources (xWebAdministration) Mature module ecosystem Can invoke via win_dsc DSC
Ad-hoc “run this now everywhere” No Native (ansible -m) Ansible
Continuous drift correction with no controller Native No (needs a scheduler) DSC
Fleet reporting from Azure Azure Machine Configuration AAP + callbacks Either

Core concepts

Six mental models make every later decision obvious. Read them once; the tables that follow enumerate the specifics.

DSC is compile-then-enforce; Ansible is push-and-converge. In DSC you author a configuration (a PowerShell function), compile it to a MOF document per node (a flat, logic-free declarative artifact), and hand that MOF to the LCM, an agent baked into every Windows box that applies it and — critically — re-applies it on a timer. In Ansible there is no compile step and no resident agent: a control node opens a remote session, runs each task’s check-then-change logic in place, and disconnects. DSC’s enforcement is continuous and autonomous; Ansible’s is episodic and orchestrated. That single difference explains most of the trade-offs below.

Idempotency lives in the Test-before-Set gate. A DSC resource is a module exposing Get (read current state), Test (does current state match desired? return a boolean), and Set (make it match). The contract is that Set runs only when Test reports drift. Ansible’s win_ modules implement the same discipline internally: they inspect current state and report changed only when they actually mutate something. Idempotency is not a property you get for free by “using a config tool” — it’s a property of each resource/module honouring that gate, and it’s your job to preserve it (especially the moment you drop to raw shell).

The MOF is a compiled binary; the configuration is the source. Treat the .ps1 configuration like source code and the <node>.mof like a build artifact. The MOF is what the LCM stores and enforces; the script that produced it is build-time only. This matters for secrets (the MOF sits on disk in clear text unless you encrypt credentials) and for CI (you test that the configuration compiles to the expected MOF).

The LCM’s ConfigurationMode decides whether drift is corrected, merely reported, or ignored. ApplyOnly applies once and never touches the box again. ApplyAndMonitor applies, then logs drift to the event log but does not fix it. ApplyAndAutoCorrect applies and re-applies every consistency check — the self-healing mode. This one meta-setting is the entire reason to reach for DSC over Ansible for security-critical state.

There are two DSCs and they are different runtimes. Everything the LCM understands — MOF, Start-DscConfiguration, the pull server, Get-DscLocalConfigurationManager — belongs to Windows PowerShell 5.1 DSC (WMI/CIM-based). The cross-platform rewrite, DSC v3, is a standalone Rust executable (dsc) with no LCM, no MOF pull server, and a JSON/YAML resource model; it’s what powers Azure Machine Configuration under the hood. Do not mix their documentation: a 5.1 deployment does not gain DSC v3 features, and DSC v3 concepts (resource manifests, dsc config set) do not map onto the LCM.

Ansible reaches Windows over a transport, and the transport is where auth pain lives. Ansible does not run an agent on Windows; it connects over WinRM (default winrm plugin, or the faster psrp plugin, both riding the WS-Man listener) or, on modern Windows, over SSH (ansible.windows supports the Win32-OpenSSH server). Which transport and which auth (Kerberos, NTLM, CredSSP, certificate, Basic) you choose determines your credential-exposure posture and whether double-hop scenarios work at all.

The vocabulary in one table

Before the deep sections, pin down every term. The glossary repeats these for lookup; this is the mental model side by side.

Term One-line definition World Why it matters
Resource Module with Get/Test/Set for one state kind DSC Idempotency unit; Test gates Set
Configuration PowerShell function declaring desired state DSC Your source of truth
MOF Compiled, logic-free desired-state document per node DSC The artifact the LCM enforces
LCM On-box agent that applies + re-applies MOF DSC Autonomous drift correction
ConfigurationMode Whether the LCM auto-corrects, monitors, or applies once DSC The self-heal switch
RefreshMode Push (sent) vs Pull (fetched) delivery DSC Operational model
Meta-configuration Config that configures the LCM itself DSC Where LCM settings are set
DSC v3 Standalone cross-platform dsc executable, no LCM/MOF DSC Different runtime; powers machine config
Machine Configuration Azure/Arc guest-config that audits/enforces via DSC v3 Azure Fleet compliance from the cloud
Module (Ansible) Unit of work run on the target Ansible Implements idempotency internally
Collection Namespaced bundle of modules (ansible.windows) Ansible Modern packaging of win_*
Connection plugin How Ansible reaches the host (winrm/psrp/ssh) Ansible Transport + auth choice
win_dsc Ansible module that invokes any installed DSC resource Both The bridge between the two
Idempotency Re-running produces no further change Both changed=0 on second pass
Drift Live state diverging from desired Both What both tools exist to kill

DSC concepts: resources, MOF, and the LCM

Three pieces make DSC. Resources know how to test and set one kind of state — a Windows feature, a service, a registry value, a file. Each exposes Get, Test, and Set. Confirm what’s installed before you author against it, and prefer the maintained PSDscResources module over the ancient in-box PSDesiredStateConfiguration resources:

# What resources does this box have, and at what version?
Get-DscResource | Select-Object Name, ModuleName, Version | Sort-Object ModuleName

# Install the supported successor resource module + a networking one, machine-wide
Install-Module -Name PSDscResources, NetworkingDsc -Scope AllUsers -Force

A configuration is a PowerShell function (keyword Configuration) that declares resources and their target values. Compiling it emits one MOF per node. The LCM ingests a MOF, calls each resource’s Test, applies Set where drift exists, and re-runs on its consistency timer. Inspect the LCM’s current settings any time:

Get-DscLocalConfigurationManager
# Key fields: RefreshMode (Push|Pull), ConfigurationMode,
# ConfigurationModeFrequencyMins, RefreshFrequencyMins, RebootNodeIfNeeded, LCMState

The resource categories you’ll use constantly, and the module each ships in — pin to the maintained module, not the in-box one:

State to manage Resource Ships in Key parameters
Windows role/feature WindowsFeature PSDscResources Name, Ensure, IncludeAllSubFeature
Service state/startup Service PSDscResources Name, State, StartupType, Credential
Registry value Registry PSDscResources Key, ValueName, ValueData, ValueType, Ensure
File / directory File PSDesiredStateConfiguration (in-box) DestinationPath, Contents, Ensure
Local group membership Group PSDscResources GroupName, MembersToInclude
Environment variable Environment PSDscResources Name, Value, Ensure
Scheduled task ScheduledTask ComputerManagementDsc TaskName, ActionExecutable, ScheduleType
IIS website/app pool xWebsite, xWebAppPool xWebAdministration Name, PhysicalPath, BindingInfo
Firewall rule Firewall NetworkingDsc Name, DisplayName, Direction, Action

The Ensure property is the DSC idiom you’ll type most: Present means “make it exist as specified,” Absent means “make sure it’s gone.” The three-method contract, and what each returns, is worth memorising because custom resources must honour it exactly:

Method Purpose Returns Runs when
Get Report the current state A hashtable of current values On Get-DscConfiguration; for reporting
Test Is current == desired? $true (compliant) / $false (drift) Every consistency check, before Set
Set Make current match desired Nothing (side effects only) Only when Test returned $false

Mental model: the configuration is your source of truth, the MOF is the compiled binary, and the LCM is the runtime that enforces it. When you find yourself writing imperative “check if X then do Y” logic in a configuration, stop — that logic belongs inside a resource’s Test/Set, not in the declarative document.

Authoring a configuration: roles, services, and registry

Here is a real baseline: install IIS, guarantee W3SVC is running and automatic, and pin a registry value that disables the legacy SMBv1 server. Parameterise the node list so the same configuration compiles for every host.

Configuration WebBaseline {
    param([string[]]$NodeName = 'localhost')

    Import-DscResource -ModuleName PSDscResources -ModuleVersion 2.12.0.0

    Node $NodeName {
        WindowsFeature IIS {
            Name   = 'Web-Server'
            Ensure = 'Present'
        }

        Service W3SVC {
            Name        = 'W3SVC'
            State       = 'Running'
            StartupType = 'Automatic'
            DependsOn   = '[WindowsFeature]IIS'   # ordering: feature before service
        }

        Registry DisableSmb1Server {
            Key       = 'HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters'
            ValueName = 'SMB1'
            ValueData = '0'
            ValueType = 'Dword'
            Ensure    = 'Present'
        }
    }
}

# Compile -> emits .\WebBaseline\<NodeName>.mof, one file per node
WebBaseline -NodeName 'WEB01','WEB02' -OutputPath .\WebBaseline

DependsOn is the only ordering primitive you get inside a node block: the service resource will not run until the feature is present. Everything without an explicit DependsOn may run in any order — DSC is declarative, not sequential. Apply the compiled MOF locally to validate before wiring any delivery model:

Start-DscConfiguration -Path .\WebBaseline -Wait -Verbose -Force
Test-DscConfiguration -Detailed        # InDesiredState : True means no drift
Get-DscConfiguration                    # what the LCM believes is currently applied

The verbs you’ll use to author, apply, inspect, and unwind a configuration — memorise this table, it is the whole DSC operational surface:

Cmdlet What it does Common flags
Start-DscConfiguration Apply a compiled MOF to node(s) -Wait -Verbose -Force, -UseExisting
Test-DscConfiguration Report compliance vs the applied MOF -Detailed (per-resource result)
Get-DscConfiguration Return the current applied state (none)
Get-DscLocalConfigurationManager Show LCM settings/state (none)
Set-DscLocalConfigurationManager Apply an LCM meta-configuration -Path -Verbose
Update-DscConfiguration Pull nodes: fetch + apply now -Wait
Remove-DscConfigurationDocument Delete a pending/current/previous MOF -Stage Current
Publish-DscConfiguration Stage a MOF without applying it -Path

The Registry resource’s ValueType must match the actual registry type or you get a perpetual “not in desired state” — this table is the mapping you’ll reach for, and it’s the same set of types win_regedit’s type: parameter accepts:

DSC ValueType Registry type win_regedit type: Typical use Value format
String REG_SZ string Plain text setting 'Enabled'
ExpandString REG_EXPAND_SZ expandstring Path with %VAR% '%SystemRoot%\logs'
Dword REG_DWORD dword Flags, on/off, small ints '0' / '1'
Qword REG_QWORD qword 64-bit numeric '4294967296'
Binary REG_BINARY binary Raw bytes 'hex:00,ff'
MultiString REG_MULTI_SZ multistring List of strings @('a','b')

A PSCredential embedded in a MOF is stored on disk in clear text unless you encrypt it. DSC supports certificate-encrypted credentials via a configuration-data block referencing a CertificateFile (public key at compile time) and a Thumbprint the LCM uses to decrypt at apply time. We cover the full pattern in Security notes — for now, the rule is absolute: never ship a plaintext credential in a MOF.

Push versus pull, and configuring the LCM

The LCM runs in one of two RefreshMode values, and that choice drives your whole operational model.

Dimension Push Pull
Delivery Start-DscConfiguration sends the MOF LCM fetches MOF + modules from a server on a timer
Direction Controller → node (imperative) Node → server (self-service)
Scale model One-to-many from a controller Fleet polls independently; no fan-out
Module delivery You pre-stage modules on the node LCM downloads modules from the pull endpoint
Drift correction Only on next push, or the LCM’s own consistency check LCM re-pulls + re-applies on its own timer
Failure mode Controller down = no new config Pull server down = nodes keep last-known-good
Reporting You collect it (events/Test) Optional report server aggregates status

You configure the LCM with a meta-configuration — a separate Configuration decorated with [DSCLocalConfigurationManager()] whose only child is a Settings block. This is where ApplyAndAutoCorrect — the setting that makes the agent remediate on every check rather than merely report — is turned on:

[DSCLocalConfigurationManager()]
Configuration LcmAutoCorrect {
    Node localhost {
        Settings {
            RefreshMode                    = 'Push'
            ConfigurationMode              = 'ApplyAndAutoCorrect'
            ConfigurationModeFrequencyMins = 30      # how often it re-checks + corrects
            RefreshFrequencyMins           = 30      # (pull) how often it re-fetches
            RebootNodeIfNeeded             = $false
            ActionAfterReboot              = 'ContinueConfiguration'
            AllowModuleOverwrite           = $true
        }
    }
}

LcmAutoCorrect -OutputPath .\LcmMeta
Set-DscLocalConfigurationManager -Path .\LcmMeta -Verbose

The three ConfigurationMode values are the single most important LCM decision:

ConfigurationMode Behaviour after first apply Drift is… Use when
ApplyOnly Applies once, never touches the box again Yours to find and fix One-shot provisioning; another tool owns steady-state
ApplyAndMonitor Applies, then logs drift to the event log; does not fix Reported, human-approved remediation Audit-only fleets; change must be approved
ApplyAndAutoCorrect Applies, then re-applies every ConfigurationModeFrequencyMins Corrected automatically Security-critical state that must never drift

The full LCM setting surface — every knob, its default, and why you’d touch it:

LCM setting What it controls Default Notes / limit
RefreshMode Push vs Pull vs Disabled Push Disabled turns the LCM off entirely
ConfigurationMode Auto-correct / monitor / apply-once ApplyAndMonitor The self-heal switch
ConfigurationModeFrequencyMins Consistency-check interval 15 Floor 15; lower values are clamped
RefreshFrequencyMins Pull re-fetch interval 30 Must be a multiple of ConfigurationModeFrequencyMins
RebootNodeIfNeeded Auto-reboot when a resource requests it $false Set $true only where surprise reboots are safe
ActionAfterReboot Resume or stop config after a reboot ContinueConfiguration Keep default for multi-step configs
AllowModuleOverwrite Overwrite modules on pull $false $true when iterating on module versions
RebootNodeIfNeeded + ActionAfterReboot Together handle multi-reboot configs AD/role installs often need this
StatusRetentionTimeInDays How long to keep status data 10 Reporting retention

ConfigurationModeFrequencyMins has a documented floor of 15 minutes — setting it lower is silently clamped. Thirty is a sane production default: frequent enough to close drift windows, infrequent enough to avoid churn on 800 nodes all hammering the same check.

For a pull server you’d set RefreshMode = 'Pull' and add a ConfigurationRepositoryWeb block with the endpoint URL and a registration key. Note the deprecation reality: Microsoft has deprecated the Windows-hosted DSC Pull Server feature. For greenfield pull at scale the supported direction is Azure Automation State Configuration or Azure Machine Configuration (covered below), or a community pull endpoint. The on-box push plus ApplyAndAutoCorrect model is fully supported and is what I lean on when pairing DSC with Ansible — it needs no server at all, just a scheduled push and an autonomous LCM.

DSC v3, Azure Machine Configuration, and the runtime split

This is the section that saves you a day of confusion. There are two DSCs, and mixing their docs is the most common self-inflicted wound in Windows automation right now.

Windows PowerShell 5.1 DSC is the WMI/CIM-based system everything above uses: Configuration functions, MOF compilation, the LCM, Start-DscConfiguration, the (deprecated) pull server. It is Windows-only, in-box on every supported Windows Server, and stable but no longer receiving feature investment.

DSC v3 is a ground-up rewrite: a standalone, cross-platform executable (dsc, written in Rust) with no LCM, no MOF, and no pull server. Resources are described by JSON/YAML manifests and can be authored in any language that can read stdin and write stdout JSON. You invoke it imperatively — dsc config get, dsc config test, dsc config set — against a YAML/JSON configuration document. It has no resident agent and no autonomous timer of its own; something else schedules it. That “something else,” in Azure, is Machine Configuration.

Azure Machine Configuration (formerly Azure Policy guest configuration) is the fleet-compliance service that audits and optionally enforces in-guest settings on Azure VMs and Arc-enabled servers (including on-prem and other clouds). It ships a guest configuration agent onto the machine; that agent runs DSC-based configurations on a schedule and reports compliance back to Azure Policy. It is the supported, cloud-native successor to DSC pull for reporting and enforcement at scale, and it uses the DSC engine under the hood.

The comparison you must internalise — which runtime owns what:

Aspect Windows PowerShell 5.1 DSC DSC v3 Azure Machine Configuration
Runtime WMI/CIM + LCM Standalone dsc executable Guest-config agent (uses DSC engine)
Artifact MOF (compiled) JSON/YAML config document Signed .zip guest-config package
Resident agent LCM (always present) None Guest configuration extension/agent
Autonomous drift correction Yes (ApplyAndAutoCorrect) No (invoked externally) Yes (audit + optional ApplyAndAutoCorrect)
Platform Windows only Windows, Linux, macOS Azure + Arc (on-prem/other clouds)
Delivery at scale Push / (deprecated) pull server You orchestrate Azure Policy assignment
Reporting You collect You collect Azure Policy compliance dashboard
Resource authoring PowerShell classes/MOF schema Any language via manifest PowerShell DSC resources (packaged)
Status Stable, no new features Actively developed GA, recommended for Azure/Arc fleets

The routing rule I use, stated plainly:

If you need… Use
On-box self-heal on Windows Server, no cloud dependency 5.1 DSC with LCM ApplyAndAutoCorrect
Cross-platform declarative resources invoked by a pipeline DSC v3 (dsc config set)
Fleet compliance + enforcement reported to Azure Policy Machine Configuration (Azure/Arc)
To replace a deprecated on-prem DSC pull server Machine Configuration or Azure Automation State Config
Ad-hoc orchestrated changes across mixed OS Ansible (not DSC at all)

A minimal taste of the DSC v3 imperative model (note: no MOF, no LCM — this is a one-shot invocation you’d wrap in a pipeline):

# baseline.dsc.config.yaml  — a DSC v3 configuration document
$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
resources:
  - name: Ensure SMB1 server disabled
    type: Microsoft.Windows/Registry
    properties:
      keyPath: HKLM\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters
      valueName: SMB1
      valueData:
        DWord: 0
# Test compliance (no change), then enforce — DSC v3 CLI
dsc config test --file baseline.dsc.config.yaml
dsc config set  --file baseline.dsc.config.yaml

And the Azure side — assign a built-in machine-configuration policy that audits (and optionally enforces) an in-guest baseline across every Arc-connected Windows Server:

# Assign a Machine Configuration (guest config) policy to a resource group
az policy assignment create \
  --name "win-securebaseline" \
  --display-name "Windows security baseline (machine config)" \
  --policy "<builtin-guest-config-policy-definition-id>" \
  --scope "/subscriptions/<sub-id>/resourceGroups/rg-win-fleet" \
  --location centralindia \
  --mi-system-assigned   # managed identity for the guest-config agent

The load-bearing takeaway: if your requirement is autonomous drift correction on Windows Server with no cloud dependency, you want 5.1 DSC’s LCM — and that is exactly the piece we pair with Ansible below. If your requirement is cloud-reported compliance across a hybrid fleet, you want Machine Configuration. DSC v3 sits in the middle as the modern engine, most valuable when a pipeline (or Ansible) invokes it.

Managing Windows from Ansible: WinRM, PSRP, and SSH

Ansible reaches Windows over one of three transports. The default winrm connection plugin speaks WS-Management; the newer psrp plugin uses the PowerShell Remoting Protocol over WinRM and is faster for multi-task plays because it reuses a single runspace; and modern Windows can accept SSH via Win32-OpenSSH, which the ansible.windows collection supports and which sidesteps a lot of WinRM/Kerberos complexity.

First, stand up a listener. The canonical bootstrap is Microsoft’s ConfigureRemotingForAnsible.ps1, but for production you want an HTTPS listener on 5986 bound to a real certificate — not the script’s self-signed default:

# On the Windows host: HTTPS WinRM listener bound to a known cert, Basic/CredSSP off
$cert = Get-ChildItem Cert:\LocalMachine\My |
        Where-Object Subject -eq 'CN=web01.corp.example.com'
New-Item -Path WSMan:\localhost\Listener -Transport HTTPS `
         -Address * -CertificateThumbPrint $cert.Thumbprint -Force
Set-Item WSMan:\localhost\Service\Auth\Basic   $false
Set-Item WSMan:\localhost\Service\Auth\CredSSP  $false
New-NetFirewallRule -DisplayName 'WinRM HTTPS' -Direction Inbound `
                    -LocalPort 5986 -Protocol TCP -Action Allow

Then the inventory. Prefer Kerberos in a domain (it enables constrained delegation and avoids putting credentials on the wire); for non-domain hosts, NTLM over HTTPS is acceptable. Avoid Basic and CredSSP unless you have a hard requirement and understand the credential-exposure trade-off.

[web]
web01.corp.example.com
web02.corp.example.com

[web:vars]
ansible_connection=psrp
ansible_port=5986
ansible_psrp_auth=kerberos
ansible_psrp_cert_validation=validate

Validate connectivity with win_ping before any real play — it confirms the listener, auth, and certificate chain in one shot:

ansible web -m ansible.windows.win_ping

The transport/auth decision matrix — pick the row that matches your environment, not the one that’s easiest to bootstrap:

Transport Auth options Default port Speed (multi-task) Double-hop When to use
winrm Kerberos, NTLM, CredSSP, Basic, Certificate 5985/5986 Baseline (new shell/task) Kerberos deleg / CredSSP Broad compatibility, older hosts
psrp Kerberos, NTLM, CredSSP, Basic, Certificate 5985/5986 Faster (reuses runspace) Kerberos deleg / CredSSP Many tasks per host; prefer this
ssh Public key, password 22 Fast Native (agent forwarding) Modern Windows; avoids Kerberos setup

The authentication options, ranked by what they expose:

Auth method Credential on the wire? Needs HTTPS? Double-hop Notes
Kerberos No (tickets) No (but recommended) Yes (constrained deleg) Best in a domain; needs krb5 + realm config
NTLM Hashed challenge/response Strongly yes No Fine for non-domain over HTTPS
Certificate No (client cert) Yes No Great for automation identities; maps to a local user
CredSSP Yes (delegated, encrypted) Yes Yes Avoid unless you need the second hop
Basic Yes (base64!) Mandatory No Local accounts only; last resort

Idempotent Ansible roles and the win_ansible.windows migration

The Windows modules are written to be idempotent: they inspect current state and report changed only when they actually mutate something. Two disciplines: use the declarative modules (win_feature, win_service, win_regedit) rather than dropping to win_shell, which Ansible cannot reason about for change detection; and always fully-qualify module names with their collection.

There’s a naming migration to get right. The bare win_* names were the legacy short form; modern content uses fully-qualified collection names (FQCNs)ansible.windows.win_feature, community.windows.win_firewall_rule, chocolatey.chocolatey.win_chocolatey. The bare names still resolve for now, but pin the collections and use FQCNs so a role is unambiguous and version-locked.

Here is the IIS baseline from the DSC section, expressed as an Ansible role task file:

# roles/web_baseline/tasks/main.yml
- name: Ensure IIS web server role is present
  ansible.windows.win_feature:
    name: Web-Server
    state: present
    include_management_tools: true
  register: iis_feature

- name: Ensure W3SVC is running and automatic
  ansible.windows.win_service:
    name: W3SVC
    state: started
    start_mode: auto

- name: Disable SMBv1 server via registry
  ansible.windows.win_regedit:
    path: HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters
    name: SMB1
    data: 0
    type: dword

- name: Reboot only if the feature install demands it
  ansible.windows.win_reboot:
  when: iis_feature.reboot_required

Two idempotency disciplines worth internalising. First, guard reboots on the module’s own signalwin_feature returns reboot_required, so reboot conditionally, never unconditionally. Second, when forced into win_command/win_shell, add creates/removes guards or a changed_when expression so a re-run doesn’t falsely report changed:

- name: Run a one-shot installer only once
  ansible.windows.win_command: C:\setup\app-install.exe /quiet
  args:
    creates: C:\Program Files\App\app.exe   # skip if already installed

Run with --check to verify a role is a no-op against already-converged hosts. A correctly authored role reports ok=... with changed=0 on the second pass — that is your idempotency proof.

The collection landscape you’ll actually pull from, and what lives where:

Collection Contains Example modules Install
ansible.windows Core, supported Windows modules win_feature, win_service, win_regedit, win_dsc, win_reboot, win_updates ansible-galaxy collection install ansible.windows
community.windows Broader community Windows modules win_firewall_rule, win_domain_membership, win_scheduled_task, win_iis_website ansible-galaxy collection install community.windows
microsoft.ad Active Directory management microsoft.ad.user, microsoft.ad.group, microsoft.ad.domain ansible-galaxy collection install microsoft.ad
chocolatey.chocolatey Chocolatey package management win_chocolatey, win_chocolatey_source ansible-galaxy collection install chocolatey.chocolatey

The ansible.windows core modules you’ll type most, mapped to the DSC resource they replace — this is the Rosetta stone between the two worlds:

ansible.windows module Manages DSC resource equivalent Key idempotent parameters
win_feature Roles/features WindowsFeature name, state, include_management_tools
win_service Service state/startup Service name, state, start_mode
win_regedit Registry values Registry path, name, data, type
win_user Local users User name, password, groups, state
win_group Local groups Group name, state
win_group_membership Group members Group (Members) name, members, state
win_environment Env variables Environment name, value, level, state
win_file Files/dirs File path, state
win_copy / win_get_url File content File (Contents/source) src/url, dest (checksum-guarded)
win_reboot Reboot + wait LCM RebootNodeIfNeeded (guard on reboot_required)
win_updates Windows Update (WSUS/xWindowsUpdate) category_names, state
win_dsc Any DSC resource (bridge to DSC) resource_name + resource props

The idempotency rules, module by module — the ones that trip people up:

Module Idempotent by default? The gotcha The fix
win_feature Yes Reboot may be required post-install Guard win_reboot on .reboot_required
win_service Yes start_mode: delayed vs auto are distinct states Match the exact start_mode you want
win_regedit Yes Wrong type (e.g. string vs dword) reports perpetual change Set type to the actual registry type
win_command No (runs every time) Reports changed on every run Add creates/removes or changed_when
win_shell No Same as above, plus shell parsing Same; prefer a declarative module
win_copy Yes (checksum) Large files re-checksum each run (slow) Use win_get_url for remote sources
win_updates Yes-ish Changes each run as new patches appear Expected; treat changed as informational
win_dsc Depends on resource Resource must be WMF 5.1-compatible & present Pre-install the module; test with check mode

Combining DSC resources inside Ansible with win_dsc

This is the payoff of knowing both tools. Ansible’s ansible.windows.win_dsc module invokes any installed DSC resource directly — no MOF compilation, no LCM scheduling. You get DSC’s mature resource implementations (especially for niche state like xWebsite bindings, xWebAppPool recycling, or intricate security settings) driven by Ansible’s orchestration, inventory, and check mode. The module maps DSC resource properties one-to-one to task parameters.

- name: Ensure a website via the DSC Service resource through Ansible
  ansible.windows.win_dsc:
    resource_name: Service
    Name: W3SVC
    State: Running
    StartupType: Automatic

- name: Configure a registry value via the DSC Registry resource
  ansible.windows.win_dsc:
    resource_name: Registry
    Key: HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters
    ValueName: SMB1
    ValueData: '0'
    ValueType: Dword
    Ensure: Present

- name: Create an IIS app pool via a richer xWebAdministration DSC resource
  ansible.windows.win_dsc:
    resource_name: xWebAppPool
    Name: shop-pool
    State: Started
    managedRuntimeVersion: ''      # No Managed Code
    idleTimeout: '00:00:00'        # disable idle recycle

win_dsc honours check mode by calling the resource’s Test method and reporting what would change, and it surfaces the resource version so you can pin it. The decision rule I use: reach for win_dsc when a high-quality DSC resource already encapsulates complex logic you’d otherwise hand-roll in win_shell; use native ansible.windows modules for the common cases because they’re faster and need no DSC module installed on the target.

One caveat: win_dsc requires the resource module present on the target, and the resource must be compatible with the WMF 5.1 DSC engine. Resources that depend on the cross-platform DSC v3 runtime will not load here — win_dsc speaks to the classic engine only.

When to use win_dsc versus a native module versus a full LCM deployment:

Approach Agent on box? Autonomous drift correction Orchestration Best for
Native ansible.windows module No No Full (Ansible) Common state, mixed OS, speed
win_dsc (Ansible → DSC resource) No (resource module only) No (runs when Ansible runs) Full (Ansible) Niche DSC resource, no LCM wanted
DSC LCM ApplyAndAutoCorrect Yes (LCM) Yes None (per-node) Must-never-drift security state
Ansible ships MOF + sets LCM Yes (LCM) Yes Full (Ansible triggers) Best of both: orchestrated + self-healing

Detecting and auto-remediating configuration drift

Drift is the whole point, and you want remediation at two layers.

Layer one — the LCM’s own loop. With ApplyAndAutoCorrect, every Windows host with an applied MOF self-corrects on its consistency-check timer with no controller involved. This is the safety net that keeps a box compliant even if your Ansible control node is offline for a week. Audit what the LCM actually did from the operational event log:

# Did anything drift and get corrected on this node in the last 50 events?
Get-WinEvent -LogName 'Microsoft-Windows-DSC/Operational' -MaxEvents 50 |
    Where-Object Message -match 'not in the desired state|in desired state' |
    Select-Object TimeCreated, Id, Message | Format-Table -Wrap

Layer two — Ansible-driven detection and remediation. Run the converging play on a schedule (cron on the control node, or AWX/AAP). Because every task is idempotent, a scheduled run is drift remediation: anything that drifted gets pulled back, and the run’s change count tells you how much drifted.

# Detect only — check mode plus diff, never mutates
ansible-playbook site.yml --check --diff

# Remediate — drop --check; idempotent tasks correct drift in place
ansible-playbook site.yml --diff

Wire the change count into alerting. A clean nightly run should be changed=0; a non-zero count means something mutated state out-of-band between runs and is worth a ticket. Parse the play recap or emit machine-readable JSON via a callback plugin into your observability pipeline.

How each tool detects and remediates drift, side by side:

Capability DSC (LCM) Ansible
Detection mechanism Test-DscConfiguration on the consistency timer --check --diff on a schedule
Autonomous (no controller) Yes No (needs cron/AWX to run)
Remediation Automatic (ApplyAndAutoCorrect) On a non---check run
Detection cadence ConfigurationModeFrequencyMins (≥15) Whatever your scheduler sets
Drift record Microsoft-Windows-DSC/Operational event log Play recap / callback JSON
“How much drifted?” signal Event count of “not in desired state” Play recap changed=N
Works when network is down Yes No
Central reporting Machine Config / report server AAP dashboard / custom callback

The decision table for which drift-correction layer to lean on:

If the setting is… Correct it with
Security-critical and must survive a partition DSC LCM ApplyAndAutoCorrect
Application config that changes on each release Ansible scheduled converge
Something a human must approve before fixing DSC ApplyAndMonitor (alert, don’t auto-fix)
Cross-host and order-sensitive Ansible (handlers, serial)
Needing cloud compliance evidence Machine Configuration audit

Testing with Pester and ansible-lint in CI

Never ship a DSC configuration or an Ansible role that hasn’t been compiled and structurally validated in CI. On the DSC side, Pester (the PowerShell test framework) asserts that a configuration compiles to a MOF and that the MOF contains the resources you expect. Pester v5 syntax:

# WebBaseline.Tests.ps1
Describe 'WebBaseline configuration' {
    BeforeAll {
        . $PSScriptRoot\WebBaseline.ps1
        $out = Join-Path $TestDrive 'mof'
        WebBaseline -NodeName 'TEST01' -OutputPath $out
    }

    It 'compiles a MOF for the node' {
        Join-Path $TestDrive 'mof\TEST01.mof' | Should -Exist
    }

    It 'declares the IIS WindowsFeature resource' {
        Get-Content (Join-Path $TestDrive 'mof\TEST01.mof') -Raw |
            Should -Match 'MSFT_RoleResource'
    }
}
$result = Invoke-Pester -Path .\tests -PassThru
if ($result.FailedCount -gt 0) { throw "Pester failed: $($result.FailedCount)" }

On the Ansible side, lint and syntax-check on every push, and lean on --check against a throwaway test host in a later stage. For a real integration test, Molecule spins up an ephemeral target and runs the role plus an idempotence assertion (covered in depth in idempotent Ansible collections and Molecule testing):

ansible-lint roles/ playbooks/
ansible-playbook site.yml --syntax-check
molecule test        # converge, idempotence check, verify, destroy

A minimal GitHub Actions pipeline ties both together — the PowerShell job on a Windows runner (DSC compilation needs WMF 5.1), the lint job on Linux:

name: config-mgmt-ci
on: [push, pull_request]
jobs:
  dsc-pester:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install DSC resources
        shell: powershell
        run: Install-Module PSDscResources -Force -Scope CurrentUser
      - name: Run Pester
        shell: powershell
        run: |
          $r = Invoke-Pester -Path .\tests -PassThru
          if ($r.FailedCount -gt 0) { exit 1 }
  ansible-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install ansible-lint
      - run: ansible-lint roles/ playbooks/

The CI gates each tool needs, and what each catches before a node ever sees the change:

Gate Tool Catches Runner
MOF compilation Pester + Configuration Syntax errors, missing resources, bad params Windows
MOF content assertion Pester + Should -Match Wrong/removed resource, wrong value Windows
YAML/play syntax ansible-playbook --syntax-check Malformed YAML, unknown modules Linux
Best-practice lint ansible-lint Non-idempotent patterns, bad naming, deprecations Linux
Idempotence Molecule / --check twice Tasks that report changed on re-run Linux + ephemeral target
Static analysis (PS) PSScriptAnalyzer Style/bug rules in resources/configs Windows

Side-by-side worked baseline: the same state, three ways

To make the DSC-vs-Ansible-vs-both choice concrete, here is one baseline — IIS present, W3SVC running/automatic, SMBv1 server disabled — expressed in all three models. The prose above shows each in isolation; this is the direct comparison so you can see, line for line, what each style buys and costs.

The three implementations at a glance:

Facet DSC configuration (LCM push) Ansible role (ansible.windows) Ansible → win_dsc
Where logic runs Compiled to MOF, LCM applies on box Control node opens session, runs remotely Control node invokes DSC resource on box
Idempotency source Resource Test/Set Module internal check DSC resource Test/Set
Self-heals when control node down Yes (ApplyAndAutoCorrect) No No
Needs software on target LCM (always present) + resource modules Nothing (WinRM/SSH) DSC resource module on target
Ordering primitive DependsOn Task order + handlers Task order
Reboot handling LCM (RebootNodeIfNeeded) win_reboot guarded on reboot_required win_reboot
Secrets Certificate-encrypted MOF ansible-vault / external ansible-vault
Best when Must never drift, no controller Orchestrated, mixed OS, ad-hoc Niche DSC resource, no LCM

Model A — DSC configuration (source of truth compiled to MOF, enforced autonomously). This is the WebBaseline from earlier, paired with an ApplyAndAutoCorrect LCM so it self-heals every 30 minutes with no controller.

Model B — Ansible role (orchestrated, agentless). This is roles/web_baseline/tasks/main.yml from earlier, run on a schedule so each run remediates drift; it needs a live control node but coordinates across the whole fleet and mixed OS in one play.

Model C — Ansible driving DSC via win_dsc, so you get DSC’s resource quality under Ansible’s orchestration without standing up the LCM or a MOF pipeline:

# roles/web_baseline_dsc/tasks/main.yml — same state, DSC resources via Ansible
- name: IIS via DSC WindowsFeature resource
  ansible.windows.win_dsc:
    resource_name: WindowsFeature
    Name: Web-Server
    Ensure: Present

- name: W3SVC via DSC Service resource
  ansible.windows.win_dsc:
    resource_name: Service
    Name: W3SVC
    State: Running
    StartupType: Automatic

- name: SMBv1 off via DSC Registry resource
  ansible.windows.win_dsc:
    resource_name: Registry
    Key: HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters
    ValueName: SMB1
    ValueData: '0'
    ValueType: Dword
    Ensure: Present

The mature combined pattern is not “pick one” — it’s Ansible orchestrates, DSC’s LCM backstops the non-negotiables. Ansible owns application deployment and the broad config surface (Model B); a small, high-value MOF (SMBv1 off, TLS registry settings, a handful of services) runs under the LCM in ApplyAndAutoCorrect (Model A) so the security-critical subset self-heals regardless of control-node reachability. win_dsc (Model C) is the tactical bridge for the odd niche resource in between.

Architecture at a glance

Picture the two control planes stacked over the same fleet of Windows Servers, because that stacking is the architecture. Trace it from the top down.

At the top sits your source of truth in Git: DSC Configuration functions and LCM meta-configurations on one side, Ansible roles/playbooks/inventory on the other. Nothing reaches a node without passing the CI gate — Pester compiles every configuration to a MOF and asserts its contents on a Windows runner, while ansible-lint and --syntax-check (and, for real coverage, Molecule) validate every role on a Linux runner. A red build never ships.

Below CI, two delivery paths fan out to the fleet. The Ansible control node (or an AWX/AAP controller) reaches each Windows host agentlessly over a WinRM HTTPS listener on 5986 (Kerberos in the domain, NTLM-over-HTTPS outside it) or over SSH on 22; it opens a session, runs each task’s check-then-change logic in place, reports changed/ok, and disconnects. It converges the fleet on a schedule, coordinates cross-host sequencing, and can invoke DSC resources inline via win_dsc. In parallel, the DSC path compiles configurations to a MOF per node; a push (Start-DscConfiguration, itself often triggered by Ansible) hands that MOF to the LCM resident on every box, which stores it and — this is the load-bearing part — re-runs each resource’s Test, applies Set on drift, and repeats every ConfigurationModeFrequencyMins (≥15) with no controller involved.

On each Windows Server, then, two enforcement mechanisms coexist: the LCM autonomously holding the security-critical MOF in desired state on its own timer, and Ansible episodically converging the broader surface when it runs. For hybrid fleets, a third arrow points to the cloud: the Azure Machine Configuration guest-config agent (over Azure Arc) audits and optionally enforces the same kind of in-guest baseline and reports compliance to Azure Policy, giving you a cloud-side dashboard on top of the on-box loops. The reader following the flow sees the whole method: define state as code, gate it in CI, deliver it via Ansible (breadth, orchestration) and/or the LCM (autonomy, self-heal), and observe drift through DSC’s operational event log, Ansible’s change count, and Azure Policy compliance — three vantage points onto the single question “does production match its definition right now?”

The mnemonic for the whole picture: Ansible pushes when it runs; the LCM holds when nobody’s watching; Machine Config reports it to the cloud. Route each setting to the plane whose guarantee matches the setting’s blast radius.

Real-world scenario

Meridian Retail ran roughly 420 Windows IIS/middleware hosts across three regions — Central India (primary), plus DR in West Europe and Southeast Asia — configured by a single Ansible control node in the primary datacenter, on AAP. Traffic was seasonal; the platform team was five engineers; the config-management surface was “everything above the OS image”: IIS roles, app pools, a dozen services, TLS registry settings, and a CIS-derived security baseline. Nightly converges ran clean, and everyone trusted the setup — until a regional network event turned a latent design flaw into a compliance incident.

The trigger was an eleven-hour partition between the primary datacenter and the Southeast Asia region during a submarine-cable fault. With the control node unreachable, AAP couldn’t converge the SEA hosts. That alone would have been fine — the boxes kept serving. But during the same window, an application team hit a production incident on those hosts and, unable to wait for change control, RDP’d in and applied manual registry “hotfixes” directly, one of which re-enabled SMBv1 as a shot-in-the-dark fix for a file-share timeout. Nothing pulled that change back, because Ansible only converges when it runs — and it could not run. When connectivity returned, the nightly converge did correct most of it, but a subset of hosts had been rebooted mid-window and the converge skipped them on a transient WinRM error that nobody triaged. Two days later a quarterly audit scan flagged SMBv1 enabled on nine production hosts — a finding with regulatory teeth.

The post-incident analysis named the real flaw precisely: an agentless tool cannot remediate what it cannot reach. Ansible was the only enforcement plane, and its guarantee — “correct on the next run” — is worthless during a partition, which is exactly when humans are most likely to hand-edit production. The fix was not to abandon Ansible; it was to stop asking Ansible to do a job it structurally can’t, and let DSC’s LCM be the always-on backstop for the security-critical subset.

They carved out a small, high-value MOF — SMBv1 server disabled, the TLS 1.0/1.1 disable registry keys, three services pinned running, and the audit-policy registry values — and set every host’s LCM to ApplyAndAutoCorrect at 30 minutes. Ansible still owned application deployment and the broad configuration surface; the non-negotiable security state now self-healed independent of the control node.

[DSCLocalConfigurationManager()]
Configuration SecurityBackstop {
    Node localhost {
        Settings {
            RefreshMode                    = 'Push'
            ConfigurationMode              = 'ApplyAndAutoCorrect'
            ConfigurationModeFrequencyMins = 30
        }
    }
}
SecurityBackstop -OutputPath .\Backstop
Set-DscLocalConfigurationManager -Path .\Backstop -Verbose

They also fixed the operational miss: the AAP job now fails loudly on any host it can’t reach (rather than logging a transient error and moving on), and a nightly report compares each host’s Microsoft-Windows-DSC/Operational log for “not in desired state” events against a baseline, so LCM self-corrections are visible — a correction is a signal that someone touched production out of band.

The next partition, four months later, was a non-event: the SEA hosts kept re-applying the security MOF every half hour regardless of control-node reachability; when an engineer again disabled a service by hand during an incident, the LCM put it back within 30 minutes and the drift showed up in the nightly report as a ticket. The audit scan stayed green. The lesson the team wrote into their runbook, verbatim: “Ansible for orchestration and breadth; the LCM for autonomous drift correction on the settings that must never drift. Anything with a compliance consequence needs an on-box agent behind it — the network is not a dependency you get to assume.”

Costs barely moved: the LCM is in-box (no license, no new infrastructure), the MOF is tiny, and the 30-minute consistency check is negligible CPU. The only real cost was authoring and testing one small DSC configuration and its Pester tests — a day of work against a regulatory finding they never wanted to repeat.

Advantages and disadvantages

The two tools have genuinely different shapes, and the honest trade-off is not “which is better” but “which guarantee do you need for this setting.”

Dimension PowerShell DSC (LCM) — advantage / disadvantage Ansible — advantage / disadvantage
Autonomy + Self-heals with no controller (ApplyAndAutoCorrect) Only converges when it runs
Orchestration Per-node MOF; weak cross-host sequencing + Plays, handlers, serial, rolling updates
Agent footprint LCM always present (but in-box) + Agentless; nothing to install/patch on target
OS reach 5.1 DSC is Windows-only + One inventory for Linux + Windows
Ad-hoc “run now” No imperative one-off + ansible -m runs anything anywhere now
Learning curve MOF/LCM/pull mental model is steep + YAML + inventory is approachable
Ecosystem direction 5.1 frozen; pull server deprecated + Actively developed collections
Reporting at scale + Machine Config → Azure Policy dashboards + AAP dashboards + callbacks
Secrets MOF clear-text unless cert-encrypted + ansible-vault, external brokers, no artifact on target
Failure during partition + Keeps enforcing last-known-good Enforcement gap for the duration

DSC’s LCM is right when a setting must never drift even when the network is down — security baselines, compliance-relevant registry state, must-run services. Its autonomy is the whole point, and the deprecated pull server doesn’t matter if you push once and let ApplyAndAutoCorrect do the rest. It’s the wrong tool for cross-host orchestration, mixed-OS estates, and anything you need to run once, now, everywhere.

Ansible is right for orchestration, breadth, and mixed estates — deploy an app across a fleet in a controlled order, manage Linux and Windows from one inventory, run ad-hoc fixes, and coordinate multi-host changes with handlers and serial. Its weakness is the flip side of agentlessness: it can only enforce when it runs, so it cannot be your sole guarantee for state with a compliance consequence during an outage.

The mature answer, as the scenario shows, is both — Ansible as the orchestration plane, the LCM as the autonomous backstop for the non-negotiable subset, and win_dsc as the tactical bridge for niche resources.

Hands-on lab

Build the same baseline both ways on one Windows Server, prove idempotency and drift correction in each, then tear down. You need a Windows Server 2022 VM (Azure Standard_B2s in Central India is fine and cheap) with WMF 5.1 (in-box), and a Linux/WSL box with ansible-core for the Ansible half. All commands are copy-pasteable.

Step 1 — Prepare the Windows target (run in an elevated PowerShell on the VM).

Install-Module PSDscResources -Force -Scope AllUsers
Get-DscResource -Module PSDscResources | Select-Object Name    # confirm resources present

Expected: a list including WindowsFeature, Service, Registry, Group, Environment.

Step 2 — Author and compile the DSC configuration. Save as WebBaseline.ps1, then compile:

Configuration WebBaseline {
    param([string[]]$NodeName = 'localhost')
    Import-DscResource -ModuleName PSDscResources -ModuleVersion 2.12.0.0
    Node $NodeName {
        WindowsFeature IIS { Name = 'Web-Server'; Ensure = 'Present' }
        Service W3SVC {
            Name = 'W3SVC'; State = 'Running'; StartupType = 'Automatic'
            DependsOn = '[WindowsFeature]IIS'
        }
        Registry DisableSmb1Server {
            Key = 'HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters'
            ValueName = 'SMB1'; ValueData = '0'; ValueType = 'Dword'; Ensure = 'Present'
        }
    }
}
WebBaseline -OutputPath .\WebBaseline

Expected: WebBaseline\localhost.mof is created. Get-Content .\WebBaseline\localhost.mof shows MSFT_RoleResource, MSFT_ServiceResource, MSFT_RegistryResource.

Step 3 — Put the LCM in auto-correct mode, then apply.

[DSCLocalConfigurationManager()]
Configuration Lcm { Node localhost { Settings {
    RefreshMode = 'Push'; ConfigurationMode = 'ApplyAndAutoCorrect'
    ConfigurationModeFrequencyMins = 15; RebootNodeIfNeeded = $false } } }
Lcm -OutputPath .\Lcm
Set-DscLocalConfigurationManager -Path .\Lcm -Verbose
Start-DscConfiguration -Path .\WebBaseline -Wait -Verbose -Force

Step 4 — Validate and prove idempotency.

Test-DscConfiguration -Detailed          # InDesiredState : True
Start-DscConfiguration -UseExisting -Wait -Verbose   # re-apply: no resource reports a change

Expected: Test-DscConfiguration returns InDesiredState : True; the re-apply logs no Set.

Step 5 — Force drift and prove the LCM corrects it.

Stop-Service W3SVC
Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters' -Name SMB1 -Value 1
Start-DscConfiguration -UseExisting -Wait -Verbose    # simulate the consistency check now
(Get-Service W3SVC).Status                            # Running
(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters').SMB1  # 0

Expected: the service is back to Running and SMB1 back to 0. In production the LCM does this automatically every 15 minutes; -UseExisting just triggers it on demand.

Step 6 — Now the Ansible half (run from the Linux/WSL control node). Install collections and set inventory (assumes the WinRM HTTPS listener from the transport section is up):

ansible-galaxy collection install ansible.windows community.windows
cat > inventory.ini <<'EOF'
[web]
winlab.corp.example.com
[web:vars]
ansible_connection=psrp
ansible_port=5986
ansible_psrp_auth=ntlm
ansible_user=labadmin
ansible_password=<from-vault-in-real-life>
EOF
ansible -i inventory.ini web -m ansible.windows.win_ping

Expected: "ping": "pong".

Step 7 — Run the equivalent Ansible role and prove idempotency. Create site.yml:

- hosts: web
  tasks:
    - ansible.windows.win_feature: { name: Web-Server, state: present }
    - ansible.windows.win_service: { name: W3SVC, state: started, start_mode: auto }
    - ansible.windows.win_regedit:
        path: HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters
        name: SMB1
        data: 0
        type: dword
ansible-playbook -i inventory.ini site.yml            # first run: changed>0
ansible-playbook -i inventory.ini site.yml --check --diff   # second run: changed=0

Expected: the first run reports changed; the --check run reports changed=0 — your idempotency proof.

Step 8 — Teardown.

# On the Windows target: revert the LCM to defaults and remove applied config
Remove-DscConfigurationDocument -Stage Current, Pending, Previous
[DSCLocalConfigurationManager()]
Configuration LcmReset { Node localhost { Settings { ConfigurationMode = 'ApplyAndMonitor' } } }
LcmReset -OutputPath .\LcmReset; Set-DscLocalConfigurationManager -Path .\LcmReset
Uninstall-WindowsFeature Web-Server   # optional: remove IIS

If you spun up an Azure VM for this, delete its resource group (az group delete -n rg-dsc-lab --yes) to stop all charges.

Common mistakes & troubleshooting

The failure modes that eat the most time, with the exact way to confirm and the real fix:

# Symptom Root cause Confirm (exact command / path) Fix
1 LCM never corrects forced drift ConfigurationMode is ApplyAndMonitor or ApplyOnly (Get-DscLocalConfigurationManager).ConfigurationMode Reapply meta-config with ApplyAndAutoCorrect
2 ConfigurationModeFrequencyMins = 5 ignored Below the 15-min floor; silently clamped (Get-DscLocalConfigurationManager).ConfigurationModeFrequencyMins shows 15 Set ≥15 and stop expecting sub-15
3 MOF compile fails: “resource not found” Module not installed or version mismatch in Import-DscResource Get-DscResource -Module PSDscResources Install-Module the exact -ModuleVersion you import
4 Credential in MOF is plaintext No certificate encryption configured Open the .mof; the password is readable Add CertificateFile/Thumbprint config-data block
5 Ansible: win_command reports changed every run No creates/changed_when guard Re-run with --check — still changed Add creates:/removes: or changed_when:
6 Ansible over WinRM fails: connection refused No HTTPS listener / firewall on 5986 Test-NetConnection winhost -Port 5986 Create the HTTPS listener + firewall rule
7 Kerberos auth fails: “server not found in database” Wrong SPN / realm not in krb5.conf kinit user@REALM on the control node Fix realm/KDC config; register HTTP/ SPN
8 win_dsc fails: resource not found on target Resource module not present on the node On the node: Get-DscResource -Name <Resource> Install-Module the resource on the target
9 win_regedit perpetual changed type mismatch (string vs dword) Compare type: to the actual registry type Set type to the true registry value type
10 Config applies but reboots the box unexpectedly Resource requested reboot + RebootNodeIfNeeded=$true (Get-DscLocalConfigurationManager).RebootNodeIfNeeded Set $false; handle reboots deliberately
11 DSC v3 examples don’t work with Start-DscConfiguration Mixing DSC v3 docs into a 5.1 LCM deployment dsc --version vs $PSVersionTable.PSVersion Use the runtime that matches your artifact
12 Ansible converge “succeeds” but skipped a host Transient WinRM error swallowed; host unreached Grep the play recap for unreachable=1 Fail the job on unreachable>0; retry+alert
13 Machine Config policy shows “not applicable” Guest-config agent/managed identity missing Azure Policy compliance → resource details Enable system-assigned MI; install guest-config extension
14 Two tools fight over the same setting DSC and Ansible both manage the identical key Both a changed (Ansible) and a “not in desired state” (DSC) every cycle Assign each setting to exactly one owner

Two anti-patterns worth calling out beyond the table. Split-brain enforcement — DSC’s LCM and an Ansible converge both managing the same registry value — produces a perpetual flap (each tool “corrects” the other’s idempotent no-op timing) and pointless churn; assign every setting to a single owner. And imperative logic in a declarative document: if a DSC configuration contains if/foreach deciding whether to do something at apply time, that logic belongs inside a resource’s Test/Set, not in the MOF-producing script, or you lose idempotency and testability.

Best practices

Security notes

Configuration management touches every host with elevated rights, so it is a security surface — treat it accordingly and see gMSA, Windows LAPS and service-account credential hardening for the identity mechanics.

MOF credential encryption is mandatory, not optional. A MOF sits on the node’s disk in clear text. To ship a PSCredential, compile against the public half of a certificate and let the LCM decrypt with the private key at apply time:

$cfgData = @{ AllNodes = @(@{
    NodeName = 'WEB01'
    CertificateFile = 'C:\certs\dsc-pub.cer'   # public key, present at compile time
    Thumbprint = 'A1B2C3...'                    # private key installed on the node
}) }
# The configuration references $Node.Thumbprint; the LCM decrypts at apply time.

Least privilege for the automation identity. Ansible should authenticate as a scoped account, ideally a gMSA (no stored password) or certificate identity mapped to a local user, granted only the rights the plays need — not Domain Admin. For DSC pull/Machine Config, the guest-configuration agent runs under a system-assigned managed identity scoped to exactly its policy assignment.

Transport security. WinRM must be HTTPS (5986) with a valid certificate; disable Basic and CredSSP (CredSSP delegates full credentials to the target — a lateral-movement gift if the target is compromised). Prefer Kerberos, which never puts a reusable secret on the wire.

Secrets never in the artifact or the repo. ansible-vault encrypts variable files at rest; better still, fetch secrets at runtime from an external broker (see HashiCorp Vault as a central secrets broker) so nothing sensitive lives in Git or on the target longer than a play.

Enforce security state with the LCM specifically because it survives a partition. As the scenario showed, the settings with a compliance consequence — SMBv1 off, TLS versions, audit policy — belong under ApplyAndAutoCorrect so a hand-edit during an outage is reverted within the consistency window, not “at the next converge, whenever the network comes back.”

The security-relevant controls in each tool, and the safe setting:

Control DSC Ansible Safe setting
Credential at rest Certificate-encrypted MOF ansible-vault / external broker Never plaintext
Transport (LCM local; pull over HTTPS) WinRM HTTPS 5986 / SSH HTTPS + valid cert
Auth on the wire n/a (local) / cert (pull) Kerberos > NTLM > cert Avoid Basic/CredSSP
Automation identity Guest-config managed identity gMSA / cert-mapped local user Least privilege, not DA
Reboot safety RebootNodeIfNeeded deliberate win_reboot guarded No surprise reboots
Drift on security keys ApplyAndAutoCorrect Scheduled converge (weaker) LCM for must-never-drift

Cost & sizing

The good news for the wallet: both tools are open-source engines with no per-node license, and the biggest cost is your time authoring and testing, not runtime infrastructure. The nuances are in the control-plane and cloud pieces.

DSC costs effectively nothing to run: the LCM is in-box on every Windows Server, a MOF is a small text file, and a 30-minute consistency check is negligible CPU (a Test sweep of a handful of resources is milliseconds). There is no server to run if you use push + ApplyAndAutoCorrect. If you need pull/reporting, the cost shifts to Azure: Machine Configuration bills through Azure Policy guest configuration — free for Azure-native VMs, and a modest per-server monthly charge for Arc-enabled (on-prem/other-cloud) servers, plus the Arc agent itself (the agent is free; connected-machine management is the charge). Budget roughly the low hundreds of INR per Arc server per month for guest-config at time of writing; confirm current pricing before committing a large hybrid fleet.

Ansible is free as ansible-core; the cost is the control plane if you go beyond a cron’d control node. AWX (upstream) is free but self-supported; Ansible Automation Platform (AAP) is a paid subscription priced per managed node, which buys you the web UI, RBAC, scheduling, credential store, and support (detail in Ansible Automation Platform at enterprise scale). A single Linux control-node VM to run scheduled converges is cheap — an Azure Standard_B2s in Central India is on the order of low four-figure INR per month, and one node comfortably drives hundreds of Windows hosts.

Rough sizing and cost signals:

Item Cost driver Rough figure Notes
DSC LCM (push + auto-correct) None ₹0 In-box; the cheapest option that self-heals
Consistency-check overhead CPU per check Negligible (ms) Even at 15-min cadence on 800 nodes
Azure Machine Config (Azure VMs) Guest config Free Native VMs; audit + enforce
Azure Machine Config (Arc servers) Per connected server/mo ~low-hundreds INR/server/mo Plus Arc connectivity; verify current pricing
Ansible control node VM Compute ~₹1,500–3,500/mo (B2s) One node drives hundreds of hosts
AWX Self-support ₹0 (software) You run/patch/scale it
AAP subscription Per managed node Paid (quote) UI, RBAC, scheduling, support
Engineer time Authoring + CI + testing The real cost Amortised across every node forever

Sizing guidance: a single control node scales to hundreds of Windows hosts for scheduled converges; past ~500–1000 nodes or when you need RBAC/scheduling/audit, move to AWX/AAP with execution nodes near each region to cut WinRM round-trip latency. For DSC, the LCM scales trivially (each node enforces itself), so “DSC scaling” is really about delivery and reporting — push from Ansible for small fleets, Machine Configuration for large hybrid ones. The cheapest robust design for a must-never-drift baseline is push-once + ApplyAndAutoCorrect: zero ongoing infrastructure cost and autonomous enforcement.

Interview & exam questions

Q1. What are the three methods every DSC resource implements, and which one makes DSC idempotent? Get (report current state), Test (return a boolean: does current match desired?), and Set (make it match). Idempotency comes from the contract that Set runs only when Test returns $false — the Test-before-Set gate.

Q2. Explain the difference between ApplyOnly, ApplyAndMonitor, and ApplyAndAutoCorrect. ApplyOnly applies once and never touches the box again. ApplyAndMonitor applies, then logs drift to the event log but does not fix it (audit-only). ApplyAndAutoCorrect applies and re-applies on every consistency check — the self-healing mode you use for state that must never drift.

Q3. Why can’t Ansible alone be your compliance-enforcement mechanism for a partition-prone fleet? Ansible is agentless and converges only when it runs. During a network partition the control node can’t reach the hosts, so any drift introduced during the outage (e.g. a hand-edit) goes uncorrected until connectivity returns. State with a compliance consequence needs an on-box agent — DSC’s LCM — that enforces autonomously.

Q4. What is a MOF, and why must you never put a plaintext credential in one? A MOF (Managed Object Format) is the compiled, logic-free desired-state document the LCM enforces, stored on the node’s disk. Because it’s stored in clear text, an embedded PSCredential would be readable on disk; you must certificate-encrypt credentials via configuration data (CertificateFile + Thumbprint).

Q5. Distinguish Windows PowerShell 5.1 DSC from DSC v3. 5.1 DSC is WMI/CIM-based: Configuration functions, MOF, the LCM, Start-DscConfiguration, the (deprecated) pull server; Windows-only. DSC v3 is a standalone cross-platform dsc executable with no LCM, no MOF, and a JSON/YAML resource model, invoked imperatively (dsc config set); it powers Azure Machine Configuration.

Q6. What does win_dsc do, and what’s its main constraint? ansible.windows.win_dsc lets an Ansible play invoke any installed DSC resource directly (no MOF, no LCM), mapping resource properties to task parameters. Its constraint: the resource module must be present on the target and compatible with the WMF 5.1 DSC engine — DSC v3-only resources won’t load.

Q7. Which WinRM auth methods put credentials on the wire, and which don’t? Basic (base64) and CredSSP (delegated) put credentials on the wire — avoid unless required, and only over HTTPS. Kerberos (tickets) and certificate auth do not send a reusable secret; NTLM sends a hashed challenge/response and should still run over HTTPS. Prefer Kerberos in a domain.

Q8. You forced drift, ran the LCM’s consistency check, and nothing corrected. What’s the first thing to check? (Get-DscLocalConfigurationManager).ConfigurationMode. If it’s ApplyAndMonitor or ApplyOnly, the LCM won’t remediate — reapply a meta-configuration with ApplyAndAutoCorrect. Also confirm a MOF is actually stored (Get-DscConfiguration).

Q9. How do you prove an Ansible role is idempotent? Run it once (expect changed>0), then run it again with --check (or just re-run): a correctly authored role reports changed=0 on the second pass. Molecule automates this as an “idempotence” assertion in CI.

Q10. What is “split-brain” enforcement and how do you avoid it? It’s DSC and Ansible both managing the same setting, so each perpetually “corrects” the other’s timing and the value flaps with pointless churn. Avoid it by assigning every setting a single owner — DSC or Ansible, never both.

Q11. Why is ConfigurationModeFrequencyMins = 5 a mistake? It’s below the documented 15-minute floor and is silently clamped to 15. You’ll believe you’re checking every 5 minutes while the LCM checks every 15 — set a realistic value (30 is a good production default) and don’t design around sub-15 cadence.

Q12. When would you choose Azure Machine Configuration over the LCM push model? When you need cloud-reported compliance and enforcement across a hybrid fleet (Azure VMs + Arc-enabled on-prem/other-cloud servers) with an Azure Policy dashboard, rather than autonomous on-box enforcement with no cloud dependency. Machine Config also replaces the deprecated on-prem DSC pull server for reporting at scale.

Quick check

  1. Which DSC resource method must return a boolean, and what does the LCM do based on it?
  2. Name the ConfigurationMode value that makes the LCM remediate drift automatically.
  3. Your Ansible task uses win_command and reports changed on every run. What’s missing?
  4. Which two DSC runtimes exist, and which one has no LCM and no MOF?
  5. What single design change would have prevented the Meridian Retail SMBv1 audit finding?

Answers

  1. Test — it returns $true/$false; the LCM runs Set only when Test returns $false (drift). That gate is where idempotency lives.
  2. ApplyAndAutoCorrect — re-applies every ConfigurationModeFrequencyMins. (ApplyAndMonitor only logs; ApplyOnly never re-checks.)
  3. A creates/removes guard or a changed_when expressionwin_command/win_shell run every time and report changed unless you tell Ansible when a change actually happened.
  4. Windows PowerShell 5.1 DSC (WMI/CIM + LCM + MOF) and DSC v3 (standalone dsc executable). DSC v3 has no LCM and no MOF.
  5. Putting the security-critical settings under the LCM in ApplyAndAutoCorrect so they self-heal during the partition — instead of relying on Ansible, which can’t converge when it can’t reach the host.

Glossary

Next steps

windows-serverpowershell-dscansibleautomationidempotencydrift-remediationazure-machine-configwinrm
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

Keep Reading