Infrastructure as Code for Azure Network Security: Bicep, Terraform, and ARM Templates — AzureNetSec
Infrastructure as Code

Infrastructure as Code for Azure Network Security: Bicep, Terraform, ARM Templates, and VS Code

Manual Azure network configuration is the enemy of consistency, auditability, and security at scale. This guide covers the leading IaC tools for Azure networking, how to set them up in Visual Studio Code, and the best practices that separate production-grade deployments from ad hoc configurations that drift over time.

Why IaC Is a Security Control, Not Just a DevOps Practice

Infrastructure as Code is not just a convenience for operations teams. For Azure network security, it is one of the most impactful controls you can implement. Here's why:

  • Configuration drift is eliminated. Manual changes in the Azure portal are not tracked, are hard to audit, and accumulate into environments that no longer match what was originally designed or approved.
  • Security is embedded in the deployment pipeline. Policy checks, NSG rule validation, and compliance scanning happen before resources are deployed — not after a security review finds problems in production.
  • Every change is reviewed. When your network configuration lives in Git, changes go through pull requests. A second pair of eyes reviews every NSG rule addition, every peering change, every firewall policy update before it reaches Azure.
  • Environments are reproducible. Disaster recovery, new region deployments, and test environment creation become trivial when your entire network is defined in code.
Real-World Impact

Most Azure security incidents I have seen in customer environments involve a configuration that was changed manually months earlier — no ticket, no review, no record. IaC doesn't just make deployments faster. It closes the audit gap that manual configuration leaves wide open.

The IaC Tools for Azure Networking

There are three main tools used to define Azure network infrastructure as code. Each has a distinct philosophy, learning curve, and best-fit use case.

Azure Bicep
Microsoft — Azure-native IaC language
Recommended for Azure-only

Bicep is Microsoft's purpose-built IaC language for Azure. It compiles down to ARM JSON at deployment time, giving you the full power of ARM with dramatically cleaner syntax. If you're Azure-only and starting fresh, Bicep is the right default choice in 2025.

Bicep has first-class support in Visual Studio Code via the Bicep extension, which gives you IntelliSense for every Azure resource type, real-time validation, and one-click deployment. The language is declarative, concise, and tightly integrated with the Azure resource model.

Strengths
  • +Azure-native — supports new resource types immediately
  • +Cleaner syntax than ARM JSON
  • +Excellent VS Code extension with full IntelliSense
  • +Free — no licensing cost
  • +Backed by Microsoft — long-term supported
  • +Direct integration with Azure Policy and Blueprints
Weaknesses
  • Azure only — no multi-cloud support
  • Smaller community than Terraform
  • State management handled by Azure (less flexible)
HashiCorp Terraform
HashiCorp — Multi-cloud IaC platform
Recommended for multi-cloud

Terraform is the most widely used IaC tool across the industry. Its Azure provider (maintained by HashiCorp with Microsoft collaboration) is comprehensive and mature. If your organisation runs workloads across Azure and AWS, or if your team already has Terraform expertise, it's the natural choice.

Terraform's state management — where it tracks what it has deployed and what needs to change — is more sophisticated than ARM/Bicep's approach. Remote state in Azure Storage or Terraform Cloud gives you a reliable source of truth for your infrastructure. The HashiCorp Configuration Language (HCL) is readable and well-documented.

Strengths
  • +Multi-cloud — one toolset for Azure, AWS, and GCP
  • +Largest IaC community and module ecosystem
  • +Sophisticated state management
  • +Excellent VS Code extension (HashiCorp Terraform)
  • +Terraform plan previews changes before applying
  • +Strong CI/CD pipeline integration
Weaknesses
  • Azure resource support sometimes lags behind Bicep
  • State file management adds operational overhead
  • BSL licence change (2023) — consider OpenTofu for open-source
  • Higher learning curve than Bicep for Azure-specific teams
ARM Templates
Microsoft — Azure Resource Manager JSON
Legacy — prefer Bicep

