Run Terraform from your laptop (Test env) with remotestate in Azure Storage — the modular way
- Rajamohan Rajendran
- Sep 6
- 5 min read
Goal: From your local machine, deploy to a test environment using Terraform modules, with state stored safely in Azure Blob Storage.
Tools: PowerShell, Azure CLI, Terraform.
0) Prerequisites (install once)
• Terraform — download & add to PATH
• Azure CLI — install via winget (Windows) or official docs (macOS/Linux)
• PowerShell 7+ — preinstalled on Windows 10+; install on macOS/Linux if needed
terraform -version
az version
$PSVersionTable.PSVersion
Why this matters: using known, recent versions ensures provider plugins and backend auth work reliably.
1) Sign in to Azure (no secrets in files)
# opens browser; signs you in with AAD
az login
# pick the right subscription
az account list --output table
az account set --subscription "<SUBSCRIPTION-NAME-OR-ID>"
Why: Terraform will use your az login session (Azure AD) for the backend and the provider—cleaner than
pasting access keys.
2) Prepare the remote state location (one-time setup)
• Resource group: rg-tfstate-shared
• Storage account: sttfstateprod123
• Container: tfstate
• State key per env: e.g., test/infra.tfstate
$rg = "rg-tfstate-shared"
$sa = "sttfstateprod123"
$cn = "tfstate"
$loc = "eastus"
# (one-time) ensure RG & storage account exist (uncomment if needed)
# az group create -n $rg -l $loc
# az storage account create -g $rg -n $sa -l $loc --sku Standard_LRS
# (one-time) create the blob container that will hold tfstate
az storage container create `
--name $cn `
--account-name $sa `
--auth-mode login `
--public-access off
Why: remote state enables locking (prevents two applies at once), team collaboration, and safe recovery.
3) Project layout with modules (environment-aware)
infra/
├─ main.tf # root calls modules
├─ providers.tf # provider + terraform blocks
├─ variables.tf # variables for root module
├─ outputs.tf # optional, expose outputs
├─ backend/
│ └─ test.backend.hcl # remote state settings for TEST
├─ env/
│ ├─ test.tfvars # variable values for TEST
│ └─ prod.tfvars
└─ modules/
├─ resource_group/
│ ├─ main.tf
│ ├─ variables.tf
│ └─ outputs.tf
└─ storage_account/
├─ main.tf
├─ variables.tf
└─ outputs.tf
Why modules?
Reusability and separation. Your root stays tiny; each module owns a focused concern.
Sample: providers.tf (root)
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.0"
}
}
}
provider "azurerm" {
features {}
}
Sample: backend/test.backend.hcl
resource_group_name = "rg-tfstate-shared"
storage_account_name = "sttfstateprod123"
container_name = "tfstate"
key = "test/infra.tfstate"
use_azuread_auth = true
Why an HCL file?
Keeps commands short and avoids leaking secrets into CLI history.
Sample: env/test.tfvars
location = "eastus"
env_name = "test"
rg_name = "rg-myapp-test"
sa_name = "myappteststorage123"
sa_sku = "Standard_LRS"
sa_kind = "StorageV2"
Sample: variables.tf (root)
variable "location" { type = string }
variable "env_name" { type = string }
variable "rg_name" { type = string }
variable "sa_name" { type = string }
variable "sa_sku" { type = string }
variable "sa_kind" { type = string }
Sample: main.tf (root calling modules)
module "rg" {
source = "./modules/resource_group"
name = var.rg_name
location = var.location
}
module "storage" {
source = "./modules/storage_account"
name = var.sa_name
resource_group_name = module.rg.name
location = var.location
sku = var.sa_sku
kind = var.sa_kind
}
Module: modules/resource_group
# main.tf
resource "azurerm_resource_group" "this" {
name = var.name
location = var.location
}
output "name" { value = azurerm_resource_group.this.name }
variable "name" { type = string }
variable "location" { type = string }
Module: modules/storage_account
# main.tf
resource "azurerm_storage_account" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
account_tier = split("_", var.sku)[0] # e.g., "Standard"
account_replication_type = split("_", var.sku)[1] # e.g., "LRS"
account_kind = var.kind #
allow_blob_public_access = false
min_tls_version = "TLS1_2"
tags = {
env = "test"
}
}
output "id" { value = azurerm_storage_account.this.id }
output "name" { value = azurerm_storage_account.this.name }
variable "name" { type = string }
variable "resource_group_name" { type = string }
variable "location" { type = string }
variable "sku" { type = string }
variable "kind" { type = string }
4) Initialize Terraform (TEST env)
Set-Location .\infra
# tidy files (optional)
terraform fmt -recursive
# critical: wire up backend and download providers
terraform init -backend-config="backend/test.backend.hcl"
What init does & why it matters: downloads providers, configures the remote backend, and enables state
locking to prevent concurrent writes.\
5) Validate the configuration (catch mistakes early)
terraform validate
Why validate: static checks—wrong types, missing variables, typos—before you waste time planning or
applying.
6) Plan your changes for TEST (dry-run + review)
New-Item -ItemType Directory -Path .\plans -ErrorAction SilentlyContinue | Out-Null
terraform plan `
-var-file="env/test.tfvars" `
-out="plans/plan-test.tfplan"
What plan does & why: refreshes state from Azure to detect drift, computes the execution plan, and saves a
binary plan file you can review and apply exactly (no surprises).
7) Apply (make it real, with state safely written remotely)
terraform apply "plans/plan-test.tfplan"
Why apply this way: you apply exactly what was reviewed. The Azure Blob backend takes a lease lock on
the state, and the state is updated under test/infra.tfstate.
8) common commands you’ll use
# show resources known to state
terraform state list
# inspect a resource
terraform state show module.storage.azurerm_storage_account.this
# re-check drift
terraform plan -var-file="env/test.tfvars"
# target a single module (use sparingly)
terraform apply -target="module.storage" -var-file="env/test.tfvars"
# destroy TEST (be careful!)
terraform destroy -var-file="env/test.tfvars"
9) Optional: one-button PowerShell helper (run-test.ps1)
param(
[ValidateSet("init","validate","plan","apply","destroy")]
[string]$Action = "plan"
)
$ErrorActionPreference = "Stop"
$repoRoot = "$PSScriptRoot\infra"
$backend = "backend/test.backend.hcl"
$tfvars = "env/test.tfvars"
$planOut = "plans/plan-test.tfplan"
# ensure login
az account show *> $null 2>&1
if ($LASTEXITCODE -ne 0) { az login | Out-Null }
Set-Location $repoRoot
terraform fmt -recursive | Out-Null
switch ($Action) {
"init" { terraform init -backend-config=$backend ; break }
"validate" { terraform validate ; break }
"plan" {
terraform init -backend-config=$backend | Out-Null
New-Item -ItemType Directory -Path ".\plans" -ErrorAction SilentlyContinue | Out-Null
terraform validate
terraform plan -var-file=$tfvars -out=$planOut
break
}
"apply" {
if (-not (Test-Path $planOut)) {
Write-Host "No saved plan found. Creating one first..."
terraform plan -var-file=$tfvars -out=$planOut
}
terraform apply $planOut
break
}
"destroy" {
terraform destroy -var-file=$tfvars
break
}
}
.\run-test.ps1 -Action init
.\run-test.ps1 -Action validate
.\run-test.ps1 -Action plan
.\run-test.ps1 -Action apply
10) Why we follow these exact steps (the human explanation)
• Modules first: keeps code tidy; each module is a small Lego piece; root assembles them.
• Env-specific files: swap only the data that changes; logic stays identical—fewer mistakes.
• Remote state in Azure: collaboration, locking, security (no local secrets), recoverability.
• init → validate → plan → apply: wire up backend, catch errors early, preview impact, apply exactly what you reviewed.
• Saved plan files: ensure immutability of intent between review and execution.
• HCL backend file: short commands, cleaner CLI history, fewer secrets exposure risks
11) Troubleshooting
• Backend auth error → confirm az login, subscription, and use_azuread_auth = true.
• Container not found → create it with az storage container create … --auth-mode login.
• State lock remains → wait 60–90s and retry; if stuck, release the blob lease cautiously.
• Provider version mismatch → pin a known-good major (e.g., ~> 3.0) and run terraform init -upgrade.
• Naming errors → fix inputs in test.tfvars (e.g., storage account name rules).
12) Printable checklist
• az login and set the subscription
• Storage RG/account/container exist
• backend/test.backend.hcl points to the right RG/SA/container/key
• env/test.tfvars has correct names & SKU for TEST
• terraform init -backend-config=backend/test.backend.hcl
• terraform validate
• terraform plan -var-file=env/test.tfvars -out=plans/plan-test.tfplan
• terraform apply plans/plan-test.tfplan

Comments