Skip to content

HCL, Providers, and Resources

Every piece of Terraform configuration is a block written in HCL — terraform, provider, resource, and data are the four you will write constantly, and together they tell Terraform what plugins to load, how to talk to Azure, what to create, and what to read.

HCL (HashiCorp Configuration Language) is built from blocks. The general shape is a block type, zero or more quoted labels, and a body of arguments in braces:

block_type "label1" "label2" {
argument = value
}

A resource block, for instance, takes two labels; a provider block takes one. Comments use # or // for a single line, and /* */ for a multi-line block. Strings support interpolation with ${...}, though referencing another value directly (name = azurerm_resource_group.main.name) does not need the ${} wrapper — that syntax is only needed inside a larger string.

# A single-line comment.
// Also a single-line comment.
resource "azurerm_resource_group" "main" {
name = "example-resources-${var.environment}" # interpolation inside a string
location = "East US"
}

The terraform block configures Terraform itself: which version of Terraform is allowed to run this configuration, and which providers it needs — including where each provider comes from and which versions are acceptable.

terraform {
required_version = ">= 1.7.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}

source is the provider’s registry address (hashicorp/azurerm resolves to the official Azure provider on the Terraform Registry), and version is a constraint, not a pin — ~> 4.0 allows any 4.x release.

The provider block then configures one instance of a provider you declared above. For azurerm, this is where the subscription lives — and it has a well-known quirk worth calling out explicitly: the features {} block is required, even when it is empty. Leaving it out entirely is one of the most common first errors newcomers hit with this provider.

provider "azurerm" {
features {}
subscription_id = "00000000-0000-0000-0000-000000000000"
}

resource blocks: the two labels that matter, and why Azure needs a resource group

Section titled “resource blocks: the two labels that matter, and why Azure needs a resource group”

A resource block is how you tell Terraform “this thing should exist.” It always has two labels. Almost every Azure resource has to live inside a resource group — a container/scoping construct that groups related resources together, controls their lifecycle as a unit, and has no direct equivalent in AWS or GCP (an AWS tag-based grouping has no enforcement behind it; a GCP project is a much heavier-weight boundary). That makes azurerm_resource_group the first resource in almost any Azure configuration:

resource "azurerm_resource_group" "main" {
name = "example-resources"
location = "East US"
}
  • The first label, azurerm_resource_group, is the resource type — defined by the provider, and it determines which arguments are valid and which Azure API Terraform calls.
  • The second label, main, is a name you choose — it only has meaning inside this configuration.

Together they form the resource’s address, azurerm_resource_group.main, which is how you reference this resource’s attributes anywhere else in your configuration:

resource "azurerm_linux_virtual_machine" "web" {
name = "web-vm"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
size = "Standard_B1s"
admin_username = "adminuser"
network_interface_ids = [
azurerm_network_interface.web.id, # defined elsewhere in this configuration
]
admin_ssh_key {
username = "adminuser"
public_key = file("~/.ssh/id_rsa.pub")
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts"
version = "latest"
}
}

Referencing azurerm_resource_group.main.name and azurerm_resource_group.main.location from the VM resource is exactly the kind of implicit dependency that builds Terraform’s dependency graph, covered below — Terraform now knows the resource group must exist before it can create the VM.

A data block reads information about something that already exists — infrastructure this configuration does not manage and will never create, update, or destroy. It is Terraform’s read-only lookup.

data "azurerm_resource_group" "existing" {
name = "networking-shared"
}
resource "azurerm_virtual_network" "app" {
name = "app-vnet"
address_space = ["10.0.0.0/16"]
resource_group_name = data.azurerm_resource_group.existing.name
location = data.azurerm_resource_group.existing.location
}

data.azurerm_resource_group.existing.location reads exactly like a resource address, with data. in front — the type and name labels work the same way, but nothing about a data block ever shows up as a create, update, or destroy in a plan. It only ever reads.

Terraform does not apply your configuration top to bottom the way it is written. Instead, it builds a DAG (directed acyclic graph) of every resource and data source, and uses it to work out the correct order — and which resources are independent enough to be created in parallel.

Edges in that graph come from two places:

  • Implicit dependencies — whenever one resource’s argument references another resource’s attribute (like the VM referencing azurerm_resource_group.main.name, or a subnet referencing a virtual network’s name), Terraform infers that the referenced resource must exist first.
  • Explicit dependenciesdepends_on on a resource, used when one resource genuinely depends on another but that relationship is not visible through any attribute reference (for example, a role assignment that must exist before an application starts relying on it, with no direct argument linking the two).
resource "azurerm_resource_group" "main" {
name = "example-resources"
location = "East US"
}
resource "azurerm_virtual_network" "main" {
name = "example-vnet"
address_space = ["10.0.0.0/16"]
location = azurerm_resource_group.main.location # implicit dependency on azurerm_resource_group.main
resource_group_name = azurerm_resource_group.main.name
}
resource "azurerm_subnet" "app" {
name = "app-subnet"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name # implicit dependency on azurerm_virtual_network.main
address_prefixes = ["10.0.1.0/24"]
}
flowchart LR
  rg["azurerm_resource_group.main"] --> vnet["azurerm_virtual_network.main"] --> subnet["azurerm_subnet.app"] --> vm["azurerm_linux_virtual_machine.web"]
  storage["azurerm_storage_account.logs (unrelated, applies in parallel)"]
Terraform builds a DAG from references, applying independent branches in parallel
In `resource "azurerm_linux_virtual_machine" "web" { ... }`, what do the two labels represent
Why is the azurerm provider features block required even when it is empty
How does Terraform decide the order in which to create resources, using the resource group example from this lesson
Which correctly describes the terraform block required_providers argument