Most of this series has been a tour of things that let us avoid virtual machines: App Service, Functions, managed databases. But sometimes a VM is exactly the right tool, and when it is, we want to create it from C# rather than clicking through the portal. In this part we'll provision a complete Linux VM — network, IP, NIC, and all — using the modern Azure.ResourceManager SDK, then automate the boring parts: scheduled start/stop and clean teardown.

When do we actually need a VM in 2026?
Honestly, less often than we think. If our workload is an ASP.NET Core app, App Service or a container almost always wins on operational effort. But VMs still earn their keep in a few situations: software that needs full OS control (custom kernels, GPU drivers, licensed desktop apps), legacy Windows services that won't containerize cleanly, self-hosted CI build agents, and jump boxes into private networks.
| Scenario | Best fit | Why |
|---|---|---|
| ASP.NET Core web app | App Service | Zero OS patching, easy deploy slots |
| Containerized microservices | Container Apps / AKS | Scale-to-zero, orchestration built in |
| Legacy Windows service, COM deps | VM | Full OS control, no refactor needed |
| Self-hosted build agents | VM (or VM Scale Set) | Custom toolchains, cache on disk |
| GPU / specialized hardware | VM | Only option for driver-level access |
| Quick experiment, full root access | VM | Fastest path to "just give me a box" |
The rule of thumb we use: reach for a VM when we need the operating system itself, not just a place to run code. Everything else is cheaper to operate as PaaS.
The modern SDK: ArmClient replaces the Fluent SDK
If you've searched for "create Azure VM C#" you've probably found samples using Microsoft.Azure.Management.Fluent with its chained .Define().WithRegion().WithNewResourceGroup() API. That SDK is retired. The current approach is the Azure.ResourceManager family (often called the ARM SDK or management-plane SDK), which pairs with Azure.Identity for authentication and follows the same conventions as the data-plane libraries we've used throughout this series.
dotnet add package Azure.ResourceManager.Compute
dotnet add package Azure.ResourceManager.Network
dotnet add package Azure.Identity
The mental model is a hierarchy: an ArmClient gives us a subscription, a subscription contains resource groups, and resource groups contain collections of typed resources (VirtualNetworkCollection, VirtualMachineCollection, and so on). Every create call is a CreateOrUpdateAsync on a collection, which makes the code naturally idempotent — running it twice updates rather than duplicates.
using Azure.Identity;
using Azure.ResourceManager;
var client = new ArmClient(new DefaultAzureCredential());
var subscription = await client.GetDefaultSubscriptionAsync();
DefaultAzureCredential behaves exactly as it did in Part 3: locally it picks up our az login session, and in Azure it uses managed identity. No secrets in code, ever.
How do I create an Azure virtual machine from C#?
Create an ArmClient with DefaultAzureCredential, get a subscription, then call CreateOrUpdateAsync on the resource collections in dependency order: resource group, virtual network with a subnet, public IP, network interface, and finally the VM itself. Each call returns the created resource, which we feed into the next one. The full flow is about 80 lines of C# and takes two to three minutes to run.
Let's walk it. First, the resource group and network plumbing:
using Azure.Core;
using Azure.ResourceManager.Network;
using Azure.ResourceManager.Network.Models;
using Azure.ResourceManager.Resources;
var location = AzureLocation.EastUS;
ArmOperation<ResourceGroupResource> rgOp = await subscription
.GetResourceGroups()
.CreateOrUpdateAsync(WaitUntil.Completed, "rg-vm-demo",
new ResourceGroupData(location));
ResourceGroupResource rg = rgOp.Value;
var vnetData = new VirtualNetworkData
{
Location = location,
AddressPrefixes = { "10.0.0.0/16" },
Subnets = { new SubnetData { Name = "default", AddressPrefix = "10.0.0.0/24" } }
};
VirtualNetworkResource vnet = (await rg.GetVirtualNetworks()
.CreateOrUpdateAsync(WaitUntil.Completed, "vnet-demo", vnetData)).Value;
var pipData = new PublicIPAddressData
{
Location = location,
PublicIPAllocationMethod = NetworkIPAllocationMethod.Static,
Sku = new PublicIPAddressSku { Name = PublicIPAddressSkuName.Standard }
};
PublicIPAddressResource pip = (await rg.GetPublicIPAddresses()
.CreateOrUpdateAsync(WaitUntil.Completed, "pip-demo", pipData)).Value;
var nicData = new NetworkInterfaceData
{
Location = location,
IPConfigurations =
{
new NetworkInterfaceIPConfigurationData
{
Name = "primary",
Primary = true,
Subnet = new SubnetData { Id = vnet.Data.Subnets[0].Id },
PublicIPAddress = new PublicIPAddressData { Id = pip.Id }
}
}
};
NetworkInterfaceResource nic = (await rg.GetNetworkInterfaces()
.CreateOrUpdateAsync(WaitUntil.Completed, "nic-demo", nicData)).Value;
Now the VM itself. We'll use an Ubuntu image, a burstable B-series size, and SSH key authentication — password auth on an internet-facing VM is asking for trouble:
using Azure.ResourceManager.Compute;
using Azure.ResourceManager.Compute.Models;
string sshPublicKey = File.ReadAllText(
Path.Combine(Environment.GetFolderPath(
Environment.SpecialFolder.UserProfile), ".ssh", "id_ed25519.pub"));
var vmData = new VirtualMachineData(location)
{
HardwareProfile = new VirtualMachineHardwareProfile
{
VmSize = VirtualMachineSizeType.StandardB2S
},
OSProfile = new VirtualMachineOSProfile
{
ComputerName = "vm-demo",
AdminUsername = "azureuser",
LinuxConfiguration = new LinuxConfiguration
{
DisablePasswordAuthentication = true,
SshPublicKeys =
{
new SshPublicKeyConfiguration
{
Path = "/home/azureuser/.ssh/authorized_keys",
KeyData = sshPublicKey
}
}
}
},
StorageProfile = new VirtualMachineStorageProfile
{
ImageReference = new ImageReference
{
Publisher = "Canonical",
Offer = "ubuntu-24_04-lts",
Sku = "server",
Version = "latest"
},
OSDisk = new VirtualMachineOSDisk(DiskCreateOptionType.FromImage)
{
ManagedDisk = new VirtualMachineManagedDisk
{
StorageAccountType = StorageAccountType.StandardSsdLrs
}
}
},
NetworkProfile = new VirtualMachineNetworkProfile
{
NetworkInterfaces =
{
new VirtualMachineNetworkInterfaceReference { Id = nic.Id, Primary = true }
}
}
};
VirtualMachineResource vm = (await rg.GetVirtualMachines()
.CreateOrUpdateAsync(WaitUntil.Completed, "vm-demo", vmData)).Value;
Console.WriteLine($"VM ready: ssh azureuser@{pip.Data.IPAddress}");
The complete runnable project is in the 07-vms folder of the companion repo: https://github.com/laxmikant-geek/azure-for-dotnet-examples. One caveat we hit while writing it: the program prints the SSH command at the end, and connecting really is just ssh azureuser@<ip> — no agents or extensions needed, because we injected our public key at creation time. If the connection times out, check that a network security group isn't blocking port 22; the minimal setup above leaves the NIC without an NSG, which many subscriptions' policies will flag.
Automating start/stop to cut the bill
A B2s VM costs roughly a dollar a day — trivial until you multiply it by a fleet of dev boxes that nobody uses at night. Deallocating a VM stops compute billing entirely (we keep paying pennies for the disk). The keyword is deallocate: a VM that's merely "stopped" from inside the OS still bills.
The simplest automation is the built-in auto-shutdown plus a scheduled start. Auto-shutdown is a first-class VM feature:
az vm auto-shutdown -g rg-vm-demo -n vm-demo --time 1900
That handles evenings. For morning starts, Azure Automation has a ready-made Start/Stop VMs solution, but for one or two VMs we find a timer-triggered Azure Function calling the same SDK to be less machinery to maintain:
// In a timer-triggered Function, 0 0 8 * * 1-5 (weekdays at 08:00)
await vm.PowerOnAsync(WaitUntil.Started);
// and a second function for evenings:
await vm.DeallocateAsync(WaitUntil.Started);
Note WaitUntil.Started here — we don't need the Function to sit waiting for the VM to finish booting; fire the operation and exit.
Cleaning up: the resource group is the unit of teardown
Our VM walkthrough created six billable or quota-consuming resources: group, vnet, public IP, NIC, disk, VM. Deleting them one by one is tedious and order-sensitive (you can't delete a NIC that's attached to a VM). This is why we created everything inside a dedicated resource group — teardown becomes one line:
await rg.DeleteAsync(WaitUntil.Completed);
Or from the shell, az group delete -n rg-vm-demo --yes --no-wait. This habit — one experiment, one resource group, delete the group when done — is the single best defense against the "forgot to delete the public IP" line items that haunt Azure bills. Deleting a VM resource alone does not delete its disk or IP by default; deleting the group takes everything.
How do I check a VM's power state from C#?
Ask for the VM's instance view — the runtime status that lives alongside the resource definition. The power state arrives as a status code like PowerState/running or PowerState/deallocated:
VirtualMachineInstanceView view = await vm.InstanceViewAsync();
string power = view.Statuses
.FirstOrDefault(s => s.Code?.StartsWith("PowerState/") == true)?
.Code?.Replace("PowerState/", "") ?? "unknown";
Console.WriteLine($"{vm.Data.Name}: {power}");
This is the building block for anything fleet-shaped. Listing every VM in the subscription with its state is a few lines more:
await foreach (VirtualMachineResource each in
subscription.GetVirtualMachinesAsync())
{
var iv = await each.InstanceViewAsync();
var state = iv.Value.Statuses
.FirstOrDefault(s => s.Code?.StartsWith("PowerState/") == true)?.Code;
Console.WriteLine($"{each.Data.Name} ({each.Data.Location}): {state}");
}
We use exactly this loop in a nightly report that flags anything still running outside business hours — it pairs naturally with the deallocate automation above, catching the boxes the schedule missed.
Running a script on the VM without SSH
Sometimes we need to run one command on the box — install an agent, rotate a certificate, grab a log — and opening SSH from an automation context is more ceremony than it's worth. Azure's Run Command feature executes a script through the VM agent, authenticated by Azure RBAC rather than SSH keys:
var runInput = new RunCommandInput("RunShellScript")
{
Script = { "uptime", "df -h /", "systemctl is-active nginx" }
};
var result = await vm.RunCommandAsync(WaitUntil.Completed, runInput);
foreach (var status in result.Value.Value)
Console.WriteLine(status.Message);
For Windows VMs the command ID is RunPowerShellScript. Two things to know before leaning on it: output is capped (long logs get truncated, so write big output to a file or storage instead), and the call needs the VM to be running. It's the right tool for occasional operational pokes; for configuration that must be true on every boot, bake a cloud-init script into the VM's OSProfile.CustomData at creation time instead.
Don't skip the network security group
The minimal walkthrough above deliberately left the NIC without a network security group to keep the code short — but anything that lives longer than an afternoon should have one. An NSG is a small firewall attached to the subnet or NIC; the sane default for an SSH-managed Linux box is "allow 22 from my IP, deny everything else inbound":
var nsgData = new NetworkSecurityGroupData
{
Location = location,
SecurityRules =
{
new SecurityRuleData
{
Name = "allow-ssh-admin",
Priority = 1000,
Access = SecurityRuleAccess.Allow,
Direction = SecurityRuleDirection.Inbound,
Protocol = SecurityRuleProtocol.Tcp,
SourceAddressPrefix = "203.0.113.7/32", // your admin IP
SourcePortRange = "*",
DestinationAddressPrefix = "*",
DestinationPortRange = "22"
}
}
};
NetworkSecurityGroupResource nsg = (await rg.GetNetworkSecurityGroups()
.CreateOrUpdateAsync(WaitUntil.Completed, "nsg-demo", nsgData)).Value;
// then, on the NIC data before creating it:
// nicData.NetworkSecurityGroup = new NetworkSecurityGroupData { Id = nsg.Id };
Even better for a single admin box: skip the public IP entirely and use Azure Bastion or the az ssh vm tunnel, so port 22 is never internet-facing at all. If the VM only makes outbound calls (a build agent pulling from GitHub, say), it needs no inbound rules whatsoever — the default deny already does the right thing.
What's next
That closes out the hands-on portion of the series: we can now build, deploy, secure, store, message, and provision from .NET. In Part 8, the finale, we'll do something most tutorials avoid — add up the actual bill. We'll price a small production ASP.NET Core app on Azure line by line, compare it honestly against a $12 VPS, and talk about when each one wins.
Comments (0)
No comments yet — be the first to share your thoughts.