DEV Community

Cover image for Provisioning Resilient Azure Infrastructure with Terraform, DevPod, and mise: A Hands-On Guide
Alan Varghese
Alan Varghese

Posted on

Provisioning Resilient Azure Infrastructure with Terraform, DevPod, and mise: A Hands-On Guide

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 ]             | | |
|   |   +-------------------------------------------------------+ | |
|   +-------------------------------------------------------------+ |
+-------------------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

To load them cleanly in your active shell:

set -a
source .env.azure
set +a
Enter fullscreen mode Exit fullscreen mode

⚠️ 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
Enter fullscreen mode Exit fullscreen mode

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 {}
}
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ 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
}
Enter fullscreen mode Exit fullscreen mode

πŸš€ 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
Enter fullscreen mode Exit fullscreen mode

2. Planning

terraform plan
Enter fullscreen mode Exit fullscreen mode

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

Plan: 5 to add, 0 to change, 0 to destroy.
Enter fullscreen mode Exit fullscreen mode

3. Application

terraform apply
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

You can view the exported IDs at any time:

terraform output
Enter fullscreen mode Exit fullscreen mode

Or verify them using the Azure CLI:

az network vnet show \
  --resource-group rg-terraform-lab \
  --name vnet-terraform-lab \
  --output table
Enter fullscreen mode Exit fullscreen mode

πŸ›‘οΈ 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

πŸ—ΊοΈ 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!

Top comments (0)