Getting started with Terraform on Azure
A step-by-step tutorial for your first infrastructure as code project. You will install Terraform, describe an Azure network in code, then plan, apply, change and remove it safely.
Why infrastructure as code
Infrastructure as code means describing your servers, networks and cloud services in text files instead of clicking through a portal. Those files live in version control next to your application, so every change is reviewed, recorded and repeatable.
Terraform is the most widely used tool for this. You write what you want in HashiCorp Configuration Language (HCL), Terraform works out what has to change, shows you a plan, and only changes anything after you approve it. The same approach works for Azure, AWS, Google Cloud and hundreds of other services.
In this tutorial you will build a small, free Azure network: a resource group, a virtual network and two subnets. Azure does not charge for virtual networks or subnets, so you can follow along without running up a bill.
Before you start
- An Azure subscription where you can create resources. A free account is enough.
- The Azure CLI, which Terraform uses to sign in.
- A code editor. Visual Studio Code with the HashiCorp Terraform extension gives you syntax highlighting and checks as you type.
- About 15 minutes.
Install Terraform
Install Terraform from HashiCorp's official sources. This tutorial needs version 1.15 or later; at the time of writing the current release is 1.16.
macOS (Homebrew)
brew tap hashicorp/tap
brew install hashicorp/tap/terraformUbuntu or Debian
sudo apt-get update && sudo apt-get install -y gnupg software-properties-common
wget -O- https://apt.releases.hashicorp.com/gpg | \
gpg --dearmor | \
sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | \
sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update
sudo apt-get install terraformWindows
Download the Windows build from the Terraform install page, unzip terraform.exe into a folder such as C:\terraform, and add that folder to your PATH.
Then open a new terminal and check the installation:
terraform -versionSign in to Azure
Terraform can use your Azure CLI session, so there are no passwords or keys to put in your code. Sign in, then look up the ID of the subscription you want to use:
az login
az account show --query id -o tsvSince version 4.0 of the Azure provider, Terraform needs the subscription ID set explicitly. Rather than writing it into a file, pass it in as an environment variable. Terraform reads any variable named TF_VAR_ followed by a variable name.
export TF_VAR_subscription_id="$(az account show --query id -o tsv)"$env:TF_VAR_subscription_id = (az account show --query id -o tsv)Create the project
Make a folder for the project. Terraform reads every .tf file in a folder together, so splitting the code across files is only for readability. A common layout is:
terraform-azure-network/
├── versions.tf # Terraform and provider versions
├── variables.tf # inputs
├── main.tf # the resources
├── outputs.tf # values to show after apply
└── .gitignoreAdd a .gitignore first, so state files and downloaded providers never reach version control:
.terraform/
*.tfstate
*.tfstate.*
tfplan
crash.logDo commit the .terraform.lock.hcl file that Terraform creates later. It records the exact provider versions, so everyone on the team gets the same ones.
Write the configuration
versions.tf
Pin the Terraform version and the Azure provider. ~> 4.0 allows any 4.x release but never jumps to 5.0, which could contain breaking changes.
terraform {
required_version = ">= 1.15.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}variables.tf
Variables keep values you might change out of the main code. The validation rule stops names Azure would reject before anything is sent.
variable "subscription_id" {
description = "Azure subscription to deploy into."
type = string
}
variable "location" {
description = "Azure region for all resources."
type = string
default = "uksouth"
}
variable "project" {
description = "Short name used in every resource name."
type = string
default = "sitdemo"
validation {
condition = can(regex("^[a-z0-9]{3,12}$", var.project))
error_message = "Use 3 to 12 lowercase letters or numbers."
}
}main.tf
Each resource block describes one thing you want to exist. References such as azurerm_resource_group.main.name link resources together, and Terraform uses them to create things in the right order.
locals {
tags = {
project = var.project
managed_by = "terraform"
}
}
resource "azurerm_resource_group" "main" {
name = "rg-${var.project}"
location = var.location
tags = local.tags
}
resource "azurerm_virtual_network" "main" {
name = "vnet-${var.project}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
address_space = ["10.10.0.0/16"]
tags = local.tags
}
resource "azurerm_subnet" "app" {
name = "snet-app"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.10.1.0/24"]
}
resource "azurerm_subnet" "data" {
name = "snet-data"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.10.2.0/24"]
}outputs.tf
Outputs print useful values after a deployment, and other configurations can read them later.
output "resource_group_name" {
value = azurerm_resource_group.main.name
}
output "vnet_id" {
value = azurerm_virtual_network.main.id
}
output "subnet_ids" {
value = {
app = azurerm_subnet.app.id
data = azurerm_subnet.data.id
}
}Initialise and check
From the project folder, run these three commands:
terraform init
terraform fmt
terraform validateinitdownloads the Azure provider and creates.terraform.lock.hcl. Run it once per project, and again whenever you change providers or the backend.fmtrewrites your files in the standard layout, so reviews focus on what changed rather than spacing.validatechecks the syntax and references without contacting Azure.
Plan and apply
A plan compares your code with what already exists and lists exactly what Terraform would do. Saving it to a file guarantees that what you approve is what gets applied.
terraform plan -out=tfplanRead the output before going further. Lines starting with + will be created, ~ changed in place, and - destroyed. For this project the last line should read:
Plan: 4 to add, 0 to change, 0 to destroy.If it matches, apply the saved plan:
terraform apply tfplanAfter a minute or so Terraform prints the outputs: the resource group name, the virtual network ID and both subnet IDs. You can see the same resources in the Azure portal, each tagged managed_by = terraform.
Make a change
The real value of infrastructure as code shows when things change. Add an environment tag to the locals block in main.tf:
locals {
tags = {
project = var.project
managed_by = "terraform"
environment = "dev"
}
}Plan again. Terraform sees that only the tags differ and updates the two tagged resources in place, without rebuilding anything:
Plan: 0 to add, 2 to change, 0 to destroy.Apply it the same way. In a team, this change would go through a pull request, with the plan attached for review.
Understand state
Terraform records what it has created in a state file, terraform.tfstate. That is how it knows what to change next time. Treat state carefully:
- Never commit it. State can contain sensitive values, which is why it is in the
.gitignore. - Never edit it by hand. Use commands such as
terraform state listto inspect it. - Share it remotely in a team. A local state file works for one person on one machine only.
On Azure, the usual home for shared state is a storage account. Create one, with a container called tfstate, and add a backend block inside the terraform block in versions.tf:
terraform {
backend "azurerm" {
resource_group_name = "rg-tfstate"
storage_account_name = "sttfstate12345" # must be globally unique
container_name = "tfstate"
key = "sitdemo.terraform.tfstate"
use_azuread_auth = true
}
# required_version and required_providers stay as before
}Then run terraform init -migrate-state to move your existing state into the storage account. Azure locks the state while someone is running a plan or apply, so two people cannot change the same infrastructure at once.
Clean up
When you have finished experimenting, remove everything this project created:
terraform destroyTerraform lists what it will delete and asks you to type yes. It only touches resources in its own state, so anything else in your subscription is safe.
Tip: run terraform plan -destroy first to preview a destroy without changing anything.
Habits for teams
- Pin versions of Terraform and every provider, and commit
.terraform.lock.hcl. - Keep state remote, with locking, and limit who can read it.
- Review every change in a pull request, and run
terraform planautomatically in your CI pipeline so reviewers see the real effect. - Keep secrets out of code. Use environment variables, managed identities or a secrets store such as Azure Key Vault.
- Separate environments, such as development and production, with their own state, so a mistake in one cannot touch the other.
- Reuse with modules once you repeat the same pattern, rather than copying blocks between projects.