Home / Azure

Azure Local | Bicep – Virtual Machines

Azure Local | Bicep – Virtual Machines


Reading Time: 8 minutes

An Azure Local cluster is valuable, but its primary purpose is to host and operate virtual machines. The platform is designed to run Windows and Linux VMs on-premises while providing seamless, cloud‑consistent management through the Azure portal. This allows organizations to centrally deploy, monitor, and maintain virtual machines using familiar Azure tooling and governance capabilities.

Deployment of virtual machines can be carried out in two ways.:

  • Azure Portal: which is convenient when you only need a single server and already have all required inputs, such as passwords and configuration details.
  • Azure DevOps: in combination with Azure Bicep

We won’t cover portal-based virtual machine deployment in this post, as anyone can deploy a VM to the cluster through the Azure portal. Instead, this post focuses on deploying virtual machines using Azure Bicep and Azure DevOps, enabling repeatable, automated, and fully integrated infrastructure workflows.

Basic knowledge of Azure DevOps and Azure Bicep is required. While I will cover many aspects throughout this post, a certain level of familiarity and foundational understanding is expected.

What are we deploying?

  • Arc Machine resource
  • Virtual machine instance
  • Network interface
  • Additional data disk
  • Automated domain join extension

Arc Machine resource

An Azure Arc machine is a non‑Azure server or VM connected to Azure, becoming a managed Azure resource with governance, policy, monitoring, and security capabilities.

Virtual Machine instance

An Azure Arc virtual machine resource represents a non‑Azure server or VM projected into Azure, enabling unified management, policies, monitoring, and governance like native Azure VMs

Network interface

An interface which is connected to the Virtual Machine instance for the onpremises connection.

Additional data disk

A different disk than the boot disk for your windows installation. This needs to be in a resource group

Automated domain join extension

To connect the new virtual machine to the domain, an extensions needs to be deployed.

Files

  • azlocal-bicep-new-virtualmachine.bicep– Main Bicep template
  • azlocal-bicep-new-virtualmachine.parameters.bicepparam– Parameter file with default values
  • azlocal-bicep-new-virtualmachine.yml– Azure DevOps pipeline for automated deployment
  • modules/– Modular Bicep components
    • arcMachine.bicep– Creates the Arc Machine resource
    • vmInstance.bicep– Deploys the VM instance
    • networkInterface.bicep– Configures network connectivity
    • virtualHardDisk.bicep– Creates additional data disks
    • domainJoin.bicep– Handles domain join extension

Parameters

ParameterTypeDescriptionExample
namestringName of the virtual machineTest-VM
locationstringAzure regionwesteurope
domainToJoinstringActive Directory domain to joinazurelocalbox.local
orgUnitPathstringOU path for domain joinOU=Servers,OU=Computers,DC=domain,DC=local
clusterRsgstringResource group containing HCI clusterazl-we-rsg-azl-koogaandezaan-01
customLocationNamestringName of the custom locationKoog-aan-de-Zaan
imageNamestringMarketplace gallery image name2025-datacenter-azure-edition-02
subscriptionIdstringAzure subscription ID
logicalNetworkNamestringLogical network namevnet101
keyVaultstringKey Vault nameazl-we-kv-cluster1-01
processorCoresintNumber of CPU cores4
memoryInGBintMemory in GB8
resourceTagsobjectResource tags{ owner: 'Name', Purpose: 'Description' }

In Azure DevOps, I created a variable group in the Library and linked it to the pipeline. This approach increases flexibility by allowing the pipeline to work with different variable groups while keeping all shared variables centrally managed. The parameters above can be set in the parameter file, but also in the variable group.

KeyVault

For sensitive values such as the localAdminPassword and domainJoinPassword, I created secrets in the Azure Key Vault associated with the cluster. This ensures secure storage and retrieval of these credentials. Of course, you can also use your own Key Vault if preferred.

Azure DevOps pipeline

So we have an Azure devOps pipeline. This makes it easier to deploy.

The begin situation before we run the Azure DevOps pipeline.

Running the pipeline gives some parameter options.

parameters:
- name: vmName
  displayName: 'Virtual Machine Name'
  type: string
  default: 'Test-VM'

