When transitioning from the manual, error-prone world of "ClickOps" in cloud portals to production-grade engineering, mastering Infrastructure as Code (IaC) is non-negotiable.

In this walkthrough, we will explore a real-world project that provisions foundational Azure cloud networking using Terraform and the latest AzureRM 4.x provider, all orchestrated within an isolated DevPod containerized development environment powered by mise.

Whether you're studying for cloud certifications or standardizing your team's cloud infrastructure footprint, this guide breaks down the architecture, codebase, security hygiene, and lessons learned along the way.

🎯 Project Objectives

Before writing a single block of HCL (HashiCorp Configuration Language), we outlined clear requirements:

  1. Deterministic & Repeatable Provisioning: Zero reliance on manual Azure Portal configurations.
  2. Predictable Developer Workspace: Avoid version discrepancies across developer machines by leveraging DevPod, Docker, and mise.
  3. Sound Networking Foundations: Establish an isolated Virtual Network (VNet), dedicated application subnet, and Network Security Group (NSG).
  4. Strict Security Hygiene: Isolate credentials using Microsoft Entra ID service principals with zero secrets leaking into version control.

🏗️ Architecture Overview

The infrastructure deployed by this project represents the foundational landing zone for any cloud-hosted application:

+-------------------------------------------------------------------+ | Azure Subscription | | | | Resource Group: rg-terraform-lab (Central India) | | +-------------------------------------------------------------+ | | | Virtual Network: vnet-terraform-lab (10.10.0.0/16) | | | | | | | | +-------------------------------------------------------+ | | | | | Subnet: snet-app (10.10.1.0/24) | | | | | | | | | | | | [ Protected by NSG: nsg-terraform-lab ] | | | | | +-------------------------------------------------------+ | | | +-------------------------------------------------------------+ | +-------------------------------------------------------------------+ 

Component Breakdown

Resource Terraform Resource Type Configuration / Purpose
Resource Group azurerm_resource_group Logical container (rg-terraform-lab) hosted in Central India with common tagging.
Virtual Network azurerm_virtual_network Isolated address space (10.10.0.0/16) defining the private cloud boundary.
Subnet azurerm_subnet Application tier subnet (10.10.1.0/24) allocated for compute or workloads.
NSG azurerm_network_security_group Firewall filter governing ingress and egress rules.
Subnet Association azurerm_subnet_network_security_group_association Decoupled link binding the NSG to the application subnet.

🧰 The Modern Developer Toolchain

One common headache in cloud engineering is tool mismatch: "It works on my machine because I'm on Terraform 1.5, but CI failed because it's running 1.9."

To eliminate this friction, this project couples DevPod / Dev Containers with mise:

1. Unified Tooling with mise

Instead of cluttering the host machine with multiple version managers (tfenv, nvm, pyenv), mise.toml pins exact binaries:

[tools] terraform = "1.16.6" uv = "0.13.1" 

2. Isolated Workspace via Dev Containers

Our .devcontainer/Dockerfile layers mise directly on an official Ubuntu 24.04 base:

FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 COPY --from=jdxcode/mise /usr/local/bin/mise /usr/local/bin/ RUN echo 'eval "$(mise activate bash)"' >> /home/vscode/.bashrc && \  echo 'eval "$(mise activate zsh)"' >> /home/vscode/.zshrc RUN usermod -aG root vscode 

Whenever a developer spins up this workspace via DevPod, VS Code Dev Containers, or GitHub Codespaces, they immediately enter an identical, battle-tested environment.

🔐 Authentication & Credential Hygiene

For automated Terraform deployments against Azure, authenticating as a Microsoft Entra ID Service Principal is the industry standard.

Exporting AzureRM Credentials

We configure authentication using the four core environment variables consumed natively by the HashiCorp AzureRM provider:

  • ARM_CLIENT_ID
  • ARM_CLIENT_SECRET
  • ARM_TENANT_ID
  • ARM_SUBSCRIPTION_ID

These are stored locally in .env.azure (which is strictly added to .gitignore):

export ARM_CLIENT_ID="<YOUR_APP_REGISTRATION_CLIENT_ID>" export ARM_CLIENT_SECRET="<YOUR_CLIENT_SECRET_VALUE>" export ARM_TENANT_ID="<YOUR_ENTRA_TENANT_ID>" export ARM_SUBSCRIPTION_ID="<YOUR_SUBSCRIPTION_ID>" 

To load them cleanly in your active shell:

set -a source .env.azure set +a 

⚠️ Security Tip: Always verify that your credentials file is ignored by Git prior to making commits:

git check-ignore -v .env.azure 

💻 Terraform Code Structure

A clean, modular folder layout ensures that code remains readable and easy to maintain as infrastructure grows:

azure-terraform/ ├── .devcontainer/ │ ├── devcontainer.json │ └── Dockerfile ├── .gitignore ├── .env.azure # Keep local & secret! ├── mise.toml ├── README.md └── terraform/ ├── versions.tf # Terraform & provider constraints ├── providers.tf # Provider configurations ├── variables.tf # Configurable input parameters ├── main.tf # Resource definitions └── outputs.tf # Exported attributes 

Let's inspect the key configuration files:

1. terraform/versions.tf & providers.tf

Pinning modern versions avoids surprise breaking changes:

# versions.tf terraform { required_version = ">= 1.6.0" required_providers { azurerm = { source = "hashicorp/azurerm" version = "~> 4.0" } } } # providers.tf provider "azurerm" { features {} } 

Note: In azurerm v4.0+, the features {} block remains mandatory.

