Create your own
Lesson illustration

Parameterized Azure Resource Provisioning with Terraform

Hello again. You now have a credential-free local Terraform workflow: Azure CLI authenticates you interactively, and Terraform uses that session while the intended subscription is made explicit in your shell.

This lesson turns that connection into a small, reusable Azure lab configuration. You will provision a resource group and a Standard locally redundant storage account, parameterize their environment-specific settings, expose useful outputs, and let Terraform infer the creation order from resource references. The storage account is deliberately modest: in the next lesson it can become the bootstrap location for remote Terraform state.


Treat the Terraform root module as an interface

A Terraform directory is a root module. Splitting it into main.tf, variables.tf, and outputs.tf is an organizational convention: Terraform reads every .tf file in the directory together.

For this lab, the files have distinct responsibilities:

FileResponsibility
main.tfProvider configuration and Azure resources to create
variables.tfInputs that differ by environment or deployment
outputs.tfValues the root module exposes after deployment
lab.auto.tfvarsYour local, ignored values for this personal lab
.gitignorePrevents state, plans, and local value files from reaching Git

A variable is not merely a shorter way to write a string. It is part of the configuration’s contract: it declares what a caller may customize, what type is expected, what defaults are safe, and which values should be rejected before Azure is changed.

variable block reference for the Terraform configuration language

Read HashiCorp’s variable-block reference to establish the difference between a reusable input and a hard-coded implementation detail.

In the Background and Configuration model sections, read the introduction. Then, in the subsections type, default, description, validation, and sensitive, focus on why constraints make failures occur during planning rather than during an Azure deployment. In Examples, review Basic variable declaration and Variable with validation; skip the provider-credential examples, since this course uses Azure CLI authentication locally.

There are four choices worth making deliberately:

  • type prevents accidental type confusion and documents expected shape, such as map(string) for tags.
  • description explains the input from the consumer’s perspective. This becomes important when the configuration later grows beyond a personal lab.
  • default makes an input optional. Use one only where a default is genuinely safe.
  • validation enforces a rule before Terraform creates a plan. It is ideal for platform naming constraints that Terraform itself cannot infer.

A storage-account name is a useful example. Azure requires it to be globally unique, 3–24 characters long, and composed only of lowercase letters and digits. Terraform can validate the character set and length locally, but it cannot prove global uniqueness without asking Azure during the plan or apply.

The image shows the intended separation: `variables.tf` declares an input contract, while `main.tf` consumes those values when defining Azure resources. This lesson uses the modern direct `var.name` syntax rather than interpolation-only strings.

Build a small parameterized Azure configuration

Use the same grasp-azure-lab directory from the previous lesson. First, add these extra exclusions to the existing .gitignore:

*.tfplan
lab.auto.tfvars

A saved Terraform plan can contain sensitive data, even where the CLI redacts it. Treat it like state: local and uncommitted.

Now replace the minimal main.tf from the previous lesson with the following complete version. It retains the same AzureRM provider setup, with no secrets or static credentials.

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
}

resource "azurerm_resource_group" "lab" {
  name     = var.resource_group_name
  location = var.location

  tags = merge(
    var.tags,
    {
      managed_by = "terraform"
    }
  )
}

resource "azurerm_storage_account" "lab" {
  name                     = var.storage_account_name
  resource_group_name      = azurerm_resource_group.lab.name
  location                 = azurerm_resource_group.lab.location
  account_tier             = "Standard"
  account_replication_type = "LRS"

  https_traffic_only_enabled   = true
  min_tls_version              = "TLS1_2"
  allow_nested_items_to_be_public = false

  tags = merge(
    var.tags,
    {
      managed_by = "terraform"
    }
  )
}

Create variables.tf alongside it:

variable "resource_group_name" {
  type        = string
  description = "Name of the Azure resource group for the lab."
  nullable    = false
}

variable "location" {
  type        = string
  description = "Azure region for the lab resources."
  default     = "westeurope"
  nullable    = false

  validation {
    condition     = contains(["westeurope", "northeurope"], var.location)
    error_message = "For this lab, location must be westeurope or northeurope."
  }
}

variable "storage_account_name" {
  type        = string
  description = "Globally unique storage account name: 3 to 24 lowercase letters and digits."
  nullable    = false

  validation {
    condition = (
      length(var.storage_account_name) >= 3 &&
      length(var.storage_account_name) <= 24 &&
      can(regex("^[a-z0-9]+$", var.storage_account_name))
    )
    error_message = "storage_account_name must contain 3 to 24 lowercase letters or digits only."
  }
}

