Infrastructure as Code is no longer just about spinning up resources, it’s about making sure they’re configured correctly and securely from the start. With DSCv3 and Azure Guest Configuration, combined with Bicep as the deployment language, we can finally declare both infrastructure and its ongoing configuration in one clean, repeatable, DevOps-friendly package.

In this post I’ll walk you through deploying a Windows Server VM using Bicep and assigning a DSCv3 Guest Configuration that actually does something visible: sets the time zone, installs the Telnet Client, and writes a registry key. No custom script extensions, no pull servers, just native Guest Configuration doing its job.

Why DSCv3 Matters Now

Old-school DSC had its place, but it was heavily PowerShell-centric, awkward to integrate into modern pipelines, and required a pull server if you wanted anything close to ongoing compliance. That was a lot of infrastructure to maintain just to keep your infrastructure in shape.

DSCv3 changes the equation. With Guest Configuration, you now get:

  • Cross-platform support (Windows & Linux)
  • Modular, language-agnostic configurations
  • Drift detection and automatic remediation
  • Native Bicep integration, no custom script extensions needed

The Guest Configuration extension handles the heavy lifting. Your Bicep template just points at the configuration and Azure takes it from there.

What We’re Building

By the end of this post we’ll have:

  • A Windows Server 2022 VM deployed via Bicep
  • A storage account to host the DSC package
  • A DSCv3 configuration that:
    • Sets the system time zone to W. Europe Standard Time
    • Installs the Telnet Client Windows feature
    • Writes a registry key as a deployment marker
  • A native Guest Configuration Assignment wiring it all together

All the files are available in the resources folder: main.bicep {:target="_blank"}, storage.bicep {:target="_blank"}, and EnforceSecureBaseline.ps1 {:target="_blank"}.

Things I Learned the Hard Way

Before diving into the how-to, here are the gotchas that aren’t in the official docs and will catch you out if you don’t know about them.

Use PSDscResources, not PSDesiredStateConfiguration. The New-GuestConfigurationPackage cmdlet cannot bundle resources from the built-in PSDesiredStateConfiguration module. You need PSDscResources instead, which is the community-maintained equivalent. This affects File, Registry, WindowsFeature, and others.

The configuration name must match the assignment name. The GC agent looks for a MOF file named after the assignment, EnforceSecureBaseline.mof in our case. If your configuration is named BaselineConfig but the assignment is EnforceSecureBaseline, the agent crashes every cycle with a cryptic worker error.

Generate the checksum file manually. New-GuestConfigurationPackage should create a .checksum file alongside the MOF, but doesn’t always. Without it, the GC worker fails to publish the assignment. Generate it yourself before packaging:

$hash = Get-FileHash "C:\DSCOutput\EnforceSecureBaseline.mof" -Algorithm SHA256
$hash.Hash | Set-Content "C:\DSCOutput\EnforceSecureBaseline.checksum" -NoNewline

Set context: '' in your Bicep assignment. If you leave this unset, Azure defaults it to "Policy", which forces the GC agent into MonitorOnly mode, it will detect drift but never remediate, regardless of what your package says. This one took a while to find.

Package with -Type AuditAndSet. Without this flag, the package doesn’t support remediation and the agent logs assignment package does not support remediation and stops.

The DSC Configuration

Save this as EnforceSecureBaseline.ps1. The configuration name must match the assignment name exactly:

configuration EnforceSecureBaseline {
    Import-DscResource -ModuleName PSDscResources -ModuleVersion 2.12.0.0
    Import-DscResource -ModuleName ComputerManagementDsc

    Node localhost {

        Registry CreateMarker {
            Key       = "HKLM:\SOFTWARE\DSCv3Demo"
            ValueName = "DeployedBy"
            ValueData = "DSCv3"
            ValueType = "String"
            Ensure    = "Present"
        }

        TimeZone SystemTimeZone {
            IsSingleInstance = "Yes"
            TimeZone         = "W. Europe Standard Time"
        }

        WindowsOptionalFeature TelnetClient {
            Name   = "TelnetClient"
            Ensure = "Present"
        }
    }
}

