Services Why us Blog Contact us +442030867612

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)

Terminal
brew tap hashicorp/tap
brew install hashicorp/tap/terraform

Ubuntu or Debian

Terminal
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 terraform

Windows

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:

Terminal
terraform -version

Sign 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:

Terminal
az login
az account show --query id -o tsv

Since 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.

macOS and Linux
export TF_VAR_subscription_id="$(az account show --query id -o tsv)"
Windows PowerShell
$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:

Project layout
terraform-azure-network/
├── versions.tf    # Terraform and provider versions
├── variables.tf   # inputs
├── main.tf        # the resources
├── outputs.tf     # values to show after apply
└── .gitignore

Add a .gitignore first, so state files and downloaded providers never reach version control:

.gitignore
.terraform/
*.tfstate
*.tfstate.*
tfplan
crash.log

Do 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.

versions.tf
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.

variables.tf
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.

main.tf
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.

outputs.tf
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:

Terminal
terraform init
terraform fmt
terraform validate
  • init downloads the Azure provider and creates .terraform.lock.hcl. Run it once per project, and again whenever you change providers or the backend.
  • fmt rewrites your files in the standard layout, so reviews focus on what changed rather than spacing.
  • validate checks 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.

Terminal
terraform plan -out=tfplan

Read the output before going further. Lines starting with + will be created, ~ changed in place, and - destroyed. For this project the last line should read:

Output
Plan: 4 to add, 0 to change, 0 to destroy.

If it matches, apply the saved plan:

Terminal
terraform apply tfplan

After 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:

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:

Output
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 list to 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:

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:

Terminal
terraform destroy

Terraform 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 plan automatically 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.