variable "tags" {
  type        = map(string)
  description = "Tags applied to all lab resources."

  default = {
    environment = "lab"
    course      = "grasp-azure"
    cleanup     = "review"
  }
}

Finally, create outputs.tf:

output "resource_group_name" {
  description = "Name of the resource group created for this lab."
  value       = azurerm_resource_group.lab.name
}

output "storage_account_name" {
  description = "Name of the storage account created for this lab."
  value       = azurerm_storage_account.lab.name
}

output "storage_account_id" {
  description = "Azure resource ID of the storage account."
  value       = azurerm_storage_account.lab.id
}

output "storage_account_blob_endpoint" {
  description = "Primary Blob service endpoint of the storage account."
  value       = azurerm_storage_account.lab.primary_blob_endpoint
}

Two design details matter here:

  1. Inputs describe the desired deployment.
    resource_group_name, storage_account_name, and tags might reasonably differ between a personal lab, test environment, and production environment. They are variables.

  2. References describe relationships inside the deployment.
    The storage account does not receive a separately typed resource-group name or location. Instead, it uses the values resolved from the resource group:

    resource_group_name = azurerm_resource_group.lab.name
    location            = azurerm_resource_group.lab.location
    

    This is a dependency reference in the form:

    resource_type.local_name.attribute
    

    Terraform sees these references and constructs a dependency graph. It therefore creates the resource group before the storage account and destroys the storage account before the resource group. No explicit depends_on is needed, because the data relationship already expresses the true infrastructure relationship.

The merge expression provides one modest example of an internal implementation expression. The caller supplies var.tags, while the root module guarantees that managed_by = "terraform" is applied. That mandatory tag is placed second, so it cannot accidentally be overridden in the variable file.


Supply local lab values safely

Create lab.auto.tfvars in the same directory. It is intentionally ignored by Git. Replace the placeholder text below with a name that will be unique in Azure; do not literally include angle brackets.

resource_group_name  = "rg-grasp-lab-yourinitials"
location             = "westeurope"
storage_account_name = "grasp<yourinitials><sixrandomdigits>"

tags = {
  environment = "lab"
  course      = "grasp-azure"
  cleanup     = "review"
  owner       = "your-initials"
}

For example, a valid storage account name might be:

storage_account_name = "graspab482917"

Do not use a real name, e-mail address, client name, password, connection string, or token in this file. Its Git exclusion reduces accidental exposure, but .tfvars files are not a secret-management mechanism.

Terraform automatically loads files named terraform.tfvars and files ending in .auto.tfvars. Here, lab.auto.tfvars is convenient because it is unmistakably local to this lab and automatically used by terraform plan.

The Microsoft quickstart uses the same overall division: provider configuration, variable definitions, a resource block consuming variables, and outputs exposing resource values.

Quickstart: Create an Azure resource group using Terraform

Read Microsoft Learn’s resource-group quickstart as a compact reference for the conventional Terraform file layout and post-apply output verification.

In Implement the Terraform code, inspect steps 2 through 5, beginning from the setup step. Compare its variable, resource, and output structure with this lesson’s configuration; our lab adds a storage account specifically to demonstrate a resource-to-resource dependency. Then read Verify the results for the use of terraform output, and scan Clean up resources, beginning the cleanup guidance.


Plan first: inspect inputs, outputs, and dependencies

Format and validate the whole root module:

terraform fmt
terraform validate

Then create a saved plan:

terraform plan -out=lab.tfplan

Read the plan rather than immediately confirming it. It should show:

  • one azurerm_resource_group.lab to be created;
  • one azurerm_storage_account.lab to be created;
  • the resource group name, location, and tags derived from your variables;
  • the storage account using the resource group’s name and location;
  • some outputs, notably the storage account ID and Blob endpoint, as “known after apply.”

That last point is important. Terraform knows the desired storage-account name before apply because you supplied it. Azure assigns the resource ID and provider-computed endpoint during creation, so Terraform correctly reports those values as unavailable until apply completes.

If the plan errors before showing Azure changes, interpret the failure by layer:

FailureTypical causeCorrect response
Required variable not setlab.auto.tfvars is missing, misnamed, or an input is absentCheck the filename and variable names.
Validation errorStorage account name has uppercase letters, punctuation, or invalid lengthChange the local input; do not weaken the validation.
Storage-account name unavailableAnother Azure customer already owns the global nameChange only storage_account_name, then plan again.
Authorization errorAzure CLI session or subscription context is wrong, or your identity lacks accessRe-run the account check from the previous lesson before changing Terraform.

