Civica Training - Terraform on Azure

Modules & State Management

Reusable infrastructure and shared state

Module 4 of 5 Intermediate

The Challenge

"Your AKS platform works great in dev. Now the team wants staging and production with the same infrastructure. Copying and pasting .tf files isn't going to scale. Let's learn modules and state management."

Variables: Deep Dive

Variables make your configuration flexible and reusable.

variable "environment" { description = "Deployment environment" type = string validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "Environment must be dev, staging, or prod." } } variable "node_count" { description = "Number of AKS nodes" type = number default = 3 validation { condition = var.node_count >= 1 && var.node_count <= 100 error_message = "Node count must be between 1 and 100." } }

Variable Types

Primitive Types

variable "name" { type = string } variable "count" { type = number } variable "enabled" { type = bool }

Complex Types

variable "tags" { type = map(string) } variable "zones" { type = list(string) } variable "node_pool" { type = object({ name = string vm_size = string count = number }) }

Sensitive Variables

variable "db_password" { description = "Database admin password" type = string sensitive = true # Redacted in plan/apply output }
$ terraform plan # azurerm_key_vault_secret.db will be created + resource "azurerm_key_vault_secret" "db" { + name = "db-password" + value = (sensitive value) }
sensitive = true only hides the value from CLI output. The value is still stored in plain text in the state file. Use remote state with encryption.

Setting Variable Values

MethodPrecedenceExample
Default valueLowestdefault = "uksouth"
terraform.tfvarslocation = "uksouth"
*.auto.tfvarsdev.auto.tfvars
-var-file flag-var-file="prod.tfvars"
TF_VAR_ env varexport TF_VAR_location="uksouth"
-var flagHighest-var="location=uksouth"

Higher precedence overrides lower. Use -var-file for environment-specific values.

Knowledge Check 1

1. Which variable setting method has the highest precedence?

2. What does sensitive = true on a variable do?

3. What happens if a validation block's condition evaluates to false?

Locals: Computed Values

Locals define computed or derived values to reduce repetition.

locals { name_prefix = "${var.project}-${var.environment}" common_tags = { Project = var.project Environment = var.environment ManagedBy = "Terraform" Region = var.location } aks_name = "aks-${local.name_prefix}-${var.location}" } # Usage: resource "azurerm_resource_group" "main" { name = "rg-${local.name_prefix}" location = var.location tags = local.common_tags }

Locals vs Variables

AspectVariables (var.)Locals (local.)
SourceExternal input (user/file/env)Computed internally
Settable by userYesNo
Can reference other valuesNo (only defaults)Yes (vars, resources, data, other locals)
Best forConfiguration that changes per deployDerived values, DRY patterns
Syntaxvar.namelocal.name

Rule of thumb: If the user needs to set it, use a variable. If it's computed from other values, use a local.

Outputs: Exposing Values

output "aks_cluster_name" { description = "Name of the AKS cluster" value = azurerm_kubernetes_cluster.main.name } output "aks_kube_config" { description = "Kubeconfig for cluster access" value = azurerm_kubernetes_cluster.main.kube_config_raw sensitive = true # Contains credentials } output "acr_login_server" { description = "ACR login server URL" value = azurerm_container_registry.main.login_server }

What Are Modules?

A module is a container for multiple resources that are used together. Every Terraform configuration is already a module (the "root module").

Why Modules?

  • Reuse infrastructure patterns
  • Encapsulate complexity
  • Enforce standards
  • Enable team collaboration

Think of Modules As...

  • Functions in programming
  • Variables = parameters
  • Outputs = return values
  • Resources = the function body

Module Structure

modules/ aks-cluster/ |- main.tf # Resources |- variables.tf # Input variables |- outputs.tf # Output values |- versions.tf # Provider requirements |- README.md # Documentation networking/ |- main.tf |- variables.tf |- outputs.tf |- versions.tf
  • main.tf - the resources this module creates
  • variables.tf - inputs the caller must/can provide
  • outputs.tf - values exposed to the caller
  • versions.tf - required providers and versions
  • Keep modules focused on one concern

Creating a Module

modules/networking/variables.tf

variable "resource_group_name" { description = "Name of the resource group" type = string } variable "location" { description = "Azure region" type = string } variable "vnet_address_space" { description = "VNet CIDR range" type = list(string) default = ["10.0.0.0/16"] } variable "subnets" { description = "Map of subnet configurations" type = map(object({ address_prefixes = list(string) })) }

Creating a Module

modules/networking/main.tf

resource "azurerm_virtual_network" "main" { name = "vnet-${var.resource_group_name}" location = var.location resource_group_name = var.resource_group_name address_space = var.vnet_address_space } resource "azurerm_subnet" "main" { for_each = var.subnets name = each.key resource_group_name = var.resource_group_name virtual_network_name = azurerm_virtual_network.main.name address_prefixes = each.value.address_prefixes }

Creating a Module

modules/networking/outputs.tf

output "vnet_id" { description = "ID of the virtual network" value = azurerm_virtual_network.main.id } output "vnet_name" { description = "Name of the virtual network" value = azurerm_virtual_network.main.name } output "subnet_ids" { description = "Map of subnet name to subnet ID" value = { for k, v in azurerm_subnet.main : k => v.id } }

Calling a Module

module "networking" { source = "./modules/networking" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location vnet_address_space = ["10.0.0.0/16"] subnets = { "snet-aks-nodes" = { address_prefixes = ["10.0.1.0/24"] } "snet-aks-pods" = { address_prefixes = ["10.0.2.0/22"] } "snet-appgw" = { address_prefixes = ["10.0.8.0/24"] } } } # Reference module outputs: vnet_subnet_id = module.networking.subnet_ids["snet-aks-nodes"]

Module Sources

SourceExampleUse Case
Local pathsource = "./modules/aks"Same repository
Terraform Registrysource = "Azure/aks/azurerm"Community/verified modules
GitHubsource = "github.com/org/repo//modules/aks"Private org modules
Git (generic)source = "git::https://..."Any Git repository
Azure DevOpssource = "git::https://dev.azure.com/..."ADO hosted modules
# Pin module versions! module "aks" { source = "Azure/aks/azurerm" version = "7.5.0" # Always pin versions }

Knowledge Check 2

1. How do you access an output from a child module named "networking"?

2. After adding a new module source, what must you run?

3. What is the standard file layout for a Terraform module?

Remote State: Azure Blob Backend

Store state remotely so the entire team shares a single source of truth.

terraform { backend "azurerm" { resource_group_name = "rg-terraform-state" storage_account_name = "stterraformstate001" container_name = "tfstate" key = "aks-platform/dev.terraform.tfstate" } }

State Locking

Prevents concurrent operations from corrupting state.

How It Works

  • Azure Blob backend uses blob leases for locking
  • When running plan/apply, Terraform acquires a lease
  • Other operations wait or fail
  • Lock is released when the operation completes

Lock Stuck?

# Force unlock (use with caution!) $ terraform force-unlock LOCK_ID # The lock ID is shown in the # error message when a lock # conflict occurs.
State locking is automatic with the Azure Blob backend. No extra configuration needed.

Setting Up the State Backend

Create the storage account first (bootstrap)

# bootstrap.sh - Run this ONCE before terraform init # Create resource group for state az group create \ --name rg-terraform-state \ --location uksouth # Create storage account az storage account create \ --name stterraformstate001 \ --resource-group rg-terraform-state \ --sku Standard_GRS \ --encryption-services blob \ --min-tls-version TLS1_2 # Create container az storage container create \ --name tfstate \ --account-name stterraformstate001 # Enable versioning for recovery az storage account blob-service-properties update \ --account-name stterraformstate001 \ --resource-group rg-terraform-state \ --enable-versioning true

Workspaces

Workspaces allow you to use the same configuration with different state files.

# List workspaces $ terraform workspace list * default # Create and switch to a new workspace $ terraform workspace new dev Created and switched to workspace "dev"! $ terraform workspace new staging $ terraform workspace new prod # Switch between workspaces $ terraform workspace select prod # Reference current workspace in config name = "rg-aks-${terraform.workspace}"

Workspaces: Pros and Cons

Advantages

  • Simple to set up
  • Same code, different state
  • Quick environment switching
  • Backend stores each workspace's state separately

Limitations

  • All workspaces share the same backend config
  • Can't have different provider configs per workspace
  • Easy to apply to the wrong workspace
  • Not suitable when environments differ significantly
Many teams prefer directory-based environments (separate folders) over workspaces for production use.

Directory-Based Environments

The recommended approach for significant environment differences

infrastructure/ modules/ aks-cluster/ # Shared module networking/ # Shared module environments/ dev/ |- main.tf # Calls modules |- variables.tf |- dev.tfvars # Dev-specific values |- backend.tf # Dev state location staging/ |- main.tf |- variables.tf |- staging.tfvars |- backend.tf prod/ |- main.tf |- variables.tf |- prod.tfvars |- backend.tf

terraform.tfvars Per Environment

dev.tfvars

environment = "dev" location = "uksouth" kubernetes_version = "1.28" aks_sku_tier = "Free" system_node_count = 1 user_node_min = 1 user_node_max = 3 user_vm_size = "Standard_D4s_v5"

prod.tfvars

environment = "prod" location = "uksouth" kubernetes_version = "1.28" aks_sku_tier = "Standard" system_node_count = 3 user_node_min = 3 user_node_max = 20 user_vm_size = "Standard_D8s_v5"
# Apply with environment-specific vars $ terraform apply -var-file="prod.tfvars"

Knowledge Check 3

1. How does the Azure Blob backend implement state locking?

2. What does terraform.workspace return?

3. Why do many teams prefer directory-based environments over workspaces?

Common State Operations

CommandPurpose
terraform state listList all resources in state
terraform state show <addr>Show attributes of one resource
terraform state mvMove/rename a resource in state
terraform state rmRemove a resource from state (doesn't delete it)
terraform state pullDownload and display the remote state
terraform state pushUpload local state to remote (dangerous)
terraform state mv is useful for refactoring. If you rename a resource in code, use state mv (or a moved block) so Terraform doesn't destroy and recreate it.

Moved Blocks: Refactoring Safely

Introduced in Terraform 1.1. Declare renames in code instead of manual state commands.

# Rename a resource without destroying it moved { from = azurerm_resource_group.main to = azurerm_resource_group.aks } # Move a resource into a module moved { from = azurerm_virtual_network.main to = module.networking.azurerm_virtual_network.main }

Reading Remote State (Cross-Project)

Access outputs from another Terraform configuration.

# In the AKS project, read networking project's state data "terraform_remote_state" "networking" { backend = "azurerm" config = { resource_group_name = "rg-terraform-state" storage_account_name = "stterraformstate001" container_name = "tfstate" key = "networking/prod.terraform.tfstate" } } # Use the outputs vnet_subnet_id = data.terraform_remote_state.networking.outputs.aks_subnet_id

Knowledge Check 4

1. What does terraform state rm do to the actual Azure resource?

2. What is the purpose of a moved block?

3. How does terraform_remote_state data source help in multi-project setups?

Module 4 Summary

Key Takeaways

  • Variables with types and validation
  • Locals for computed/DRY values
  • Modules for reusable infrastructure
  • Remote state on Azure Blob with locking
  • Workspaces vs directory-based environments
  • Moved blocks for safe refactoring

Next: Module 5

  • Dynamic blocks and for_each
  • Conditional resources
  • terraform import
  • CI/CD pipeline integration
  • Security best practices
  • Production checklist
Module 4 Complete
← Back