Core Concepts
Providers, resources, variables, outputs, data sources, and locals - the building blocks you'll use in every Terraform project
Core Concepts
These six block types make up 90% of what you'll write in Terraform.
| Block | Purpose |
|---|---|
provider | Plugin that talks to an external API (AWS, GCP, Kubernetes, ...) |
resource | An object Terraform creates and manages |
data | A read-only lookup of something that already exists |
variable | An input — values supplied from outside the module |
output | An exposed value — readable after apply |
locals | Named intermediate values inside one module |
Providers
A provider is a plugin. Each provider adds resource and data source types.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # any 5.x, but not 6.0
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Environment = var.environment
ManagedBy = "terraform"
Project = var.project_name
}
}
}Multiple Provider Instances
Need to deploy to two regions? Use alias:
provider "aws" {
alias = "us_east"
region = "us-east-1"
}
provider "aws" {
alias = "eu_west"
region = "eu-west-1"
}
resource "aws_s3_bucket" "logs_us" {
provider = aws.us_east
bucket = "logs-us-east"
}Resources
A resource block declares one cloud object. Syntax: resource "TYPE" "LOCAL_NAME" { ... }.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "${var.project_name}-vpc" }
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id # reference another resource
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
tags = { Name = "${var.project_name}-public-1a" }
}Two things to note:
- References create dependencies.
aws_subnet.publicdepends onaws_vpc.mainbecause it readsaws_vpc.main.id. Terraform builds a DAG and parallelizes whatever isn't dependent. - Local names are not cloud names.
"public"is how you reference the subnet in HCL. The actual AWS resource gets its name from thetags(or wherever the provider expects it).
Variables
Inputs to your module. Declare them, then reference with var.NAME.
# variables.tf
variable "aws_region" {
type = string
description = "AWS region to deploy into"
default = "us-east-1"
}
variable "environment" {
type = string
description = "Environment name (staging | production)"
validation {
condition = contains(["staging", "production"], var.environment)
error_message = "environment must be 'staging' or 'production'."
}
}
variable "instance_count" {
type = number
default = 2
}
variable "tags" {
type = map(string)
default = {}
}
variable "db_password" {
type = string
sensitive = true # never printed in plan output
}Where Variable Values Come From
In precedence order (later wins):
- Defaults in the
variableblock. terraform.tfvars(auto-loaded) and*.auto.tfvars.-var-file=foo.tfvarson the CLI.-var="key=value"on the CLI.TF_VAR_<name>environment variables.
A typical terraform.tfvars:
aws_region = "us-east-1"
environment = "staging"
instance_count = 3Never commit *.tfvars files containing secrets. Either gitignore them, or pass secrets via environment variables / a secrets manager / a CI secret store.
Outputs
Outputs surface values from a module. They're shown after apply, queryable with terraform output, and consumable by parent modules.
# outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
description = "ID of the VPC"
}
output "database_endpoint" {
value = aws_db_instance.main.endpoint
sensitive = true # masked in CLI output
}terraform output # all outputs
terraform output vpc_id # one
terraform output -json # for piping into jq or other toolsData Sources
A data block reads something that exists outside Terraform's control — perfect for grabbing AMI IDs, existing VPCs, or secrets.
# Look up the latest Ubuntu 22.04 AMI
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
# Read an existing VPC by tag
data "aws_vpc" "shared" {
tags = { Name = "shared-services-vpc" }
}
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id # reference: data.TYPE.NAME.ATTR
instance_type = "t3.medium"
subnet_id = data.aws_vpc.shared.id
}Use a data source when:
- The resource is owned by another team or another Terraform state.
- You need a value that providers compute dynamically (latest AMI, current account ID, default KMS key).
Locals
locals give a name to an expression. Use them to avoid repeating yourself.
locals {
name_prefix = "${var.project_name}-${var.environment}"
common_tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
is_production = var.environment == "production"
}
resource "aws_db_instance" "main" {
identifier = "${local.name_prefix}-db"
instance_class = local.is_production ? "db.r6g.xlarge" : "db.t3.medium"
deletion_protection = local.is_production
tags = local.common_tags
}A Typical File Layout
For anything beyond a toy project, split your config into focused files. Terraform reads every .tf file in the directory and concatenates them.
infrastructure/
├── providers.tf # terraform {} + provider {} blocks
├── variables.tf # all variable {} blocks
├── locals.tf # all locals {} blocks
├── data.tf # all data {} blocks
├── main.tf # the resources themselves
├── outputs.tf # all output {} blocks
├── terraform.tfvars # values (gitignored if it contains secrets)
└── backend.tf # backend configuration (see State Management)This is convention, not requirement — group however helps you find things.
What's Next
You now have everything to declare resources and pass values between them. The next problem is: where does Terraform keep its memory of what it created? → State Management.