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.bicepoutputs a SAS URL with a 2-hour expiry. If you wait longer than that before deploying the VM, redeploystorage.bicepfirst 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.jsonapproach 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.
