Provisioning Azure Virtual Machines with Pulumi

Learn how to launch an SSH-key-authenticated Linux VM on Azure with Pulumi and TypeScript, including its network interface and public IP

Provisioning Azure Virtual Machines with Pulumi

This guide launches a Linux VM on Azure with Pulumi, building on the VNet guide - a public IP, a network interface, an NSG scoped to the VM, and the VM itself with SSH key authentication (no passwords).

Prerequisites

  • The resource group and subnets from the Pulumi Azure VNet guide
  • An SSH key pair (ssh-keygen -t ed25519) - the public key is passed in as config, never generated or stored by the program itself
pulumi config set sshPublicKey "$(cat ~/.ssh/id_ed25519.pub)"

Public IP and Network Interface

Unlike AWS, where a public IP can be attached directly via an instance argument, Azure VMs always go through an explicit NetworkInterface resource, and a public IP (if you want one) is its own resource attached to that NIC’s IP configuration:

// index.ts
import * as pulumi from "@pulumi/pulumi";
import * as network from "@pulumi/azure-native/network";
import * as compute from "@pulumi/azure-native/compute";

const config = new pulumi.Config();
const sshPublicKey = config.require("sshPublicKey");

const publicIp = new network.PublicIPAddress("web", {
    resourceGroupName: resourceGroup.name,
    location: location,
    publicIpAddressName: "web-ip",
    publicIPAllocationMethod: network.IPAllocationMethod.Static,
    sku: { name: network.PublicIPAddressSkuName.Standard },
});

const webNsg = new network.NetworkSecurityGroup("web", {
    resourceGroupName: resourceGroup.name,
    location: location,
    networkSecurityGroupName: "web-nsg",
    securityRules: [{
        name: "AllowSSH",
        priority: 100,
        direction: network.SecurityRuleDirection.Inbound,
        access: network.SecurityRuleAccess.Allow,
        protocol: network.SecurityRuleProtocol.Tcp,
        sourcePortRange: "*",
        destinationPortRange: "22",
        sourceAddressPrefix: "*",
        destinationAddressPrefix: "*",
    }],
});

const nic = new network.NetworkInterface("web", {
    resourceGroupName: resourceGroup.name,
    location: location,
    networkInterfaceName: "web-nic",
    ipConfigurations: [{
        name: "ipconfig1",
        subnet: { id: publicSubnet.id },
        publicIPAddress: { id: publicIp.id },
    }],
    networkSecurityGroup: { id: webNsg.id },
});

Note the inconsistent casing between publicIpAddressName (lowercase “p” in “Ip”) and publicIPAddress/PublicIPAddress elsewhere (uppercase “IP”) - both are real, current property/type names in the provider, just generated from ARM specs that aren’t perfectly self-consistent. TypeScript will catch a mismatch immediately if you get one wrong.

The Virtual Machine

const vm = new compute.VirtualMachine("web", {
    resourceGroupName: resourceGroup.name,
    location: location,
    vmName: "web-vm",
    hardwareProfile: {
        vmSize: "Standard_B2s",
    },
    networkProfile: {
        networkInterfaces: [{
            id: nic.id,
            primary: true,
        }],
    },
    osProfile: {
        computerName: "webvm",
        adminUsername: "azureuser",
        linuxConfiguration: {
            disablePasswordAuthentication: true,
            ssh: {
                publicKeys: [{
                    path: "/home/azureuser/.ssh/authorized_keys",
                    keyData: sshPublicKey,
                }],
            },
        },
    },
    storageProfile: {
        imageReference: {
            publisher: "Canonical",
            offer: "0001-com-ubuntu-server-jammy",
            sku: "22_04-lts-gen2",
            version: "latest",
        },
        osDisk: {
            createOption: compute.DiskCreateOptionTypes.FromImage,
            managedDisk: {
                storageAccountType: compute.StorageAccountTypes.Standard_LRS,
            },
        },
    },
});

export const vmId = vm.id;
export const publicIpAddress = publicIp.ipAddress;

disablePasswordAuthentication: true plus an SSH public key is the direct equivalent of omitting an admin_password and setting disable_password_authentication = true in the Terraform azurerm_linux_virtual_machine resource - the VM never has a working password at all, only key-based auth.

Best Practices

  1. Never pass a plaintext adminPassword - use linuxConfiguration.ssh.publicKeys (Linux) or a Pulumi secret config value if a Windows VM genuinely needs a password.
  2. Size with Standard_B2s-class burstable VMs for dev/test, and move to a fixed-performance SKU (Standard_D-series) once workload profiling shows sustained CPU use - burstable VMs throttle hard once their credit balance runs out.
  3. Scope the NSG to the VM’s actual exposed ports - here just SSH; add application ports as separate rules rather than widening destinationPortRange to a range.

Conclusion

Compare this to the EC2 guide in the AWS series: the concepts are identical (network interface, security group, image reference, key-based auth) but Azure’s ARM-modeled resources need an explicit NIC and a hardwareProfile/osProfile/storageProfile/networkProfile split where AWS flattens most of that onto a single Instance resource.

For more Pulumi topics, check out: