top of page

Run Terraform from your laptop (Test env) with remotestate in Azure Storage — the modular way

  • Writer: Rajamohan Rajendran
    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


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


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


bottom of page