Steven's Knowledge

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.

BlockPurpose
providerPlugin that talks to an external API (AWS, GCP, Kubernetes, ...)
resourceAn object Terraform creates and manages
dataA read-only lookup of something that already exists
variableAn input — values supplied from outside the module
outputAn exposed value — readable after apply
localsNamed 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:

  1. References create dependencies. aws_subnet.public depends on aws_vpc.main because it reads aws_vpc.main.id. Terraform builds a DAG and parallelizes whatever isn't dependent.
  2. Local names are not cloud names. "public" is how you reference the subnet in HCL. The actual AWS resource gets its name from the tags (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):

  1. Defaults in the variable block.
  2. terraform.tfvars (auto-loaded) and *.auto.tfvars.
  3. -var-file=foo.tfvars on the CLI.
  4. -var="key=value" on the CLI.
  5. TF_VAR_<name> environment variables.

A typical terraform.tfvars:

aws_region     = "us-east-1"
environment    = "staging"
instance_count = 3

Never 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 tools

Data 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.

On this page