2. terraform/variables.tf

Parameterizing region, names, and CIDR blocks keeps our configuration DRY:

variable "resource_group_name" { description = "Name of the Azure resource group" type = string default = "rg-terraform-lab" } variable "location" { description = "Azure region for the infrastructure" type = string default = "Central India" } variable "vnet_address_space" { description = "Address space for the virtual network" type = list(string) default = ["10.10.0.0/16"] } variable "subnet_address_prefixes" { description = "Address range for the application subnet" type = list(string) default = ["10.10.1.0/24"] } 

3. terraform/main.tf

Here we provision the resource group, virtual network, subnet, NSG, and their association:

resource "azurerm_resource_group" "lab" { name = var.resource_group_name location = var.location tags = { environment = "lab" managed_by = "terraform" project = "azure-infrastructure" } } resource "azurerm_virtual_network" "lab" { name = "vnet-terraform-lab" location = azurerm_resource_group.lab.location resource_group_name = azurerm_resource_group.lab.name address_space = var.vnet_address_space tags = { environment = "lab" managed_by = "terraform" } } resource "azurerm_subnet" "app" { name = "snet-app" resource_group_name = azurerm_resource_group.lab.name virtual_network_name = azurerm_virtual_network.lab.name address_prefixes = var.subnet_address_prefixes } resource "azurerm_network_security_group" "lab" { name = "nsg-terraform-lab" location = azurerm_resource_group.lab.location resource_group_name = azurerm_resource_group.lab.name tags = { environment = "lab" managed_by = "terraform" } } # Explicit decoupling: association resource rather than an inline subnet block resource "azurerm_subnet_network_security_group_association" "app" { subnet_id = azurerm_subnet.app.id network_security_group_id = azurerm_network_security_group.lab.id } 

💡 Best Practice Note: Avoid declaring subnets inline within the azurerm_virtual_network resource. Managing azurerm_subnet and azurerm_subnet_network_security_group_association as dedicated resources avoids configuration drift, circular dependencies, and unexpected resource replacements.

4. terraform/outputs.tf

Outputs expose resource attributes that downstream modules or CLI scripts can consume:

output "resource_group_name" { description = "Azure resource group name" value = azurerm_resource_group.lab.name } output "virtual_network_name" { description = "Azure virtual network name" value = azurerm_virtual_network.lab.name } output "virtual_network_id" { description = "Azure virtual network resource ID" value = azurerm_virtual_network.lab.id } output "subnet_id" { description = "Application subnet resource ID" value = azurerm_subnet.app.id } output "network_security_group_id" { description = "Network security group resource ID" value = azurerm_network_security_group.lab.id } 

🚀 Execution & Verification Flow

Navigating to the terraform/ directory, the deployment follows the canonical Terraform workflow:

1. Initialization & Linting

# Initialize working directory & download AzureRM provider terraform init # Canonical format checking terraform fmt -recursive # Syntax and structural validation terraform validate 

2. Planning

terraform plan 

Terraform determines the execution graph and confirms the exact 5 resources to be created:

Plan: 5 to add, 0 to change, 0 to destroy. 

3. Application

terraform apply 

Type yes when prompted. In less than 30 seconds, Azure confirms all resources are created.

4. Verification

Inspect the live state directly from your terminal:

terraform state list 

Expected output:

azurerm_network_security_group.lab azurerm_resource_group.lab azurerm_subnet.app azurerm_subnet_network_security_group_association.app azurerm_virtual_network.lab 

You can view the exported IDs at any time:

terraform output 

Or verify them using the Azure CLI:

az network vnet show \ --resource-group rg-terraform-lab \ --name vnet-terraform-lab \ --output table 

🛡️ Critical Lessons Learned & Production Considerations

1. Local State vs. Remote Backend

In this learning lab, local state (terraform.tfstate) is used. However, in a team or production setup, never rely on local state. Local state cannot prevent concurrent writes and risks leaking secrets.
Production path: Migrate to an Azure Blob Storage backend with state locking via Azure Blob Leases:

terraform { backend "azurerm" { resource_group_name = "rg-tfstate" storage_account_name = "sttfstatelab123" container_name = "tfstate" key = "networking.terraform.tfstate" } } 

2. Avoid Static Client Secrets in CI/CD

While .env.azure is great for fast local iteration, CI/CD pipelines (like GitHub Actions or GitLab CI) should utilize Azure Workload Identity Federation (OIDC). OIDC uses short-lived tokens without storing long-lived passwords or client secrets in your repository settings.

3. Tagging Strategy

Tagging resources early (environment = "lab", managed_by = "terraform") makes cost attribution and lifecycle tracking drastically easier as the infrastructure expands.

🧹 Tearing Down (Zero Cost Surprise)

When your lab session concludes, cleanup is just as important as creation to prevent runaway cloud charges:

# Review what will be destroyed terraform plan -destroy # Tear down all resources cleanly terraform destroy -auto-approve 

🗺️ What's Next?

From here, this foundation is primed for expansion:

  • [ ] Add Compute: Provision an Azure Virtual Machine or Azure Kubernetes Service (AKS) inside snet-app.
  • [ ] Implement GitHub Actions with OIDC for automated pull-request validation (fmt, validate, tflint, and plan).
  • [ ] Modularize the networking configuration into a reusable Terraform Module.

💬 Let's Connect

How do you manage your local vs. remote Terraform environments? Are you using DevPod or containers for cloud tooling? Let me know in the comments below!

If you found this helpful, feel free to ❤️ or 🦄 this post!