EnforceSecureBaseline -OutputPath C:\DSCOutput

A couple of things worth noting. WindowsOptionalFeature from PSDscResources takes TelnetClient without a hyphen and uses "Present" not "Enable". TimeZone comes from ComputerManagementDsc, this is the right approach rather than writing registry keys directly. The Registry resource gives us a visible deployment marker we can check after the fact.

The Bicep Template

We use two Bicep files. storage.bicep creates the storage account and outputs a SAS URL for the package. main.bicep deploys the VM and assignment once the package is uploaded.

Here’s the Guest Configuration Assignment resource, the key piece linking the VM to the DSC package:

resource guestConfigAssignment 'Microsoft.GuestConfiguration/guestConfigurationAssignments@2024-04-05' = {
  name: 'EnforceSecureBaseline'
  scope: vm
  location: location
  dependsOn: [ guestConfigExtension ]
  properties: {
    context: ''  // Must be empty, 'Policy' forces MonitorOnly mode
    guestConfiguration: {
      name: 'EnforceSecureBaseline'
      version: '1.0'
      contentUri: dscContentUri
      contentHash: dscContentHash
      assignmentType: 'ApplyAndAutoCorrect'
      configurationSetting: {
        configurationMode: 'ApplyAndAutoCorrect'
        actionAfterReboot: 'ContinueConfiguration'
        allowModuleOverwrite: true
        rebootIfNeeded: true
        refreshFrequencyMins: 30
        configurationModeFrequencyMins: 15
      }
    }
  }
}

Three things the full template handles that are easy to miss:

The VM needs a system-assigned managed identity (identity: { type: 'SystemAssigned' }). The GC agent uses it to report compliance back to Azure. Without it the assignment sits in Pending indefinitely.

The Guest Configuration extension must be deployed on the VM before the assignment. It doesn’t come automatically:

resource guestConfigExtension 'Microsoft.Compute/virtualMachines/extensions@2023-09-01' = {
  parent: vm
  name: 'AzurePolicyforWindows'
  location: location
  properties: {
    publisher: 'Microsoft.GuestConfiguration'
    type: 'ConfigurationforWindows'
    typeHandlerVersion: '1.0'
    autoUpgradeMinorVersion: true
    enableAutomaticUpgrade: true
  }
}

The dependsOn: [guestConfigExtension] on the assignment enforces ordering, skip it and you’ll get a race condition where the assignment is created before the VM is ready.

Try It Yourself

All commands are PowerShell on Windows.

Step 1: Install required modules

Install-Module -Name PSDscResources -Force
Install-Module -Name ComputerManagementDsc -Force
Install-Module -Name GuestConfiguration -Force

Step 2: Compile and package the configuration

# Compile
.\EnforceSecureBaseline.ps1

# Rename the MOF, must match the assignment name
Rename-Item "C:\DSCOutput\localhost.mof" "C:\DSCOutput\EnforceSecureBaseline.mof"

# Generate the checksum
$mofHash = Get-FileHash "C:\DSCOutput\EnforceSecureBaseline.mof" -Algorithm SHA256
$mofHash.Hash | Set-Content "C:\DSCOutput\EnforceSecureBaseline.checksum" -NoNewline

# Package with AuditAndSet
New-GuestConfigurationPackage `
  -Name "EnforceSecureBaseline" `
  -Configuration "C:\DSCOutput\EnforceSecureBaseline.mof" `
  -Path "C:\GCPackage" `
  -Type AuditAndSet `
  -Force

# Save the hash, you'll need it in Step 4
$hash = Get-FileHash "C:\GCPackage\EnforceSecureBaseline.zip" `
  -Algorithm SHA256 | Select-Object -ExpandProperty Hash
