Provisioning an Azure Virtual Network with Pulumi

Learn how to build an Azure VNet with public and private subnets and a Network Security Group using Pulumi and TypeScript

Provisioning an Azure Virtual Network with Pulumi

This guide builds the same VNet topology as the Terraform VNet guide on this site, using Pulumi and the @pulumi/azure-native provider - a resource group, a VNet with public and private subnets, and a Network Security Group the rest of this Azure Pulumi series builds on.

azure-native vs. the Classic azure Provider

Pulumi has two Azure providers: @pulumi/azure (“Classic”, bridged from the Terraform azurerm provider, the same relationship @pulumi/aws has to Terraform’s AWS provider) and @pulumi/azure-native (generated directly from Azure Resource Manager’s API specs). This series uses azure-native - it’s Pulumi’s current recommended default for new Azure projects, covers the full ARM API surface immediately as Azure ships it rather than waiting on a bridge update, and its resource/argument names map directly onto ARM’s own naming rather than a Terraform-specific schema.

Prerequisites

npm install @pulumi/pulumi @pulumi/azure-native
  • Pulumi CLI and az login completed (the provider uses your Azure CLI credentials by default, same as Terraform’s azurerm provider)

Resource Group

Every Azure resource lives inside a resource group - there’s no equivalent “no group” option the way AWS or GCP resources can exist without an enclosing container:

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

const config = new pulumi.Config();
const location = config.get("location") ?? "eastus";

const resourceGroup = new resources.ResourceGroup("main", {
    resourceGroupName: "app-rg",
    location: location,
});

Virtual Network and Subnets

Unlike AWS’s aws.ec2.Subnet, azure-native’s Subnet is a separate resource from the VNet that references it by name, mirroring how ARM itself models subnets as VNet sub-resources:

const vnet = new network.VirtualNetwork("main", {
    resourceGroupName: resourceGroup.name,
    location: resourceGroup.location,
    virtualNetworkName: "app-vnet",
    addressSpace: {
        addressPrefixes: ["10.0.0.0/16"],
    },
});

const publicSubnet = new network.Subnet("public", {
    resourceGroupName: resourceGroup.name,
    virtualNetworkName: vnet.name,
    subnetName: "public",
    addressPrefix: "10.0.1.0/24",
});

const privateSubnet = new network.Subnet("private", {
    resourceGroupName: resourceGroup.name,
    virtualNetworkName: vnet.name,
    subnetName: "private",
    addressPrefix: "10.0.2.0/24",
});

Network Security Group

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

azure-native models ARM enum fields (direction, access, protocol here) as generated const objects like network.SecurityRuleDirection.Inbound rather than plain strings - they compile down to the same string ARM expects, but you get autocomplete and a compile error instead of a runtime rejection if you typo one.

Stack Outputs

export const resourceGroupName = resourceGroup.name;
export const vnetId = vnet.id;
export const publicSubnetId = publicSubnet.id;
export const privateSubnetId = privateSubnet.id;
export const appNsgId = appNsg.id;

Best Practices

  1. Attach the NSG to subnets, not individual NICs, when the same rules should apply to everything in a subnet - it’s one resource to audit instead of one per VM.
  2. Name resources explicitly (resourceGroupName, virtualNetworkName, and so on) rather than relying on Pulumi’s auto-naming - ARM resource names show up directly in the Azure portal and in other teams’ Terraform/Bicep referencing your resources by name.
  3. Keep the resource group as the blast-radius boundary - pulumi destroy on a stack that owns a whole resource group is a much larger, more deliberate action than destroying individual resources; scope stacks accordingly.

Conclusion

This VNet is the foundation the rest of this Azure Pulumi series builds on - the VM, Storage, and AKS guides all reference resourceGroup, publicSubnet, and privateSubnet from here.

For more Pulumi topics, check out: