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:
- Deterministic & Repeatable Provisioning: Zero reliance on manual Azure Portal configurations.
-
Predictable Developer Workspace: Avoid version discrepancies across developer machines by leveraging DevPod, Docker, and
mise. - Sound Networking Foundations: Establish an isolated Virtual Network (VNet), dedicated application subnet, and Network Security Group (NSG).
- 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_IDARM_CLIENT_SECRETARM_TENANT_IDARM_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_networkresource. Managingazurerm_subnetandazurerm_subnet_network_security_group_associationas 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, andplan). - [ ] 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!
Top comments (0)