In one of my projects, I developed an Azure-based solution that enables users to provision a virtual machine (VM) or an Azure Virtual Desktop (AVD) instance through a simple web form. The goal was to provide a secure, fast, and scalable self-service model that allows internal employees to deploy resources without needing direct access to the Azure portal.
Contents
🔧 Architecture Overview
This solution is built entirely using native Azure services and relies on ARM templates for provisioning. The orchestration flow is straightforward and consists of the following components:
- Web Form (static HTML hosted on Azure Storage): A basic frontend for testing purposes, designed to be replaced by company-specific frontends.
- Logic App: Validates user input (especially the UPN) and writes to an Azure Storage Queue.
- Function App: Triggered by new messages in the queue, it processes the request and initiates the ARM deployment from a nestedTemplate located in GitHub.
- ARM Templates: Define all infrastructure logic for provisioning VMs or AVDs, including networking components, extensions, and configuration.
- Azure Key Vault: Stores passwords and other sensitive data securely.
- Azure Storage Queue: Acts as the decoupled trigger mechanism between the Logic App and the Function App.
🧾 Input Options (via Web Form)

Users can provide the following inputs:
Field | Description |
---|---|
Type | Choose between Standard VM or AVD |
Operating System | Windows 11, Windows Server 2022, or Windows Server 2025 (depending on selection) |
Public IP | Yes/No (for Standard VMs only) |
AD Join | Join to Active Directory – Yes/No |
AAD Login Extension | Enable Azure AD Login extension – Yes/No |
User Principal Name (UPN) | Used to identify and validate the user |
Application Name | Used as the VM name base (for Standard VMs only) |
🔐 Security & Permissions
The solution uses Managed Identities to handle communication securely:
- The Logic App is granted Queue Contributor and Entra Directory Reader roles to write to the queue and validate UPNs.
- The Function App accesses Key Vault to retrieve credentials or other secrets during deployment. Also the Function App require access to a storage account to store functions and more while it is also required to give the Function App the needed rights to deploy resources at a specific scope – in my case it is the Contributor role on a seperate ResourceGroup for VMs.
Currently, no user authentication is enforced on the form. Instead, the Logic App validates the submitted UPN. For production-grade use, integrating Entra ID authentication or embedding the logic into an existing secure portal would be the next step.
⚙️ Deployment Workflow
- A user submits the form.
- The Logic App validates the UPN and writes a message to the queue.
- The Function App is triggered and reads the input.
- The Function App starts the deployment via ARM template.
- A working VM or AVD is available within 5–10 minutes.
From form submission to deployment start, the process typically takes less than 1 minute, depending on the Function App’s startup latency.
💡Deployment Solution
Recommended seperation:
- Resourcegroups
- OrderAutomation (main)
- LogicApp
- FunctionApp
- API Connections
- Storage Account
- Queue
- OrderInfrastructure (optional)
- Storage Account as Static Website
- Key Vault – Admin Passwords
- OrderVirtualMachines (optional)
- AVD Hostpool
- Virtual Network & Subnet
- OrderAutomation (main)

1. Optional Resources
These Resources aren’t optional at all but I call them like that because they aren’t part of the automation process and I could imagine that you implement these in other ways to your productive environment.
1.1 Frontend Solution and Key Vault
At first we create the required resources in a seperate resource group which I called OrderInfrastructure.

After deploying the resources I had to make sure that the storage account can be used as a static website.
This will create a blob container inside of the storage account where you can store your html source code.

🔒Make sure that your secrets for the admin accounts are stored in the Azure KeyVault!
After enabling the static website I have to upload my HTML code. Because the HTML code needs the LogicApps HTTP URL I can upload the code later after created the automation resources or upload it now and insert the url later. In my case I upload it right now.
You can find my code here on GitHub


Then I can copy the static websites URL and review my web content.

1.2 VM related resources – VNet & Hostpool
I created a resource group for this to seperate VMs and their related resources from the automation or infrastructure resources.
To keep it simple I did this manually but if you need help for doing it as IaC – feel free to contact me.

2. Automation
This part is way more complex than the previous mentioned resources – so i would recommend to deploy it via my ARM Template:
This creates the LogicApp incl. the needed logic and api connections, a function app, the storage account with the queue service and the managed identities including the role assignments which they need inside the same resource group.


2.1 Implement LogicApp in your Code
Then you can copy the Logic App URL and implement it inside of your Frontend.