- name: imageName
  displayName: 'Select Image'
  type: string
  default: '2025-datacenter-azure-edition-02'
  values:
  - '11-252-3'
  - '2022-datacenter-azure-edition-02'
  - '2025-datacenter-azure-edition-01'
  - '2025-datacenter-azure-edition-02'
  - 'win11-25h2-avd-m365-01'

- name: processorCores
  displayName: 'Processor Cores'
  type: number
  default: 4
  values:
  - 2
  - 4
  - 8
  - 16

- name: memoryInGB
  displayName: 'Memory (GB)'
  type: number
  default: 8
  values:
  - 4
  - 8
  - 16
  - 32

- name: environment
  displayName: 'Environment'
  type: string
  default: 'Production'
  values:
  - 'Production'
  - 'Development'
  - 'Test'

The YAML shown above defines the pipeline options. I included all available VM images as parameter choices, ensuring that each deployment uses a valid image already present on the cluster. This approach provides consistency, prevents referencing unavailable images, and keeps the pipeline flexible when new images are added.

The pipeline is setup in three stages:

  • Validate Azure Bicep template if there is any issue within the code which is not allowed
  • Check if the new to create vm already exists
  • Deploy to Azure Local
- stage: Validate
  displayName: 'Validate Bicep Template'
  jobs:
  - job: ValidateBicep
    displayName: 'Validate Bicep'
    steps:
    - task: PowerShell@2
      displayName: 'Display Deployment Parameters'
      inputs:
        targetType: 'inline'
        script: |
          Write-Host "===================================="
          Write-Host "Deployment Parameters"
          Write-Host "===================================="
          Write-Host "VM Name: ${{ parameters.vmName }}"
          Write-Host "Image: ${{ parameters.imageName }}"
          Write-Host "Processor Cores: ${{ parameters.processorCores }}"
          Write-Host "Memory (GB): ${{ parameters.memoryInGB }}"
          Write-Host "Environment: ${{ parameters.environment }}"
          Write-Host "Resource Group: $(resourceGroupName)"
          Write-Host "===================================="

    - task: AzureResourceManagerTemplateDeployment@3
      displayName: 'Validate Bicep Template'
      inputs:
        deploymentScope: 'Resource Group'
        azureResourceManagerConnection: $(azureSubscription)
        subscriptionId: $(subscriptionId)
        action: 'Create Or Update Resource Group'
        resourceGroupName: $(resourceGroupName)
        location: $(location)
        templateLocation: 'Linked artifact'
        csmFile: $(templateFile)
        csmParametersFile: $(parametersFile)
        overrideParameters: '-name "${{ parameters.vmName }}" -imageName "${{ parameters.imageName }}" -processorCores ${{ parameters.processorCores }} -memoryInGB ${{ parameters.memoryInGB }}'
        deploymentMode: 'Validation'

After validation is complete, the pipeline checks whether the virtual machine already exists. If an existing VM with the same name is found, the pipeline is terminated to prevent accidental overwrites or duplicate deployments.

- stage: CheckExistence
  displayName: 'Check VM Existence'
  jobs:
  - job: CheckVM
    displayName: 'Check if VM exists'
    steps:
    - task: AzureCLI@2
      displayName: 'Check for existing VM'
      name: CheckVMTask
      inputs:
        azureSubscription: $(azureSubscription)
        scriptType: 'bash'
        scriptLocation: 'inlineScript'
        inlineScript: |
          echo "Checking if VM '${{ parameters.vmName }}' exists in resource group '$(resourceGroupName)'..."

          # Check if VM exists
          vmExists=$(az hybridcompute machine show \
            --name "${{ parameters.vmName }}" \
            --resource-group $(resourceGroupName) \
            --subscription $(subscriptionId) \
            --query "name" -o tsv 2>/dev/null || echo "")

          if [ -n "$vmExists" ]; then
            echo "##vso[task.logissue type=error]VM '${{ parameters.vmName }}' already exists in resource group '$(resourceGroupName)'"
            echo "##vso[task.complete result=Failed;]VM already exists"
            exit 1
          else
            echo "VM '${{ parameters.vmName }}' does not exist. Proceeding with deployment."
          fi

If all checks pass and the status remains green, the virtual machine deployment will proceed to the next stage.