Write-Host "Package hash: $hash"

Step 3: Deploy storage and upload the package

az group create --name dscv3-test --location westeurope

az deployment group create `
  --resource-group dscv3-test `
  --template-file storage.bicep

# Upload using the storage account key
$key = az storage account keys list `
  --account-name dscv3democonfigs `
  --resource-group dscv3-test `
  --query "[0].value" --output tsv

az storage blob upload `
  --account-name dscv3democonfigs `
  --container-name configs `
  --name EnforceSecureBaseline.zip `
  --file "C:\GCPackage\EnforceSecureBaseline.zip" `
  --account-key $key `
  --overwrite

Step 4: Build the parameters file and deploy the VM

$uri = (az deployment group show `
  --resource-group dscv3-test `
  --name storage `
  --query "properties.outputs.dscContentUri.value" `
  --output tsv)

@"
{
  "adminPassword": { "value": "<YourP@ssword123>" },
  "dscContentUri": { "value": "$uri" },
  "dscContentHash": { "value": "$hash" }
}
"@ | Set-Content C:\params.json

az deployment group create `
  --resource-group dscv3-test `
  --template-file main.bicep `
  --parameters @C:\params.json

Note on the SAS token: storage.bicep outputs a SAS URL with a 2-hour expiry. If you wait longer than that before deploying the VM, redeploy storage.bicep first to get a fresh token, it’s idempotent and won’t affect existing blobs.

Note on passing parameters: If you hit shell escaping issues with the SAS URL (the & characters in the query string break PowerShell), always use a parameters file rather than inline --parameters. The @C:\params.json approach sidesteps this entirely.

The VM deployment takes 5–8 minutes. Guest Configuration will evaluate and apply the configuration within 15 minutes of the assignment being created.

Verification Checklist

RDP into the VM using the public IP from the deployment output:

mstsc /v:<publicIpAddress>

Then run through these checks. All three should pass:

Check 1, Registry marker:

Get-ItemProperty "HKLM:\SOFTWARE\DSCv3Demo"

Expected: DeployedBy = DSCv3

Check 2, Time zone:

(Get-TimeZone).Id

Expected: W. Europe Standard Time

Check 3, Telnet Client:

(Get-WindowsOptionalFeature -Online -FeatureName TelnetClient).State

Expected: Enabled

Check 4, Compliance status from Azure:

az guestconfig guest-configuration-assignment show `
  --name EnforceSecureBaseline `
  --resource-group dscv3-test `
  --vm-name dscv3demo-vm `
  --output json

Look for complianceStatusPropertiesComplianceStatus: Compliant in the output.

Bonus, test drift remediation:

Delete the registry key manually on the VM:

Remove-Item "HKLM:\SOFTWARE\DSCv3Demo" -Force

Wait 15 minutes and check again. It will reappear, that’s ApplyAndAutoCorrect doing exactly what it should.

Cleanup when you’re done:

az group delete --name dscv3-test --yes --no-wait

Final Thoughts

What I like about this approach is how much it consolidates. In the past I’d have a VM deployment in one place, a custom script extension doing post-provisioning work in another, and drift monitoring bolted on as an afterthought. With DSCv3 and Guest Configuration in Bicep, it all lives together.

The ApplyAndAutoCorrect mode is what makes this genuinely useful rather than just another compliance checkbox. If someone manually removes Telnet Client or wipes the registry key, Azure puts it back on the next evaluation cycle without any intervention.

That said, getting here wasn’t frictionless. The documentation around PSDscResources vs PSDesiredStateConfiguration, the context field behaviour, and the checksum requirement are things you won’t find clearly explained in the official docs. Hopefully the “learned the hard way” section at the top saves you the time it took me to track them all down.

If you’re still using Custom Script Extensions for post-deployment configuration, it’s worth looking at whether Guest Configuration can replace them. For stateful, ongoing configuration it’s a much better fit.