Skip to content
Infrastructure as Code iac hcl 4 min read

Blocks & Arguments

A Terraform configuration is built almost entirely from blocks — named containers that group related arguments (key/value settings) and sometimes other nested blocks. Each top-level block type tells Terraform something different: how to run, which providers to use, what infrastructure to create, and what to read back out. Understanding the handful of block types and how they compose is the foundation for everything else in HCL, and the same model applies identically to OpenTofu.

Anatomy of a block

Every block follows the same grammar: a type, zero or more labels, and a body enclosed in braces. The body holds arguments (name = value) and optionally nested blocks.

resource "aws_instance" "web" {
  ami           = "ami-0c2b8ca1dc6cf8160"
  instance_type = "t3.micro"

  tags = {
    Name = "web-server"
  }
}

Here resource is the block type, "aws_instance" and "web" are labels (the resource type and a local name), and everything inside { } is the body. Arguments take expressions on the right-hand side, so values can be literals, references like var.region, or function calls.

The top-level block types

These are the blocks you place at the root of a .tf file. Terraform loads every .tf file in a directory and merges them, so block order across files never matters.

BlockLabelsPurpose
terraformnoneSettings for Terraform itself: required version, providers, backend
providernameConfigures a provider plugin (region, credentials, aliases)
resourcetype, nameDeclares a managed object Terraform creates and owns
datatype, nameReads existing information without creating anything
variablenameDeclares an input the module accepts
outputnameExposes a value to the caller or CLI
localsnoneDefines named local values for reuse within a module
modulenameCalls a child module

terraform

The terraform block configures Terraform’s own behavior. It pins versions and declares where state lives. Provider source addresses go in the nested required_providers block.

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket = "my-tfstate"
    key    = "prod/terraform.tfstate"
    region = "us-east-1"
  }
}

provider

A provider block configures a plugin instance. You can define multiple instances of the same provider using alias, then point individual resources at them.

provider "aws" {
  region = "us-east-1"
}

provider "aws" {
  alias  = "west"
  region = "us-west-2"
}

resource and data

resource blocks are the core of Terraform — each one maps to a real object that Terraform creates, updates, and destroys. data blocks are the read-only counterpart: they fetch information about objects Terraform does not manage.

data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"] # Canonical

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }
}

resource "aws_instance" "app" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.small"
}

Resources are referenced as aws_instance.app.id, but data sources require the data. prefix: data.aws_ami.ubuntu.id. Forgetting the prefix is one of the most common beginner errors.

variable, output, and locals

variable blocks parameterize a module; output blocks publish results; locals blocks name intermediate values so you don’t repeat an expression.

variable "environment" {
  type    = string
  default = "dev"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging, or prod."
  }
}

locals {
  name_prefix = "myapp-${var.environment}"
}

output "instance_ip" {
  value       = aws_instance.app.private_ip
  description = "Private IP of the app server"
}

module

A module block calls a reusable child module, passing inputs as arguments and consuming its outputs.

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = local.name_prefix
  cidr = "10.0.0.0/16"
}

Nested blocks

Many arguments are themselves expressed as nested blocks rather than key = value pairs. The filter block above and tags map are examples — tags is an argument (a map), while filter is a nested block (repeatable). Nested blocks model sub-resources like ingress rules or lifecycle settings.

resource "aws_security_group" "web" {
  name = "${local.name_prefix}-web"

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  lifecycle {
    create_before_destroy = true
  }
}

When a nested block must repeat a variable number of times, generate it with a dynamic block instead of copy-pasting.

Running it

With these blocks in place, terraform plan shows exactly what each block will produce.

Output:

Terraform will perform the following actions:

  # aws_instance.app will be created
  + resource "aws_instance" "app" {
      + ami           = "ami-0abcd1234"
      + instance_type = "t3.small"
      + id            = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + instance_ip = (known after apply)

Best Practices

  • Keep a single terraform block per configuration and pin both required_version and provider versions to avoid drift across machines.
  • Group blocks logically across files (main.tf, variables.tf, outputs.tf) — Terraform merges them, so split for readability, not behavior.
  • Always add a description to variable and output blocks; it surfaces in module docs and terraform plan.
  • Use locals to name any expression you reference more than once instead of repeating it.
  • Prefer data blocks over hardcoded IDs (AMIs, account IDs) so configurations stay portable across regions and accounts.
  • Reserve provider alias for genuine multi-region or multi-account setups; one default provider keeps most configs simple.
Last updated June 14, 2026
Was this helpful?