The entire deployment process can be monitored directly through the Azure Portal, where all provisioning steps and status updates are visible in real time.

- stage: Deploy
  displayName: 'Deploy to Azure Local'
  dependsOn: CheckExistence
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
  jobs:
  - deployment: DeployVirtualMachine
    displayName: 'Deploy VM - ${{ parameters.vmName }}'
    environment: 'Azure-Local-${{ parameters.environment }}'
    strategy:
      runOnce:
        deploy:
          steps:
          - checkout: self

          - task: PowerShell@2
            displayName: 'Pre-Deployment Summary'
            inputs:
              targetType: 'inline'
              script: |
                Write-Host "========================================="
                Write-Host "Starting VM Deployment"
                Write-Host "========================================="
                Write-Host "VM Name: ${{ parameters.vmName }}"
                Write-Host "Image: ${{ parameters.imageName }}"
                Write-Host "Configuration: ${{ parameters.processorCores }} cores / ${{ parameters.memoryInGB }} GB RAM"
                Write-Host "Environment: ${{ parameters.environment }}"
                Write-Host "Deployment Name: $(deploymentName)"
                Write-Host "========================================="

          - task: AzureResourceManagerTemplateDeployment@3
            displayName: 'Deploy Bicep Template'
            name: BicepDeployment
            inputs:
              deploymentScope: 'Resource Group'
              azureResourceManagerConnection: $(azureSubscription)
              subscriptionId: $(subscriptionId)
              action: 'Create Or Update Resource Group'
              resourceGroupName: $(resourceGroupName)
              location: $(location)
              templateLocation: 'Linked artifact'
              csmFile: $(templateFile)
              csmParametersFile: $(parametersFile)
              overrideParameters: '-name "${{ parameters.vmName }}" -imageName "${{ parameters.imageName }}" -processorCores ${{ parameters.processorCores }} -memoryInGB ${{ parameters.memoryInGB }}'
              deploymentMode: 'Incremental'
              deploymentName: $(deploymentName)
              deploymentOutputs: 'deploymentOutputs'

          - task: PowerShell@2
            displayName: 'Display Deployment Outputs'
            inputs:
              targetType: 'inline'
              script: |
                Write-Host "========================================="
                Write-Host "Deployment Completed Successfully!"
                Write-Host "========================================="
                Write-Host "VM Name: ${{ parameters.vmName }}"
                Write-Host "Deployment Name: $(deploymentName)"

                if ($env:DEPLOYMENTOUTPUTS) {
                  Write-Host ""
                  Write-Host "Deployment Outputs:"
                  Write-Host $env:DEPLOYMENTOUTPUTS
                }
                Write-Host "========================================="
            env:
              DEPLOYMENTOUTPUTS: $(deploymentOutputs)

          - task: AzureCLI@2
            displayName: 'Update Arc Gateway Settings'
            inputs:
              azureSubscription: $(azureSubscription)
              scriptType: 'bash'
              scriptLocation: 'inlineScript'
              inlineScript: |
                echo "Updating Arc Gateway settings for VM: ${{ parameters.vmName }}..."

                az arcgateway settings update \
                  --resource-group $(resourceGroupName) \
                  --subscription $(subscriptionId) \
                  --base-provider Microsoft.HybridCompute \
                  --base-resource-type machines \
                  --base-resource-name "${{ parameters.vmName }}" \
                  --gateway-resource-id "/subscriptions/$(subscriptionId)/resourceGroups/$(arcGwRsg)/providers/Microsoft.HybridCompute/gateways/$(arcGwName)"

                echo "Arc Gateway settings updated successfully!"

          - task: PowerShell@2
            displayName: 'Post-Deployment Summary'
            inputs:
              targetType: 'inline'
              script: |
                Write-Host "========================================="
                Write-Host "VM Deployment Complete"
                Write-Host "========================================="
                Write-Host "VM Name: ${{ parameters.vmName }}"
                Write-Host "Image: ${{ parameters.imageName }}"
                Write-Host "Specs: ${{ parameters.processorCores }} vCPU / ${{ parameters.memoryInGB }} GB RAM"
                Write-Host "Status: Successfully deployed and configured"
                Write-Host "Arc Gateway: Configured"
                Write-Host "========================================="