ARM Templates are the original Azure IaC format — raw JSON files that define Azure resources. Bicep compiles to ARM JSON, so understanding ARM is still useful for reading compiled output and working with older codebases. For new projects in 2025, Bicep supersedes ARM in every way.

You will still encounter ARM templates in Azure Quickstart Templates, exported resource definitions, and older enterprise codebases. The VS Code ARM Tools extension provides some help but the experience is significantly worse than Bicep or Terraform.

Strengths
  • +Foundation of Azure — every resource type supported
  • +Large existing template library (Azure Quickstart)
  • +No compilation step — deploys directly to ARM
Weaknesses
  • Extremely verbose JSON — difficult to read and maintain
  • No native modularity — workarounds are clunky
  • Superseded by Bicep for all new work
  • Poor developer experience compared to alternatives
Pulumi
Pulumi Corporation — General-purpose language IaC
Worth knowing about

Pulumi takes a different approach — instead of a domain-specific language, you write infrastructure code in TypeScript, Python, Go, C#, or Java. For developer-led organisations or teams that want to write infrastructure tests in the same language as their application code, Pulumi is a compelling option.

Its Azure support is solid and it integrates well with Azure DevOps and GitHub Actions. It's less common in pure network security contexts but worth knowing as it grows in adoption.

Setting Up Visual Studio Code for Azure IaC

Visual Studio Code is the standard editor for Azure IaC work. The right extensions transform it from a text editor into a full Azure development environment with real-time validation, IntelliSense, and one-click deployment.

Essential VS Code Extensions

Install these extensions from the VS Code Marketplace:

VS Code Setup

Open VS Code, press Ctrl+Shift+X (or Cmd+Shift+X on Mac) to open the Extensions panel, and search for each of the following:

  • Bicep (ms-azuretools.vscode-bicep) — IntelliSense, validation, and visualiser for Bicep files. Shows you a graphical diagram of your resource dependencies as you write.
  • HashiCorp Terraform (hashicorp.terraform) — Syntax highlighting, IntelliSense, and formatting for HCL files. Required for Terraform development.
  • Azure Resource Manager Tools (msazurermtools.azurerm-vscode-tools) — Syntax support for ARM JSON templates. Only needed if you're maintaining legacy ARM templates.
  • Azure CLI Tools (ms-vscode.azurecli) — Run Azure CLI commands directly from VS Code terminal with IntelliSense.
  • Azure Account (ms-vscode.azure-account) — Sign in to Azure from VS Code and deploy directly from the editor.
  • GitLens (eamodio.gitlens) — Essential for tracking who changed what in your IaC repository. Shows blame annotations inline with your code.
  • YAML (redhat.vscode-yaml) — Needed for Azure Pipelines and GitHub Actions workflow files.

Recommended VS Code Settings for IaC Work

Add these to your VS Code settings.json for a cleaner IaC development experience:

JSON settings.json
{ "editor.formatOnSave": true, "editor.tabSize": 2, "files.autoSave": "afterDelay", "bicep.enableOutputsView": true, "terraform.languageServer.enable": true, "[bicep]": { "editor.defaultFormatter": "ms-azuretools.vscode-bicep" }, "[terraform]": { "editor.defaultFormatter": "hashicorp.terraform" } }

Bicep in Practice — Deploying an NSG with Security Rules

Here is a complete Bicep module that deploys a Network Security Group with hardened inbound and outbound rules, following the deny-all-by-default pattern. This is the kind of code that replaces clicking through the Azure portal and provides a repeatable, auditable configuration.

