Setting up your local development environment
Before writing any infrastructure configuration, you need three things installed and working: the Terraform CLI, the Azure CLI, and a code editor configured for HCL. This article walks through each installation on Windows, explains the authentication options available to you, and deploys a single resource group to confirm everything is working end to end.
The current stable versions as of this writing are Terraform 1.14.8, Azure CLI 2.85.0, and AzureRM provider 4.68.0. Version numbers matter here — the AzureRM provider had a breaking change in v4.x that breaks every configuration written for v3, and the official HashiCorp tutorial still targets v3. That gap is addressed explicitly later in this article.
Installing Terraform CLI
Terraform ships as a single binary — terraform.exe on Windows. There is no MSI, no installer wizard, no Windows service. The only requirement is that the binary is reachable on your system PATH.
The lowest-friction method on Windows is winget:
winget install -e --id Hashicorp.Terraform
Winget handles the download, extraction, and PATH symlink automatically. After installation, restart your terminal — existing sessions cache PATH at launch — and verify with:
terraform --version
# Expected output:
# Terraform v1.14.8
# on windows_amd64
If you use Chocolatey, the equivalent is choco install terraform from an elevated (Administrator) PowerShell prompt. Chocolatey places the binary at C:\ProgramData\chocolatey\bin\terraform.exe, which is already on the system PATH after Chocolatey itself is installed.
If you prefer manual installation — for example, to manage multiple Terraform versions or to keep control over where the binary lives — download the zip archive directly from https://releases.hashicorp.com/terraform/1.14.8/terraform_1.14.8_windows_amd64.zip, extract terraform.exe to a short path like C:\Terraform, and add that path to the system PATH. Keeping the project root path short matters for a reason covered in the pitfalls section below.
A note on licensing. Since August 2023, Terraform has been under the Business Source License (BUSL 1.1), which restricts use in competing commercial products but places no restrictions on using Terraform to provision your own infrastructure. The community fork OpenTofu exists under the original Mozilla Public License and shares the same HCL syntax — the two are largely compatible for the configurations in this series. This series uses Terraform.
Installing Azure CLI
The Azure CLI is what Terraform uses to authenticate to Azure during local development. Install it with:
winget install --exact --id Microsoft.AzureCLI
This installs the MSI-based package, which bundles a managed Python runtime so you don’t need Python on your system separately. Verify the installation:
az version
This outputs JSON listing the CLI version, Python version, and any installed extensions. You want azure-cli to show 2.85.0 or later.
Authenticating with az login
Running az login is how you connect the Azure CLI — and by extension, Terraform — to your Azure account. On Windows 11 (and recent Windows 10 builds with Azure CLI 2.61.0 or later), the login flow uses Web Account Manager (WAM), which opens a native Windows authentication dialog rather than a browser tab. WAM integrates with your existing Windows session and supports Windows Hello and FIDO keys. If you need the older browser-based flow for any reason, disable WAM first:
az config set core.enable_broker_on_windows=false
az login
After login, the CLI stores your tokens at %USERPROFILE%\.azure\msal_token_cache.bin — encrypted with Windows DPAPI, bound to your user account. The active subscription and tenant details are stored in %USERPROFILE%\.azure\azureProfile.json.
If your account has access to multiple subscriptions, set the one you want Terraform to target:
az account list --output table
az account set --subscription "My Subscription Name"
Two authentication patterns: local dev vs. automation
Understanding the difference between Azure CLI authentication and service principal authentication will save you significant confusion when moving from a local workstation to a CI/CD pipeline.
Azure CLI authentication works by having the AzureRM provider read the cached credentials that az login placed in %USERPROFILE%\.azure\. When you run terraform plan or terraform apply locally, Terraform calls the az binary in the background to obtain a fresh access token. This requires no environment variables or secrets in your Terraform configuration. The tradeoff is that it requires an interactive login, tokens expire (access tokens live about an hour, though the CLI refreshes them automatically), and it fundamentally cannot work in a CI/CD pipeline where there’s no human to complete a login flow.
Service principal authentication uses a dedicated, non-human identity in Microsoft Entra ID (formerly Azure AD) with assigned RBAC roles. You create one with the Azure CLI:
az ad sp create-for-rbac `
--name "terraform-dev" `
--role Contributor `
--scopes /subscriptions/<your-subscription-id>
This command outputs three values — appId, password, and tenant — that map to four environment variables the AzureRM provider reads automatically:
$env:ARM_CLIENT_ID = "<appId>"
$env:ARM_CLIENT_SECRET = "<password>"
$env:ARM_TENANT_ID = "<tenant>"
$env:ARM_SUBSCRIPTION_ID = "<your-subscription-id>"
When these variables are set, Terraform uses them for authentication without any changes to your .tf files. This is the right approach for CI/CD pipelines, shared environments, and any context where az login isn’t available. The limitation is that ARM_CLIENT_SECRET is a long-lived credential that expires (by default after one year) and must be rotated.
Workload Identity Federation is the modern alternative that eliminates the secret entirely. Instead of a password, your CI/CD platform (GitHub Actions, Azure DevOps) issues a short-lived OIDC token, and Azure trusts it via a federation relationship configured on the app registration. Setting ARM_USE_OIDC=true alongside the three non-secret variables (ARM_CLIENT_ID, ARM_TENANT_ID, ARM_SUBSCRIPTION_ID) is enough. No secret to manage, no expiry to track. This is the right long-term target for production pipelines, but it requires additional Azure configuration beyond this article’s scope. It’s introduced here because you’ll encounter it in later articles when CI/CD pipelines are covered.
For this article and the tutorials that follow, use az login for local development. The provider block configuration for CLI auth is shown in the next section.
Configuring VS Code
Any text editor works for writing Terraform. VS Code with the right extensions provides syntax highlighting, inline validation, and provider-aware autocompletion that meaningfully reduces the time spent looking up resource argument names.
Install two extensions. The first is mandatory; the second adds Azure-specific capabilities:
# HashiCorp Terraform extension (v2.39.0)
code --install-extension hashicorp.terraform
# Microsoft Azure Terraform extension (v0.7.0)
code --install-extension ms-azuretools.vscode-azureterraform
The HashiCorp extension (hashicorp.terraform) bundles the Terraform Language Server, which provides IntelliSense with provider-aware autocompletion, Go to Definition for resources and modules, format-on-save via terraform fmt, and inline validation diagnostics. You will see red underlines for invalid arguments before Terraform plan ever runs.
The Azure Terraform extension (ms-azuretools.vscode-azureterraform) adds AzAPI provider IntelliSense, the ability to export existing Azure resources as Terraform configuration, and preflight validation against Azure APIs. CloudShell integration (previously a key feature) is currently unavailable — all commands run in the local terminal.
Two additional extensions worth installing while you’re here: ms-azuretools.vscode-azureresourcegroups for browsing live Azure resources inside VS Code, and ms-vscode.azurecli for IntelliSense in .azcli files. The older ms-vscode.azure-account extension was deprecated in June 2025 and should not be installed.
Add the following to your VS Code settings.json. The important entries are formatOnSave and validateOnSave — the former keeps your files consistently formatted, the latter surfaces errors without requiring a terminal command:
{
"[terraform]": {
"editor.defaultFormatter": "hashicorp.terraform",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[terraform-vars]": {
"editor.defaultFormatter": "hashicorp.terraform",
"editor.formatOnSave": true
},
"terraform.experimentalFeatures.validateOnSave": true,
"terraform.experimentalFeatures.prefillRequiredFields": true
}
One known issue: format-on-save silently does nothing if terraform.exe is not on PATH. If saving a .tf file produces no formatting changes and no error message, PATH is the first thing to check.
Your first configuration: a resource group
Create a new directory for this exercise — keep the path short for reasons explained shortly — and create a single file named main.tf. The complete configuration for deploying an Azure resource group is below. Every line is explained.
# This block tells Terraform which version of itself is required,
# and declares the providers this configuration depends on.
# The "source" tells Terraform to fetch from the public registry
# at registry.terraform.io/hashicorp/azurerm.
# "~> 4.0" means "any 4.x release, but not 5.0 or later."
terraform {
required_version = ">= 1.6"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
# The provider block configures the AzureRM provider itself.
# "features {}" is mandatory — omitting it is a hard error.
# "subscription_id" is also mandatory in v4.x.
# Replace the value below with your actual subscription ID,
# or set the ARM_SUBSCRIPTION_ID environment variable and
# remove the subscription_id line entirely.
provider "azurerm" {
features {}
subscription_id = "00000000-0000-0000-0000-000000000000"
}
# This is the resource being created: an Azure resource group.
# The format is: resource "<type>" "<local_name>"
# The local name ("example") is used only within this configuration
# to reference this resource. It does not affect the name in Azure.
# The "name" argument is what appears in the Azure portal.
resource "azurerm_resource_group" "example" {
name = "rg-getting-started"
location = "East US"
tags = {
environment = "dev"
}
}
To get your subscription ID, run az account show --query id --output tsv. If you’d rather not hardcode it, set the environment variable instead and omit the subscription_id line:
$env:ARM_SUBSCRIPTION_ID = "00000000-0000-0000-0000-000000000000"
Step 1: terraform init
From the directory containing main.tf, run:
terraform init
This command does three things. It downloads the AzureRM provider binary into .terraform/providers/, fetches any referenced modules into .terraform/modules/, and creates the .terraform.lock.hcl dependency lock file.
The lock file records the exact provider version selected and cryptographic hashes of the provider binaries for every platform. It is how Terraform ensures that every team member and every CI/CD run uses exactly the same provider version, regardless of when they run terraform init. Commit .terraform.lock.hcl to version control. Never commit the .terraform/ directory itself — it contains platform-specific binaries and can be regenerated by running terraform init again.
Expected output when init succeeds:
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/azurerm versions matching "~> 4.0"...
- Installing hashicorp/azurerm v4.68.0...
- Installed hashicorp/azurerm v4.68.0 (signed by HashiCorp)
Terraform has been successfully initialized!
Step 2: terraform plan
terraform plan
Plan reads your configuration, compares it against the current state (empty on first run), and produces an execution plan showing what will change. It does not make any changes to Azure. The output will show:
Terraform will perform the following actions:
# azurerm_resource_group.example will be created
+ resource "azurerm_resource_group" "example" {
+ id = (known after apply)
+ location = "eastus"
+ name = "rg-getting-started"
+ tags = {
+ "environment" = "dev"
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
The + symbol means “create.” Other symbols you’ll encounter in later articles are ~ (update in place) and - (destroy). The combination -/+ means the resource must be replaced — destroyed and recreated — because the changed attribute cannot be updated in place. Reading plan output carefully before applying is the habit that prevents most production incidents.
Step 3: terraform apply
terraform apply
Apply generates the plan again, displays it, then prompts:
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value:
Type yes exactly. After approximately 15–30 seconds, the resource group will exist in Azure:
azurerm_resource_group.example: Creating...
azurerm_resource_group.example: Creation complete after 3s
[id=/subscriptions/xxx/resourceGroups/rg-getting-started]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Terraform also writes terraform.tfstate to your working directory. This file is Terraform’s record of what it created — it maps the logical resource azurerm_resource_group.example to the actual Azure resource ID. State is how Terraform knows what exists, what has changed, and what needs to be destroyed. The contents are plaintext JSON including resource IDs and attribute values. Do not commit terraform.tfstate to version control. Later articles cover moving state to a remote Azure Blob Storage backend, which is required for any team or production use.
Step 4: terraform destroy
To clean up the resource group created by this exercise:
terraform destroy
Destroy shows a plan with - symbols and prompts for confirmation. Type yes to proceed. The resource group and all resources inside it will be deleted.
A note on the official HashiCorp tutorial
The HashiCorp Azure Get Started tutorial at developer.hashicorp.com/terraform/tutorials/azure-get-started covers the same material as this article and is worth working through. However, it was written for the AzureRM provider ~> 3.0 and has not been updated for v4.x. If you follow it, two adjustments are required.
First, change the provider version constraint from ~> 3.0.2 to ~> 4.0. Second, add subscription_id to the provider block — in v3 this was optional and could be inferred from Azure CLI; in v4 it is required. Without it, you will see Error: subscription_id is a required provider property when running plan or apply. Every other aspect of the tutorial — the resource group configuration, the variable examples, the output examples — works correctly with v4 after these two changes.
Windows-specific issues to address before they find you
Long file paths. When Terraform downloads a provider during terraform init, it creates a directory structure under .terraform/providers/registry.terraform.io/hashicorp/azurerm/<version>/windows_amd64/. If your project lives in a deeply nested path like C:\Users\YourName\Documents\Projects\Infrastructure\Terraform\, the combined path regularly exceeds Windows’ default 260-character limit, and terraform init fails with a cryptic access error. Two fixes: enable long path support in Windows (set HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled to 1 and reboot), and keep project root paths short. C:\tf\myproject is better than a nested Documents path.
Line endings. terraform fmt always writes LF line endings regardless of OS. If Git is configured to convert line endings to CRLF on checkout (the Windows default), you get into a state where files Terraform just formatted get reverted by Git on the next commit, producing perpetual diffs. More dangerously, heredoc blocks with CRLF endings can cause failures when the content is executed inside a Linux VM. Set git config --global core.autocrlf false and add a .gitattributes file to any Terraform repository:
*.tf text eol=lf
*.tfvars text eol=lf
*.hcl text eol=lf
Environment variable syntax. Tutorial documentation is frequently written for bash. The PowerShell equivalent for setting authentication variables uses $env: prefix syntax — $env:ARM_SUBSCRIPTION_ID = "value" — not the export VAR=value form that appears in most examples. In CMD the syntax is set ARM_SUBSCRIPTION_ID=value with no spaces around =. A trailing space in CMD becomes part of the variable value and causes authentication failures that are difficult to diagnose. Use PowerShell or Windows Terminal with a PowerShell profile for consistency.
Windows Defender scanning. Provider plugin binaries downloaded during terraform init are unsigned executables, and Windows Defender’s real-time scanning sometimes quarantines them or makes initialization noticeably slow. If init takes several minutes or provider binaries disappear after download, add exclusions in Windows Security for your .terraform project directories and the Terraform plugin cache directory (%APPDATA%\terraform.d\plugin-cache if configured). Installing Terraform via winget or Chocolatey avoids SmartScreen warnings on the main binary.
References: HashiCorp Terraform install docs (developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli); AzureRM provider docs (registry.terraform.io/providers/hashicorp/azurerm/latest/docs); Azure CLI install docs (learn.microsoft.com/en-us/cli/azure/install-azure-cli-windows); AzureRM v4.0 changelog (hashicorp.com/blog/terraform-azurerm-provider-4-0-adds-provider-defined-functions); HashiCorp Azure tutorial (developer.hashicorp.com/terraform/tutorials/azure-get-started).