State, resources and the dependency graph
The five concepts Terraform is built from — providers, resources, state, variables and modules — plus the dependency graph that decides the order everything happens in.
Five concepts. Everything in Terraform is one of them.
1. Providers — the API translators
A provider is a plugin that knows how to talk to one API. hashicorp/aws knows AWS; cloudflare/cloudflare
knows Cloudflare.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
You can configure the same provider twice with aliases, which is how you manage two regions or two accounts from one configuration:
provider "aws" {
alias = "eu"
region = "eu-west-1"
}
resource "aws_s3_bucket" "eu_backup" {
provider = aws.eu
bucket = "myapp-backup-eu"
}
2. Resources and data sources
A resource is something Terraform creates and owns:
resource "aws_s3_bucket" "uploads" {
bucket = "myapp-uploads"
}
aws_s3_bucket is the type, uploads is your local name. Together they form the address
aws_s3_bucket.uploads, which is how you refer to it everywhere else — in state, in plan output,
and in other resources.
A data source reads something Terraform does not own:
data "aws_vpc" "default" {
default = true
}
resource "aws_subnet" "app" {
vpc_id = data.aws_vpc.default.id
cidr_block = "172.31.100.0/24"
}
Resources get created and destroyed. Data sources only ever read.
3. State — the mapping to reality
The state file maps your addresses to real resource IDs:
{
"resources": [{
"type": "aws_s3_bucket",
"name": "uploads",
"instances": [{
"attributes": { "id": "myapp-uploads", "arn": "arn:aws:s3:::myapp-uploads" }
}]
}]
}
Without it, Terraform cannot know that aws_s3_bucket.uploads already exists — it would try to
create it again.
Remote state, and why local state does not survive contact with a team
Local state is a file on one laptop. Two people applying at the same time produce two divergent versions of reality. Move it to a backend that supports locking:
terraform {
backend "s3" {
bucket = "myorg-terraform-state"
key = "prod/app/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true
}
}
Now state is shared, encrypted, versioned by S3, and locked so two applies cannot collide.
4. Variables, locals and outputs
Variables are inputs:
variable "environment" {
description = "Deployment environment"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging or prod."
}
}
variable "instance_count" {
type = number
default = 2
}
Set them by file (terraform.tfvars), by flag (-var), or by environment variable
(TF_VAR_environment=prod).
Locals are computed values you use more than once:
locals {
name_prefix = "${var.project}-${var.environment}"
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
}
Outputs expose values to you, and to other configurations:
output "bucket_arn" {
description = "ARN of the uploads bucket"
value = aws_s3_bucket.uploads.arn
}
output "db_password" {
value = random_password.db.result
sensitive = true # redacted from CLI output
}
5. Modules — reusable groups of resources
A module is a directory containing .tf files. Calling it:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.13.0"
name = local.name_prefix
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
enable_nat_gateway = true
tags = local.common_tags
}
Reference its outputs with module.vpc.vpc_id. Always pin version on a public module — it is a
dependency like any other.
The dependency graph
Terraform never runs your file top to bottom. It builds a graph from the references between resources and executes it in dependency order, in parallel where it can.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "app" {
vpc_id = aws_vpc.main.id # ← creates the dependency
}
Because aws_subnet.app references aws_vpc.main.id, the VPC is created first. You never
specify order; you express references and the order follows.
When a dependency exists but there is no reference — an IAM policy that must exist before a service starts using it, say — declare it explicitly:
resource "aws_instance" "app" {
depends_on = [aws_iam_role_policy.app]
}
The lifecycle of an apply
terraform init → download providers, configure the backend
terraform plan → read state, refresh reality, compute the diff
terraform apply → execute the graph, update state
terraform destroy → reverse the graph, tear everything down
plan mutates nothing. apply re-plans first, then asks for confirmation. That confirmation
prompt is the last checkpoint before something irreversible.
Next: building real infrastructure with all of this.