Bicep modules/nsg-web-subnet.bicep
// Parameters param location string = resourceGroup().location param environment string // e.g. 'prod', 'dev' param appSubnetPrefix string // e.g. '10.0.2.0/24' // Tags applied to all resources var tags = { environment: environment managedBy: 'IaC-Bicep' lastUpdated: utcNow('yyyy-MM-dd') } // NSG for the web-facing subnet resource nsgWebSubnet 'Microsoft.Network/networkSecurityGroups@2023-09-01' = { name: 'nsg-web-${environment}' location: location tags: tags properties: { securityRules: [ { name: 'Allow-HTTPS-Inbound' properties: { priority: 100 direction: 'Inbound' access: 'Allow' protocol: 'Tcp' sourceAddressPrefix: 'Internet' sourcePortRange: '*' destinationAddressPrefix: '*' destinationPortRange: '443' } } { name: 'Allow-HTTP-Inbound' properties: { priority: 110 direction: 'Inbound' access: 'Allow' protocol: 'Tcp' sourceAddressPrefix: 'Internet' sourcePortRange: '*' destinationAddressPrefix: '*' destinationPortRange: '80' } } { name: 'Allow-App-From-Web' properties: { priority: 200 direction: 'Outbound' access: 'Allow' protocol: 'Tcp' sourceAddressPrefix: '*' sourcePortRange: '*' destinationAddressPrefix: appSubnetPrefix destinationPortRange: '8080' } } { name: 'Deny-All-Inbound' properties: { priority: 4000 direction: 'Inbound' access: 'Deny' protocol: '*' sourceAddressPrefix: '*' sourcePortRange: '*' destinationAddressPrefix: '*' destinationPortRange: '*' } } { name: 'Deny-All-Outbound' properties: { priority: 4000 direction: 'Outbound' access: 'Deny' protocol: '*' sourceAddressPrefix: '*' sourcePortRange: '*' destinationAddressPrefix: '*' destinationPortRange: '*' } } ] } } // Outputs for use in parent modules output nsgId string = nsgWebSubnet.id output nsgName string = nsgWebSubnet.name

Deploy this module from the command line using the Azure CLI:

Bash Terminal
# Deploy the NSG module to a resource group az deployment group create \ --resource-group rg-networking-prod \ --template-file modules/nsg-web-subnet.bicep \ --parameters environment=prod appSubnetPrefix=10.0.2.0/24 # Preview changes without deploying (what-if) az deployment group what-if \ --resource-group rg-networking-prod \ --template-file modules/nsg-web-subnet.bicep \ --parameters environment=prod appSubnetPrefix=10.0.2.0/24
Always Use What-If First

The az deployment group what-if command shows you exactly what will be created, modified, or deleted before you apply the change. Make it a habit — especially when modifying existing NSG rules in production.

Terraform in Practice — Azure Firewall with Policy

Here is a Terraform configuration that deploys Azure Firewall with a Firewall Policy, including a network rule collection to control east-west traffic between spokes. This pattern is the foundation of a secure hub-spoke architecture managed entirely as code.

HCL modules/azure-firewall/main.tf
# Firewall Policy resource "azurerm_firewall_policy" "main" { name = "fw-policy-${var.environment}" resource_group_name = var.resource_group_name location = var.location sku = "Premium" threat_intelligence_mode = "Alert" intrusion_detection { mode = "Alert" } tags = var.tags } # Network Rule Collection — East-West Traffic Control resource "azurerm_firewall_policy_rule_collection_group" "network_rules" { name = "network-rules" firewall_policy_id = azurerm_firewall_policy.main.id priority = 200 network_rule_collection { name = "spoke-to-spoke" priority = 100 action = "Allow" rule { name = "web-to-app" protocols = ["TCP"] source_addresses = ["10.0.1.0/24"] # Web subnet destination_addresses = ["10.0.2.0/24"] # App subnet destination_ports = ["8080"] } rule { name = "app-to-data" protocols = ["TCP"] source_addresses = ["10.0.2.0/24"] # App subnet destination_addresses = ["10.0.3.0/24"] # Data subnet destination_ports = ["1433"] } } application_rule_collection { name = "outbound-internet" priority = 200 action = "Allow" rule { name = "allow-windows-update" source_addresses = ["10.0.0.0/16"] protocols { port = "443" type = "Https" } destination_fqdns = [ "*.windowsupdate.com", "*.microsoft.com", "*.azure.com" ] } } } # Azure Firewall resource "azurerm_firewall" "main" { name = "fw-hub-${var.environment}" resource_group_name = var.resource_group_name location = var.location sku_name = "AZFW_VNet" sku_tier = "Premium" firewall_policy_id = azurerm_firewall_policy.main.id ip_configuration { name = "fw-ip-config" subnet_id = var.firewall_subnet_id public_ip_address_id = azurerm_public_ip.fw.id } tags = var.tags }
Bash Terminal
# Initialise Terraform and download providers terraform init # Preview what will be deployed terraform plan -var="environment=prod" # Apply the configuration terraform apply -var="environment=prod" # Store state remotely in Azure Storage (recommended) terraform init \ -backend-config="storage_account_name=tfstateazurenetsec" \ -backend-config="container_name=tfstate" \ -backend-config="key=prod.azure-firewall.tfstate"