In my case I replaced the wildcard inside of my web frontend source code which is already located in the storage account (1.1).
3. Roles & Rights
To provide a high level of security we use managed identities and rbac roles.
3.1 Summary
- LogicApp – Managed Identity
- Role: Storage Queue Data Contributor – Scope: Queues’ Storage Account (automatic via ARM Template)
- Entra Role: Directory Reader – to validate UPN (manually)
- Function App – Managed Identity
- Role: Storage Blob Data Owner – Scope: Storage Account
- Role: Key Vault Secrets User – Scope: Key Vault with local admin & domain join admin Passwords (manually)
- Role: Contributor – Scope: Resource Group for VirtualMachines (manually)

3.2 Steps
Entra Role Directory Reader


Key Vault Secrets User



Contributor – Feel free to make a custom role if you want to be more precise



4. Additional Steps
At this point every resources & permissions are given.
We got:
- the Frontend which got the LogicApps URL implemented and triggers the LogicApp successfully
- the LogicApp which is able to validate the UPN and writes messages in the Queue
- the KeyVault with the stored Secrets
- the AVD Hostpool, VNet and Subnet for the VirtualMachines
- all the roles and permissions
But what about the FunctionApp?
At this point a FunctionApp and the ServerFarm are existing but there is no function and no trigger yet, no script code which handles the incoming queue messages.
4.1 Upload the function code
There are two ways.
Upload the code via a ZIP file and Azure CLI
- Download the code from my GitHub
- ZIP the content
- Upload it via CLI

Create the function manually and copy code

- make sure that the function is an powershell function & got a queue trigger (connected to your storage queue)
- use the run.ps1 and replace your functions’ ps1 with the provided powershell script
- make sure that you install the right modules in your requirements.psd1
- Recommendation: use the required modules instead of Az = 14.*

4.2 Add Environment Variables
Many variables get filled by the queue message but there are some more variables which are needed by the function app. These are more about the infrastructure and less about the virtual machine itself.
When you upload the code via ZIP & CLI these will already exists and you just need to fill them with your environmental values.

✅ Test & Succeed
Validate the UPN by entering a non existing UPN and a existing UPN.


Check the LogicApp

Check the Queue

The function will get triggered by the new queue message and will run the code and start a new template deployment from the nested template on GitHub

Standard VM with Application Name & Public IP:

Azure Virtual Desktop VM with AADLogin Extension and adding the VM as a AVD SessionHost:


