Azure DevOps – Automating Domain Controller promotion

For a while I have been deploying infrastructure using Azure DevOps, and in particular deploying demo environments which need to be spun up and torn down again, but also to demonstrate the power of Infrastructure as Code.

One issue I’ve faced with a pure ARM/Bicep or Terraform deployment is the promotion of a Domain Controller… it is straightforward to deploy the necessary roles to a VM via something like a custom script extension, but the actual promotion of a Domain Controller (plus any setup of Lab OUs or Accounts and more) presents some issues due to reboots and connection methods. We can circumvent this in a few ways; manually, via a tool like Ansible, Azure DSC, WinRM, etc etc. However, if you are using Azure DevOps – there is an easier way! 🙂

The Azure DevOps way – within a Release Pipeline

When using a release pipeline – we can call a PowerShell script using az vm run-command, and then run this on our soon-to-be Domain Controller VM. This, combined with a predefined PowerShell script, allows us to promote a Domain Controller, and if required, configure things like OUs, Accounts, and more. This needs just 3 simple task steps…

If having a Password inside your repo is not an issue for you you can just move straight to step 3!

  1. Getting the required password from a Key Vault
  2. Replacing a token within a PowerShell File with the Key Vault Value (Safe Mode Admin Password). This is so we don’t expose the password in our repo.
  3. Running the script against our chosen VM

The Release Pipeline in Azure DevOps has these tasks in:

Just to note – you will obviously need a VM deployed with the required Domain Controller roles installed, and also a Key Vault with the administrator password saved within. If you want to deploy this programmatically – check out my Base Lab Environments. I usually use a Custom Script Extension (also in my Base Labs) to install these. This post just covers the promotion of the VM to a functional Domain Controller. 

Breaking down the Pipeline tasks

To understand why Tasks 1 and 2 are required, we need to look at the PowerShell used in step 3. This is shown below:

$password = ConvertTo-SecureString '__vmpassword__' -AsPlainText -Force
Import-Module ADDSDeployment
Install-ADDSForest `
-CreateDnsDelegation:$false `
-DatabasePath "E:\windows\NTDS" `
-DomainMode "WinThreshold" `
-DomainName "ad.lab" `
-DomainNetbiosName "ad" `
-ForestMode "WinThreshold" `
-InstallDns:$true `
-LogPath "E:\windows\NTDS" `
-NoRebootOnCompletion:$false `
-SysvolPath "E:\windows\SYSVOL" `
-Force:$true `
-SafeModeAdministratorPassword: $password

Note that the $password variable, which is used (at the very end) as the Safe Mode Administrator password, is shown as ‘__vmpassword__’. This is a variable within Azure Devops, which I have created in my release Pipeline:

Because we are using this variable within the PowerShell, we have no need to store the Password within the script itself, so our repo does not leave us exposed from a security perspective. With this variable in mind – steps 1 and 2 now become clear…

  1. In step 1 – we access Azure Key Vault and get this variable, which is then usable by the Pipeline.
  2. In step 2 – we use the token replacement task – so that our ‘__vmpassword__’ section within our PowerShell script, is replaced by the actual password taken from the Azure Key Vault.

This means we can securely access the Password, and have no need to store it anywhere other than the Key Vault.

Pipeline Task details

To provide as much information as possible, let’s examine the Pipeline tasks in detail. I’ve also provided the YAML output for each task below.

Step 1 – Getting the Key Vault secret

This step is really simple to configure – we just need to provide the Subscription, Key Vault name, and a filter for the secret we want to pull from the Key Vault. This can be used for much more than just what I am covering in this post – different scripts, setup options and more, for example.


- task: AzureKeyVault@1
  displayName: 'Azure Key Vault Secret'
    azureSubscription: 'jwnetworks MSDN (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)'
    KeyVaultName: demolabxxxxxxxx
    SecretsFilter: vmpassword
Step 2 – Replacing tokens in the Domain Setup script