IaC Best Practices for Azure Network Security

01

Everything in Git, nothing in the portal

If a change wasn't made through your IaC pipeline, it didn't happen. Enforce this with Azure Policy to deny manual portal changes in production subscriptions.

02

Use parameter files for environment differences

Never hardcode IP ranges, names, or environment-specific values. Use Bicep parameter files or Terraform variable files — one per environment. Promotes code reuse and prevents inconsistency.

03

Modularise your network components

VNets, NSGs, Azure Firewall, and route tables should each be separate modules. Compose them in a root module. This makes testing, reuse, and change management significantly easier.

04

Run what-if / plan in every PR

Configure your CI pipeline to run az deployment what-if or terraform plan automatically on every pull request. Reviewers see exactly what the infrastructure change will do before approving.

05

Scan for security misconfigurations before deployment

Integrate Checkov or tfsec into your pipeline to scan Bicep and Terraform code for security issues before deployment. Catches open NSG rules, missing encryption, and insecure defaults automatically.

06

Tag every resource consistently

Always include environment, owner, cost centre, and managedBy: IaC tags in your modules. Resources without the IaC tag are drift candidates — use Azure Policy to alert on them.

07

Never store secrets in IaC code

Passwords, connection strings, and API keys must never appear in Bicep or Terraform files — even in private repos. Reference Azure Key Vault secrets at deployment time instead.

08

Lock production deployments to main branch only

Configure your pipeline so only the main/master branch can deploy to production. Feature branches deploy to dev/test only. This prevents untested code reaching production environments.

CI/CD Pipeline for Azure Network IaC

Writing IaC code is only half the story. The deployment pipeline is what enforces the security and consistency guarantees you're trying to achieve. Here is a recommended GitHub Actions workflow for Bicep deployments:

YAML .github/workflows/deploy-network.yml
name: Deploy Azure Network Infrastructure on: push: branches: [ main ] paths: [ 'network/**' ] pull_request: branches: [ main ] paths: [ 'network/**' ] env: AZURE_RESOURCE_GROUP: rg-networking-prod BICEP_FILE: network/main.bicep jobs: validate: name: Validate and Security Scan runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Azure Login uses: azure/login@v2 with: creds: ${{ secrets.AZURE_CREDENTIALS }} - name: Bicep Build (compile check) run: az bicep build --file $BICEP_FILE - name: Security Scan with Checkov uses: bridgecrewio/checkov-action@master with: directory: network/ framework: bicep soft_fail: false - name: What-If Preview run: | az deployment group what-if \ --resource-group $AZURE_RESOURCE_GROUP \ --template-file $BICEP_FILE \ --parameters @network/params.prod.json deploy: name: Deploy to Production runs-on: ubuntu-latest needs: validate if: github.ref == 'refs/heads/main' environment: production steps: - uses: actions/checkout@v4 - name: Azure Login uses: azure/login@v2 with: creds: ${{ secrets.AZURE_CREDENTIALS }} - name: Deploy Network Infrastructure run: | az deployment group create \ --resource-group $AZURE_RESOURCE_GROUP \ --template-file $BICEP_FILE \ --parameters @network/params.prod.json \ --mode Incremental