Cost of this practical action: USD 0 in Azure usage. terraform fmt, terraform validate, and terraform plan do not create Azure resources. The saved lab.tfplan file exists only on your workstation.


Apply, verify, and use the outputs

When the plan accurately matches your intended names, subscription, region, tags, and two-resource scope, apply the reviewed plan:

terraform apply lab.tfplan

Terraform should create the resource group first and then the storage account. After a successful apply, query the root-module outputs:

terraform output

For values useful in scripts, request one value without Terraform’s quotation marks:

terraform output -raw storage_account_name
terraform output -raw storage_account_blob_endpoint

You can also verify the resources through Azure CLI:

az group show \
  --name "$(terraform output -raw resource_group_name)" \
  --query "{name:name, location:location, tags:tags}" \
  --output jsonc
az storage account show \
  --name "$(terraform output -raw storage_account_name)" \
  --resource-group "$(terraform output -raw resource_group_name)" \
  --query "{name:name, location:primaryLocation, sku:sku.name, httpsOnly:enableHttpsTrafficOnly}" \
  --output jsonc

In PowerShell, the $(terraform output -raw ...) subexpressions work as well. If you prefer, run the output commands first and paste the resulting names into the Azure CLI commands.

Outputs make this root module easier to operate and compose:

  • A human can read them after apply.
  • A shell script can capture them with terraform output -raw.
  • A later root module or child module can consume an exposed value through a module output.
  • They do not make a value secret. An output should be marked sensitive = true if it exposes confidential information, and that still does not remove the value from Terraform state.

The outputs in this lesson are operational metadata rather than credentials. Do not output storage access keys or connection strings. The AzureRM provider may still record sensitive provider-returned values in Terraform state, which is one reason the next lesson moves state to a controlled Azure Storage backend.

Cost of this practical action: the resource group itself costs USD 0. The storage account is configured as Standard LRS, Azure’s lowest-cost replication option for this kind of lab. An empty account with no uploaded blobs, snapshots, backup, diagnostics, or data transfer is normally expected to have effectively zero or negligible usage for a short exercise; Azure Storage pricing is usage-based and varies by region, subscription, and operations. Keep it empty, do not enable extra data services, and check Cost Management if you leave it running. This account is a candidate for the state backend in the next lesson, so retaining it briefly is reasonable.


Keep the dependency graph clean and retain a cleanup choice

A common Terraform mistake is to replace a meaningful reference with duplicated input values:

# Avoid this pattern in this lab.
resource_group_name = var.resource_group_name
location            = var.location

It may deploy successfully, but it weakens the resource relationship. If resource-group naming or location logic later changes, the storage account can become inconsistent. The configuration you used instead derives both values from the resource that owns them.

Use this practical rule:

SituationPreferred mechanism
A deployment choice comes from outside the moduleInput variable, referenced as var.<name>
A value belongs to an existing Terraform resourceResource attribute reference
A value should be available after applyOutput block
A dependency has no data relationship at allConsider depends_on carefully; do not use it by default

For now, keep the resource group and storage account if you will continue to the next lesson soon. That lesson will use Azure Storage to establish remote state safely.

If you are pausing the course or want to remove every resource now, destroy only through Terraform:

terraform plan -destroy -out=destroy.tfplan
terraform apply destroy.tfplan

Do not delete one of these resources manually in the Azure portal or CLI while Terraform still manages it. Manual deletion creates drift and is confusing when you later run plan or destroy.

Cost of this cleanup action: USD 0 for the deletion operation itself. Destroying the storage account stops its future storage and transaction usage. Review the destroy plan before applying it; deleting a storage account permanently deletes any blobs it contains.


Key takeaways

You now have a small but realistic Terraform root module for Azure:

  • Variables make deployment-specific choices explicit, typed, documented, and validated.
  • A local ignored lab.auto.tfvars file supplies personal lab values without placing them in source control.
  • Dependency references such as azurerm_resource_group.lab.name express both data flow and creation order.
  • Outputs expose selected post-deployment values to people and automation.
  • terraform plan -out followed by applying the reviewed plan gives you a safer operational workflow.
  • The resource group is free; the empty Standard LRS storage account should remain negligible in cost, but should still be retained only while it has a purpose.

Next, you will use Azure Storage deliberately as a Terraform remote-state backend, then practice safe plan, apply, drift inspection, and destroy operations against that shared state.

Can't find a good explanation? Sign up and we'll make it for you

Sign up