Within this section, any variables within our repo files (set below in the Root directory), are replaced with variables in our Release Pipeline. Because we have used the Key Vault step above, and defined this variable within the pipeline variables, the variable ‘__vmpassword__’ is replaced with the value from the Key Vault.


- task: qetza.replacetokens.replacetokens-task.replacetokens@3
  displayName: 'Replace tokens in PowerShell Domain Setup'
    rootDirectory: '$(System.DefaultWorkingDirectory)/Terraform, Packer/Terraform/PowerShell'
    targetFiles: DomainSetup.ps1
    escapeType: none
    tokenPrefix: '__'
    tokenSuffix: '__'
Step 3 – Running the script on the VM

Finally, we are ready to run the script – and get our VM promoted to a Domain Controller. Note: in the below, remember to set the working directory… otherwise you’ll spend an hour wondering why things aren’t working (oops!). This section runs the PowerShell file on an Azure VM, by using the az vm run-command CLI command. The command you need to run within this step is shown below:

call az vm run-command invoke --command-id RunPowerShellScript --name region1-dc01-vm -g demolabv1-region1infra --scripts @DomainSetup.ps1


- task: AzureCLI@2
  displayName: 'Azure CLI to run PowerShell on DC VM'
    azureSubscription: 'jwnetworks MSDN (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)'
    scriptType: batch
    scriptLocation: inlineScript
    inlineScript: 'call az vm run-command invoke --command-id RunPowerShellScript --name region1-dc01-vm -g demolabv1-region1infra --scripts @DomainSetup.ps1'
    workingDirectory: '$(System.DefaultWorkingDirectory)/Terraform, Packer/Terraform/PowerShell'
Step 4 – what else?

Worth noting here, is that the same method of remotely invoking PowerShell can be used in another step to setup extra elements or carry out additional work. For example, I use the below script to setup some Lab OUs and a test account for any lab environment I provision using Azure DevOps:

#Setup Variables
$DCRoot = "DC=ad,DC=lab"
$LabDCRoot = "OU=Lab,DC=ad,DC=Lab"
$password = $password = ConvertTo-SecureString '__userpassword__' -AsPlainText -Force
#Create Root Lab OU
New-ADOrganizationalUnit -Name "Lab" -Path $DCRoot -ProtectedFromAccidentalDeletion $False -Description "Lab Environment"
#Create Other OUs
New-ADOrganizationalUnit -Name "Users" -Path $LabDCRoot -ProtectedFromAccidentalDeletion $False -Description "Lab Users"
New-ADOrganizationalUnit -Name "Service Accounts" -Path $LabDCRoot -ProtectedFromAccidentalDeletion $False -Description "Lab Service Accounts"
New-ADOrganizationalUnit -Name "Servers" -Path $LabDCRoot -ProtectedFromAccidentalDeletion $False -Description "Lab Servers"
New-ADOrganizationalUnit -Name "WVD" -Path $LabDCRoot -ProtectedFromAccidentalDeletion $False -Description "Lab WVD Session Hosts"
New-ADOrganizationalUnit -Name "Computers" -Path $LabDCRoot -ProtectedFromAccidentalDeletion $False -Description "Lab Computers"
New-ADOrganizationalUnit -Name "ANF" -Path $LabDCRoot -ProtectedFromAccidentalDeletion $False -Description "Lab ANF Objects"
#Create Test Account within Domain
New-ADUser -Name "Test Account" -AccountPassword $password -DisplayName "Test Account" -EmailAddress "testaccount@ad.lab" -Enabled $True -GivenName "Test" -Path "OU=Users,OU=Lab,DC=ad,DC=Lab" -SamAccountName "testaccount" -Surname "Test" -UserPrincipalName "testaccount@ad.lab"

Note that in the above example, I am also using the variable ‘__userpassword__’ so that I can create and store this within a Key Vault, and then allow Azure DevOps to replace it with the correct value when run. This ensures no passwords are exposed in our Repos.


Hopefully this has been helpful – and shows how simply (and securely!) a Domain Controller can be promoted within a few simple steps in an Azure DevOps Release Pipeline. Until next time 🙂