Resource Syntax
Resources are the heart of Terraform. Every piece of infrastructure you manage — a virtual machine, a DNS record, an S3 bucket, an IAM policy — is declared as a resource block. Understanding the exact shape of that block, how it is addressed, and how its inputs and outputs flow between other resources is the single most important skill in writing maintainable Terraform (and OpenTofu) configurations.
The shape of a resource block
A resource block has a fixed grammar. It begins with the keyword resource, followed by two quoted labels — the resource type and the resource name — and a body in curly braces containing arguments.
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "web-server"
Environment = "production"
}
}
Breaking this down:
| Token | Example | Meaning |
|---|---|---|
resource | resource | The block keyword that declares managed infrastructure. |
| Resource type | "aws_instance" | The kind of object. The prefix (aws_) identifies the provider; the rest names the API object. |
| Resource name | "web" | A local identifier you choose. Unique per type within a module. It does not appear in your cloud account. |
| Body | { ... } | Arguments that configure the object. |
The resource type is defined by the provider, not by you — aws_instance, aws_s3_bucket, and google_compute_instance all come from their respective providers. The resource name, by contrast, is yours to pick. Use a name that describes the role of the object (web, primary, app_logs), not its type, since the type is already in the address.
The resource address
The combination of type and name forms the resource address: type.name. For the block above, the address is aws_instance.web. This address is how you reference the resource everywhere else — in the CLI, in state, and in expressions inside other blocks.
terraform state show aws_instance.web
terraform taint aws_instance.web # legacy; prefer -replace
terraform apply -replace="aws_instance.web"
Addresses are also how Terraform tracks objects in state. Renaming a resource changes its address, which Terraform reads as “destroy the old one, create a new one.” To rename without destroying, use a moved block or terraform state mv.
The address must be unique across the whole configuration. Two resources of different types may share a name (
aws_instance.webandaws_eip.webare distinct), but two resources of the same type may not.
Arguments versus attributes
This distinction trips up newcomers constantly, so it is worth stating precisely.
- Arguments are inputs you write. They go inside the body and configure the object:
ami,instance_type,tags. Some are required, some optional with defaults. - Attributes are outputs Terraform reads back after creating the object. Many are computed by the provider and unknown until apply:
id,arn,private_ip,public_dns.
Some fields are both — you can set them as arguments, and they are also readable as attributes. Others are computed only (read-only), like id, which the cloud provider assigns. The provider’s documentation lists each one under “Argument Reference” and “Attribute Reference.”
resource "aws_s3_bucket" "logs" {
bucket = "devcraftly-app-logs" # argument: input
}
output "bucket_arn" {
value = aws_s3_bucket.logs.arn # attribute: output, computed by AWS
}
Referencing attributes elsewhere
You wire infrastructure together by referencing one resource’s attributes inside another resource’s arguments, using the form type.name.attribute. Terraform parses these references to build a dependency graph automatically — the referenced resource is created first.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id # implicit dependency
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
}
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
subnet_id = aws_subnet.public.id
}
Because aws_subnet.public reads aws_vpc.main.id, Terraform knows the VPC must exist first. You do not declare this ordering manually; the reference itself is the dependency. When you run a plan, computed attributes that are not yet known appear as (known after apply).
Output:
Terraform will perform the following actions:
# aws_subnet.public will be created
+ resource "aws_subnet" "public" {
+ availability_zone = "us-east-1a"
+ cidr_block = "10.0.1.0/24"
+ id = (known after apply)
+ vpc_id = (known after apply)
}
Plan: 3 to add, 0 to change, 0 to destroy.
The vpc_id shows (known after apply) because aws_vpc.main.id is only assigned once AWS creates the VPC during apply.
Annotated full example
resource "aws_db_instance" "primary" { # type "aws_db_instance", name "primary"
identifier = "app-prod-db" # argument (input)
engine = "postgres" # argument (input)
engine_version = "16.3" # argument — quoted so it is a string
instance_class = "db.t3.medium" # argument (input)
allocated_storage = 20 # argument (input)
username = "appuser" # argument (input)
password = var.db_password # argument from a variable
skip_final_snapshot = true
}
output "db_endpoint" {
# address.attribute — "endpoint" is computed after apply
value = aws_db_instance.primary.endpoint
}
Quote version-like values such as
"16.3"or"18". A bare16.3is a number and may be normalized in unexpected ways; engine versions are strings.
Best practices
- Name resources by their role, not their type —
aws_instance.web, neveraws_instance.aws_instance. - Reference attributes (
aws_vpc.main.id) instead of hardcoding IDs, so Terraform manages ordering and you avoid stale values. - Treat the resource address as a stable contract; rename via
movedblocks rather than editing labels and re-creating objects. - Read the provider docs to distinguish required arguments, optional arguments, and read-only attributes before writing a block.
- Quote string-typed values like ports, versions, and account IDs to prevent unintended numeric coercion.
- Keep one logical concern per resource and let references express relationships — this keeps the dependency graph accurate. This syntax is identical in OpenTofu, so configurations port without changes.