Real-World Scenarios

Scenario 01

Deploying a Hardened Hub-Spoke Network from Scratch

An organisation is migrating from on-premises to Azure and needs to deploy a hub-spoke network with Azure Firewall, NSGs on every subnet, Private Endpoints for all PaaS services, and User Defined Routes forcing traffic through the hub firewall.

Approach: Three Bicep modules — one for the hub VNet and Azure Firewall, one for spoke VNets (reused for each spoke), and one for Private Endpoints. A root main.bicep composes them. Parameters files define each spoke's address space and workload requirements. The entire network deploys in under 20 minutes from a single pipeline run.

Security benefit: Every NSG rule, every firewall policy, and every route table entry is in Git. Any change — even a single NSG rule addition — goes through a pull request with what-if output reviewed before approval. Configuration drift is impossible because the pipeline re-applies the desired state on every deployment.

Scenario 02

Enforcing NSG Compliance Across 40 Subscriptions

A large enterprise has 40 Azure subscriptions managed by different teams. Security audits keep finding open management ports (22, 3389) in NSGs that teams have added manually for convenience. The security team needs to stop this at scale.

Approach: A Terraform module defines a deny-management-ports NSG rule that must be present on every subnet. An Azure Policy definition (also written as Bicep and deployed via the pipeline) enforces that all NSGs include this rule. Checkov scans any IaC code that touches NSGs. Teams can still deploy their own NSG rules — they just cannot override the security baseline.

Security benefit: The combination of IaC enforcement plus Azure Policy means neither manual portal changes nor IaC code can remove the baseline rule. Compliance reporting shows 100% of subnets compliant within one sprint of rollout.

Scenario 03

Zero Trust Network Deployment via IaC Pipeline

An organisation is implementing Zero Trust and needs to deploy Microsoft Entra Private Access connectors, configure Conditional Access policies, and update NSGs to remove legacy VPN access routes — all consistently across dev, staging, and production environments.

Approach: Bicep handles the network layer (NSG changes, Private Endpoint configurations, UDR updates). Terraform manages the Entra ID configuration (Conditional Access policies, application registrations). Both run in the same pipeline with environment-specific parameter files. Dev deploys first, validation gates block staging deployment if security scans fail.

Security benefit: The Zero Trust configuration is identical across all environments — no inconsistencies between dev and production that create false confidence during testing. Rollback is a Git revert away if something goes wrong.

Choosing the Right Tool

ScenarioRecommended ToolReason
Azure-only, greenfield deploymentBicepNative, concise, best VS Code experience
Multi-cloud (Azure + AWS)TerraformSingle toolset across both clouds
Existing Terraform codebaseTerraformConsistency — don't split your state management
Existing ARM templatesMigrate to BicepBicep decompiles ARM JSON automatically
Developer-led teams (TypeScript/Python)PulumiWrite infra in your existing language
Quick prototype or one-off deploymentAzure CLI / PortalFor throwaway resources only — never production
Migration Tip

If you have existing ARM templates, you don't have to rewrite them from scratch. Run az bicep decompile --file yourtemplate.json to automatically convert ARM JSON to Bicep. The output won't be perfect but gives you a 90% starting point that you can clean up and modularise.

Key Takeaways

What to remember from this article

  • IaC is a security control — it eliminates configuration drift, enforces review, and creates an audit trail for every network change
  • Bicep is the right default for Azure-only environments — cleaner than ARM, native Azure support, excellent VS Code integration
  • Terraform wins for multi-cloud — if you run Azure and AWS, use one tool for both with consistent state management
  • ARM Templates are legacy — migrate to Bicep using the decompile command, don't write new ARM JSON
  • Always run what-if / plan before applying — see exactly what will change before it changes in production
  • Scan before you deploy — integrate Checkov or tfsec into every pipeline to catch security misconfigurations in code
  • Never store secrets in IaC code — reference Key Vault secrets at deployment time