🔭 Future Enhancements
Some planned or possible improvements
- Entra ID login integration to the frontend
- Status tracking and history per request
- Support for additional resource types (e.g. Blob Storage, Key Vaults, …)
- Code as terraform for simplicity
📌 Conclusion
This project demonstrates how to leverage Azure-native components to build a flexible, secure, and fast self-service provisioning solution. Whether you want to enable developers, automate IT processes, or prototype new offerings — this foundation can be extended to suit many real-world scenarios.
If you’re interested in learning more — feel free to reach out!
⚙️ Code Overview
run.ps1 – FunctionApp
<#
.TITLE
VM Order - AppFunction Handler
.SYNOPSIS
React to new Azure Queue entry and start VM deployment
.DESCRIPTION
This PowerShell script was designed to act as an app function with a queue trigger. It reads the queue message and the function apps environment variables to create a resource group deployment which is based on an ARM-Template.
.TAGS
Automation, PowerShell, VirtualMachine, Order, Project
.MINROLE
Key Vault Secrets User
Contributor
.PERMISSIONS
.AUTHOR
Simon Vedder
.VERSION
1.0
.CHANGELOG
1.0 - Initial release
.LASTUPDATE
2025-06-08
.NOTES
- Required Resources:
- Key Vault = if you want to store your passwords securely. Use your existing one
- Resource Group = the resource group where you want to store your VirtualMachines
.USAGE
- Automatically get triggered by a new queue entry
#>
param($QueueItem, $TriggerMetadata)
# --- URL to my VM ARM Template ---
# Main resources: VirtualMachine, Network Security Group, Network Interface
# optional resources (depending on your order): Public IP, ADJoin Extension, EntraLogin Extension, AddSessionHost Extension
$vmDeploymentTemplateUrl = "https://raw.githubusercontent.com/simon-vedder/projects/refs/heads/main/order_vm/nestedTemplates/_vm.json"
try {
# --- 1. JSON Input Processing ---
Write-Host "Processing incoming JSON request..."
$vmType = $QueueItem.vmType # Standard or avd
$vmSize = $QueueItem.vmSize
$publicIp = $QueueItem.publicIp
$adJoin = $QueueItem.adJoin
$entraExt = $QueueItem.entraExt
$os = $QueueItem.os
$application = $QueueItem.application
# --- 2. Key Vault Read for Admin Password and AD Join Password ---
Write-Host "Reading secrets from Key Vault: $env:keyVaultName"
try {
# Ensure your Azure Function's Managed Identity has 'Key Vault Secrets User' role on the Key Vault
$adminPassword = (Get-AzKeyVaultSecret -VaultName $env:keyVaultName -Name "VmAdminPassword" -ErrorAction Stop).SecretValue # Replace with your actual secret names
$domainPassword = (Get-AzKeyVaultSecret -VaultName $env:keyVaultName -Name "adJoinPassword" -ErrorAction Stop).SecretValue # Replace with your actual secret names
}
catch {
Write-Host "Error reading secrets from Key Vault: $($PSItem.Exception.Message)" -ErrorAction Continue
# Optional: More details for debugging
Write-Host "Detailed error information:"
Write-Host " Exception Type: $($PSItem.Exception.GetType().FullName)"
Write-Host " Error Message: $($PSItem.Exception.Message)"
Write-Host " StackTrace: $($PSItem.ScriptStackTrace)"
}
# --- 3. Deploy VM with ARM Template ---
Write-Host "Initiating VM deployment using ARM template..."
try {
# Generate VMName
if(!$application)
{
$vmName = "$($env:vmPrefix)-$(Get-Random -Maximum 99999)"
}
else {
$vmName = "$($env:vmPrefix)-$($application)-$(Get-Random -Maximum 99999)"
}
# Generate template parameters
$vmTemplateParameters = @{
vmName = $vmName
vmSize = $vmSize
os = $os
adminUsername = $env:adminUsername
adminPassword = $adminPassword
vnetName = $env:vnetName
subnetName = $env:subnetName
publicIpEnabled = $publicIp
entraExt = $entraExt
}
# Add AVD required parameters
if ($vmType -eq "avd") {
# Get or renew Hostpool token
try {
$tokenObj = Get-AzWvdHostPoolRegistrationToken -ResourceGroupName $env:resourceGroupName -HostPoolName $env:avdHostPoolName
if (-not $tokenObj -or -not $tokenObj.Token) {
throw "Token is missing"
}
$token = $tokenObj.Token
}
catch {
$token = (New-AzWvdRegistrationInfo -ResourceGroupName $env:resourceGroupName -HostPoolName $env:avdHostPoolName -ExpirationTime ((Get-Date).AddDays(1))).Token
}
$vmTemplateParameters.Add("avdExt", $true)
$vmTemplateParameters.Add("avdHostPoolName", $env:avdHostPoolName)
$vmTemplateParameters.Add("avdRegistrationToken", $token)
}
# Add ADJoin required parameters
if($adJoin -eq $true)
{
$vmTemplateParameters.Add("adJoin", $adJoin)
$vmTemplateParameters.Add("domainName", $env:domainName)
$vmTemplateParameters.Add("ouPath", $env:ouPath)
$vmTemplateParameters.Add("domainUserName", $env:domainUserName)
$vmTemplateParameters.Add("domainPassword", $domainPassword)
}
# Generate deploymentName
$deploymentName = "vm-order-$(Get-Date -Format 'yyyyMMddHHmmss')-$(Get-Random)"
# Start template deployment
New-AzResourceGroupDeployment `
-ResourceGroupName $env:resourceGroupName `
-TemplateUri $vmDeploymentTemplateUrl `
-TemplateParameterObject $vmTemplateParameters `
-Name $deploymentName `
-Force `
-ErrorAction Stop
Write-Host "VM deployment '$deploymentName' initiated successfully."
}
catch {
Write-Host "Error during VM deployment: $($PSItem.Exception.Message)" -ErrorAction Continue
# Optional: More details for debugging
Write-Host "Detailed error information:"
Write-Host " Exception Type: $($PSItem.Exception.GetType().FullName)"
Write-Host " Error Message: $($PSItem.Exception.Message)"
Write-Host " StackTrace: $($PSItem.ScriptStackTrace)"
}
}
catch {
Write-Host "An unexpected error occurred"
}
index.html – Web Frontend
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VM-Bestellformular</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
}
.form-input {
@apply mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm;
}
.form-checkbox {
@apply h-4 w-4 text-blue-600 border-gray-300 rounded;
}
</style>
</head>
<body class="bg-gray-100 flex items-center justify-center min-h-screen p-4">
<div class="bg-white p-8 rounded-lg shadow-xl w-full max-w-md">
<h2 class="text-3xl font-bold text-gray-800 mb-6 text-center">Neue VM bestellen</h2>
<form id="vmOrderForm" class="space-y-4">
<div>
<label for="upn" class="block text-sm font-medium text-gray-700">UPN (Benutzerprinzipalname)</label>
<input type="email" id="upn" name="upn" required class="form-input rounded-md">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">VM-Typ</label>
<div class="mt-2 space-y-2">
<label class="inline-flex items-center">
<input type="radio" name="vmType" value="standard" class="form-checkbox" checked>
<span class="ml-2 text-gray-700">Standard VM</span>
</label>
<label class="inline-flex items-center ml-6">
<input type="radio" name="vmType" value="avd" class="form-checkbox">
<span class="ml-2 text-gray-700">Azure Virtual Desktop VM</span>
</label>
</div>
</div>
<div id="applicationField">
<label for="application" class="block text-sm font-medium text-gray-700">Applikation (für Standard VM)</label>
<input type="text" id="application" name="application" class="form-input rounded-md">
</div>
<div>
<label for="vmSize" class="block text-sm font-medium text-gray-700">VM-Grösse</label>
<select id="vmSize" name="vmSize" required class="form-input rounded-md">
<option value="">Bitte auswählen</option>
<option value="Standard_B2s">Standard_B2s (2 vCPU, 4 GiB RAM)</option>
<option value="Standard_B4ms">Standard_B4ms (4 vCPU, 16 GiB RAM)</option>
<option value="Standard_D2s_v3">Standard_D2s_v3 (2 vCPU, 8 GiB RAM)</option>
<option value="Standard_D4s_v3">Standard_D4s_v3 (4 vCPU, 16 GiB RAM)</option>
</select>
</div>
<div>
<label for="os" class="block text-sm font-medium text-gray-700">Betriebssystem</label>
<select id="os" name="os" required class="form-input rounded-md">
<!-- Options will be dynamically loaded by JavaScript -->
</select>
</div>
<div class="flex items-center" id="publicIpContainer">
<input type="checkbox" id="publicIp" name="publicIp" class="form-checkbox rounded-md">
<label for="publicIp" class="ml-2 text-sm font-medium text-gray-700">Öffentliche IP-Adresse</label>
</div>
<div class="flex items-center">
<input type="checkbox" id="adJoin" name="adJoin" class="form-checkbox rounded-md">
<label for="adJoin" class="ml-2 text-sm font-medium text-gray-700">Active Directory Domänen-Join</label>
</div>
<div class="flex items-center">
<input type="checkbox" id="entraExt" name="entraExt" class="form-checkbox rounded-md">
<label for="entraExt" class="ml-2 text-sm font-medium text-gray-700">Entra ID Login-Erweiterung</label>
</div>
<div>
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 rounded-md">
VM bestellen
</button>
</div>
<div id="responseMessage" class="mt-4 p-3 text-center text-sm font-medium rounded-md" style="display:none;"></div>
</form>
</div>
<script>
// ERSETZEN SIE DIES MIT DEM TATSÄCHLICHEN ENDPUNKT IHRER PROXY LOGIC APP!
// Diesen Wert erhalten Sie aus dem 'proxyLogicAppEndpoint'-Output Ihres ARM-Deployments.
const LOGIC_APP_ENDPOINT = 'IHRE_PROXY_LOGIC_APP_HTTP_ENDPUNKT_HIER_EINSETZEN'; // Beispiel: https://prod-xx.centralus.logic.azure.com:443/workflows/.../triggers/manual/paths/invoke?...
const form = document.getElementById('vmOrderForm');
const responseMessage = document.getElementById('responseMessage');
const vmTypeRadios = document.querySelectorAll('input[name="vmType"]');
const applicationField = document.getElementById('applicationField');
const applicationInput = document.getElementById('application');
const osSelect = document.getElementById('os');
const publicIpCheckbox = document.getElementById('publicIp');
const publicIpContainer = document.getElementById('publicIpContainer'); // Container für die Public IP Checkbox
const osOptions = {
standard: [
{ value: 'windows11', text: 'Windows 11 Enterprise' },
{ value: 'windows2022', text: 'Windows Server 2022 Datacenter' },
{ value: 'windows2025', text: 'Windows Server 2025 Datacenter' }
],
avd: [
{ value: 'windows11', text: 'Windows 11 Enterprise' } // Nur noch Windows 11 für AVD Personal
]
};
// Funktion zur Aktualisierung der Formularfelder basierend auf dem ausgewählten VM-Typ
function updateFormFields() {
const selectedVmType = document.querySelector('input[name="vmType"]:checked').value;
// Anwendungsfeld Sichtbarkeit und Erforderlichkeit
if (selectedVmType === 'standard') {
applicationField.style.display = 'block';
applicationInput.setAttribute('required', 'required');
} else {
applicationField.style.display = 'none';
applicationInput.removeAttribute('required');
applicationInput.value = ''; // Wert leeren, wenn nicht relevant
}
// OS-Optionen aktualisieren
osSelect.innerHTML = '<option value="">Bitte auswählen</option>'; // Vorhandene Optionen leeren
const currentOsOptions = osOptions[selectedVmType];
currentOsOptions.forEach(optionData => {
const option = document.createElement('option');
option.value = optionData.value;
option.textContent = optionData.text;
osSelect.appendChild(option);
});
osSelect.value = ''; // Ausgewählte OS-Option zurücksetzen
// Public IP Checkbox Sichtbarkeit und Zustand
if (selectedVmType === 'avd') {
publicIpContainer.style.display = 'none'; // Ganze Zeile ausblenden
publicIpCheckbox.checked = false; // Sicherstellen, dass es deaktiviert ist
publicIpCheckbox.disabled = true; // Deaktivieren
} else {
publicIpContainer.style.display = 'flex'; // Zeile anzeigen
publicIpCheckbox.disabled = false; // Aktivieren
}
}
// Listener für Änderungen am VM-Typ
vmTypeRadios.forEach(radio => {
radio.addEventListener('change', updateFormFields);
});
// Initialer Aufruf bei Seitenladung
updateFormFields();
form.addEventListener('submit', async (event) => {
event.preventDefault(); // Standard-Formularübermittlung verhindern
responseMessage.style.display = 'none'; // Alte Nachrichten ausblenden
responseMessage.className = 'mt-4 p-3 text-center text-sm font-medium rounded-md'; // Klassen zurücksetzen
// Sammeln der Formulardaten
const formData = new FormData(form);
const data = {};
formData.forEach((value, key) => {
// Checkboxen als boolesche Werte behandeln
if (form.elements[key].type === 'checkbox') {
data[key] = form.elements[key].checked;
} else if (key === 'vmType' && value === 'avd') {
// Für AVD, loadBalancerType leer lassen oder entfernen, da es personal ist
data[key] = value;
}
else {
data[key] = value;
}
});
// Spezifische Logik für AVD: Anwendungsfeld entfernen, Public IP erzwingen auf false
if (data.vmType === 'avd') {
delete data.application; // Anwendungsfeld ist für AVD nicht relevant
data.publicIp = false; // Public IP ist für AVD nicht erlaubt
}
console.log('Sending data:', data);
try {
const response = await fetch(LOGIC_APP_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
let responseText = await response.text();
try {
// Versuche, als JSON zu parsen, falls die Antwort JSON ist
const jsonResponse = JSON.parse(responseText);
responseText = jsonResponse.message || JSON.stringify(jsonResponse, null, 2); // Verwende 'message' oder den Stringify-Output
} catch (e) {
// Wenn kein JSON, dann ist es einfach Text, verwende responseText direkt
}
if (response.ok) {
responseMessage.textContent = responseText || "Bestellung erfolgreich übermittelt.";
responseMessage.classList.add('bg-green-100', 'text-green-800');
form.reset(); // Formular zurücksetzen bei Erfolg
updateFormFields(); // Felder nach dem Reset aktualisieren, um die korrekten Anfangszustände zu zeigen
} else {
responseMessage.textContent = responseText || "Fehler beim Senden der Bestellung.";
responseMessage.classList.add('bg-red-100', 'text-red-800');
}
} catch (error) {
console.error('Fetch error:', error);
responseMessage.textContent = `Netzwerkfehler oder Server nicht erreichbar: ${error.message}`;
responseMessage.classList.add('bg-red-100', 'text-red-800');
} finally {
responseMessage.style.display = 'block'; // Nachricht anzeigen
}
});
</script>
</body>
</html>