After the virtual machine is successfully created, I also include a step to register it with the Azure Arc Gateway. This ensures the VM becomes an Azure Arc–enabled resource, allowing centralized governance, monitoring, and management through Azure.

Within 12 minutes, a fresh virtual machine is deployed on my Azure Local.

After the deployment we will find the virtual machine within Azure ARC.

Brief sidenote:
Use the Bicep code wisely and the Azure DevOps pipeline at own risk. Just copy and paste is not recommended. There is some configuration required.

You can find the code on GitHub: https://github.com/GetToThe-Cloud/Website/tree/main/AzureBicep-AzureLocal-DeployVirtualMachines

Share and Enjoy !

Shares

Designer (23)

Stay close to the action—follow GetToThe.Cloud across social!
Deep dives and hands‑on how‑tos on Azure Local, hybrid cloud, automation, PowerShell/Bicep, AVD + FSLogix, image pipelines, monitoring, networking, and resilient design when the internet/Azure is down.

🔗 Our channels
▶️ YouTube: https://www.youtube.com/channel/UCa33PgGdXt-Dr4w3Ub9hrdQ
💼 LinkedIn Group: https://www.linkedin.com/groups/9181126/
✖️ X (Twitter): https://x.com/Gettothecloud
🎵 TikTok: https://www.tiktok.com/@gettothecloud
🐙 GitHub: https://github.com/GetToThe-Cloud/Website
💬 Slack: DM us for an invite
📲 WhatsApp: DM for the community link

We use cookies to personalise content and ads, to provide social media features and to analyse our traffic. We also share information about your use of our site with our social media, advertising and analytics partners. View more
Cookies settings
Accept
Privacy & Cookie policy
Privacy & Cookies policy
Cookie name Active

Who we are

Our website address is: https://www.gettothe.cloud

Comments

When visitors leave comments on the site we collect the data shown in the comments form, and also the visitor’s IP address and browser user agent string to help spam detection.An anonymized string created from your email address (also called a hash) may be provided to the Gravatar service to see if you are using it. The Gravatar service privacy policy is available here: https://automattic.com/privacy/. After approval of your comment, your profile picture is visible to the public in the context of your comment.

Media

If you upload images to the website, you should avoid uploading images with embedded location data (EXIF GPS) included. Visitors to the website can download and extract any location data from images on the website.

Cookies

If you leave a comment on our site you may opt-in to saving your name, email address and website in cookies. These are for your convenience so that you do not have to fill in your details again when you leave another comment. These cookies will last for one year.If you visit our login page, we will set a temporary cookie to determine if your browser accepts cookies. This cookie contains no personal data and is discarded when you close your browser.When you log in, we will also set up several cookies to save your login information and your screen display choices. Login cookies last for two days, and screen options cookies last for a year. If you select "Remember Me", your login will persist for two weeks. If you log out of your account, the login cookies will be removed.If you edit or publish an article, an additional cookie will be saved in your browser. This cookie includes no personal data and simply indicates the post ID of the article you just edited. It expires after 1 day.

Embedded content from other websites

Articles on this site may include embedded content (e.g. videos, images, articles, etc.). Embedded content from other websites behaves in the exact same way as if the visitor has visited the other website.These websites may collect data about you, use cookies, embed additional third-party tracking, and monitor your interaction with that embedded content, including tracking your interaction with the embedded content if you have an account and are logged in to that website.

Who we share your data with

If you request a password reset, your IP address will be included in the reset email.

How long we retain your data

If you leave a comment, the comment and its metadata are retained indefinitely. This is so we can recognize and approve any follow-up comments automatically instead of holding them in a moderation queue.For users that register on our website (if any), we also store the personal information they provide in their user profile. All users can see, edit, or delete their personal information at any time (except they cannot change their username). Website administrators can also see and edit that information.

What rights you have over your data

If you have an account on this site, or have left comments, you can request to receive an exported file of the personal data we hold about you, including any data you have provided to us. You can also request that we erase any personal data we hold about you. This does not include any data we are obliged to keep for administrative, legal, or security purposes.

Where we send your data

Visitor comments may be checked through an automated spam detection service.
Save settings
Cookies settings