All articles

Azure for .NET Developers — Part 7: Create and Automate VMs with the Azure SDK

The modern Azure.ResourceManager SDK end to end: resource group, network, and a Linux VM with SSH keys from C#, plus scheduled start/stop to keep the bill sane.

0 · log in to like, save & follow Share on LinkedIn Share on X

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.

Provisioning Azure virtual machines from C# with the Azure.ResourceManager SDK

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.

Part 7 of 8 — don't miss the rest New parts of Azure for .NET Developers publish every few days. Get each one by email the day it lands — no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.