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.
| Block | Labels | Purpose |
|---|---|---|
terraform | none | Settings for Terraform itself: required version, providers, backend |
provider | name | Configures a provider plugin (region, credentials, aliases) |
resource | type, name | Declares a managed object Terraform creates and owns |
data | type, name | Reads existing information without creating anything |
variable | name | Declares an input the module accepts |
output | name | Exposes a value to the caller or CLI |
locals | none | Defines named local values for reuse within a module |
module | name | Calls 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 thedata.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
terraformblock per configuration and pin bothrequired_versionand 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
descriptiontovariableandoutputblocks; it surfaces in module docs andterraform plan. - Use
localsto name any expression you reference more than once instead of repeating it. - Prefer
datablocks over hardcoded IDs (AMIs, account IDs) so configurations stay portable across regions and accounts. - Reserve
provideraliasfor genuine multi-region or multi-account setups; one default provider keeps most configs simple.