Terraform Fundamentals: HCL, State & Providers
Master Terraform from scratch ā understand HCL syntax, resource blocks, variables, outputs, providers, and the state file that makes infrastructure as code work reliably.
What is Terraform?
Terraform is an open-source Infrastructure as Code (IaC) tool by HashiCorp. Instead of clicking through the AWS Console to create resources, you write declarative configuration files ā Terraform figures out what to create, update, or destroy.
Why it matters:
- Reproducible ā same code always produces the same infrastructure
- Version-controlled ā infra changes go through PR review like application code
- Multi-cloud ā one tool for AWS, Azure, GCP, Kubernetes, and 3,000+ providers
- Self-documenting ā the code is the documentation
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Terraform Workflow ā
ā ā
ā Write HCL ā terraform init ā terraform plan ā
ā ā terraform apply ā terraform destroy ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāInstalling Terraform
# macOS (Homebrew)
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
# Windows (Chocolatey)
choco install terraform
# Linux (Ubuntu/Debian)
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
# Verify
terraform --version
# Terraform v1.8.xHCL: HashiCorp Configuration Language
Terraform uses HCL (HashiCorp Configuration Language) ā a human-readable, declarative language. If you can read JSON, you can read HCL within minutes.
Core Syntax
# This is a comment
# Block syntax: type "label_1" "label_2" { ... }
resource "aws_s3_bucket" "my_bucket" {
bucket = "my-unique-bucket-name"
tags = {
Environment = "production"
Team = "platform"
}
}Block types:
| Block | Purpose |
|-------|---------|
| terraform | Configure Terraform itself (required providers, backend) |
| provider | Configure a provider (AWS credentials, region) |
| resource | Declare an infrastructure resource |
| data | Read existing resources (not managed by Terraform) |
| variable | Input values |
| output | Export values |
| locals | Local computed values |
| module | Reusable infrastructure components |
Project Structure
project/
āāā main.tf # Primary resource definitions
āāā variables.tf # Input variable declarations
āāā outputs.tf # Output value declarations
āāā providers.tf # Provider configuration
āāā versions.tf # Terraform and provider version constraints
āāā terraform.tfvars # Variable values (NOT committed to git)Keep
.terraform/and*.tfstatein.gitignore. Always.
Providers
Providers are plugins that let Terraform interact with APIs. The AWS provider translates Terraform resources into AWS API calls.
# versions.tf
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # >= 5.0, < 6.0
}
}
}
# providers.tf
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "learnixo"
ManagedBy = "terraform"
Environment = var.environment
}
}
}Authentication ā Terraform reads AWS credentials in this order:
- Environment variables:
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY - AWS credentials file:
~/.aws/credentials - IAM instance role (EC2/ECS/Lambda)
- AWS SSO / profile
# Best practice: use named profiles, never hardcode keys
export AWS_PROFILE=my-dev-account
terraform planResources
Resources are the core building blocks ā they represent real infrastructure objects.
# Syntax: resource "<provider_type>" "<local_name>" { ... }
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "main-vpc"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id # Reference to above resource
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = true
}Referencing resources: <type>.<name>.<attribute> ā e.g., aws_vpc.main.id
Terraform builds a dependency graph automatically. If resource B references resource A, Terraform creates A first.
Variables
Variables make your configuration reusable and environment-aware.
# variables.tf
variable "aws_region" {
description = "AWS region to deploy into"
type = string
default = "us-east-1"
}
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" {
description = "Number of EC2 instances"
type = number
default = 1
}
variable "allowed_cidr_blocks" {
description = "CIDR blocks allowed to access the bastion"
type = list(string)
default = []
}
variable "tags" {
description = "Additional resource tags"
type = map(string)
default = {}
}Setting variable values (in order of precedence):
# 1. CLI flag (highest priority)
terraform apply -var="environment=prod"
# 2. .tfvars file
terraform apply -var-file="prod.tfvars"
# 3. Auto-loaded file
# terraform.tfvars or *.auto.tfvars are loaded automatically
# 4. Environment variables
export TF_VAR_environment=prod
# 5. Default value in variable declaration (lowest priority)# terraform.tfvars (DO NOT commit to git ā contains environment-specific values)
aws_region = "us-east-1"
environment = "dev"
instance_count = 2Locals
Locals are computed values used within a module. Unlike variables, they can't be overridden from outside.
locals {
name_prefix = "${var.environment}-${var.project_name}"
common_tags = merge(var.tags, {
Environment = var.environment
ManagedBy = "terraform"
CreatedAt = "2026-04-16"
})
is_production = var.environment == "prod"
}
resource "aws_s3_bucket" "data" {
bucket = "${local.name_prefix}-data"
tags = local.common_tags
}Outputs
Outputs expose values from your configuration ā useful for other modules or for humans reading terraform apply output.
# outputs.tf
output "vpc_id" {
description = "ID of the created VPC"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "IDs of public subnets"
value = aws_subnet.public[*].id
}
output "bucket_arn" {
description = "ARN of the S3 data bucket"
value = aws_s3_bucket.data.arn
sensitive = false
}
output "db_password" {
description = "RDS master password"
value = random_password.db.result
sensitive = true # Hides value in CLI output
}# View outputs after apply
terraform output
terraform output vpc_id
terraform output -json # Machine-readableData Sources
Data sources let you read existing infrastructure that Terraform doesn't manage ā like looking up an AMI ID or an existing VPC.
# Look up the latest Amazon Linux 2023 AMI
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
# Look up an existing VPC by tag
data "aws_vpc" "existing" {
tags = {
Name = "pre-existing-vpc"
}
}
# Use the data source
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
subnet_id = data.aws_vpc.existing.id
}The State File
The state file (terraform.tfstate) is Terraform's memory ā it maps your HCL resources to real infrastructure IDs.
// terraform.tfstate (simplified)
{
"version": 4,
"resources": [
{
"type": "aws_s3_bucket",
"name": "my_bucket",
"instances": [
{
"attributes": {
"id": "my-unique-bucket-name",
"arn": "arn:aws:s3:::my-unique-bucket-name",
"region": "us-east-1"
}
}
]
}
]
}State rules:
- Never edit state manually
- Never commit state to git (it may contain secrets)
- Use remote state for team environments
Remote State with S3 + DynamoDB
# versions.tf
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "prod/vpc/terraform.tfstate"
region = "us-east-1"
encrypt = true
kms_key_id = "arn:aws:kms:us-east-1:123456789:key/abc123"
# DynamoDB table for state locking
dynamodb_table = "terraform-state-locks"
}
}Create the backend resources first (chicken-and-egg ā do this manually or with a bootstrap script):
# Bootstrap script (run once)
aws s3api create-bucket \
--bucket my-terraform-state-bucket \
--region us-east-1
aws s3api put-bucket-versioning \
--bucket my-terraform-state-bucket \
--versioning-configuration Status=Enabled
aws s3api put-bucket-encryption \
--bucket my-terraform-state-bucket \
--server-side-encryption-configuration '{
"Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "aws:kms"}}]
}'
aws dynamodb create-table \
--table-name terraform-state-locks \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUESTCore Commands
# Initialize working directory (download providers)
terraform init
# Preview changes without applying
terraform plan
# Apply changes
terraform apply
# Apply without interactive approval (CI/CD)
terraform apply -auto-approve
# Destroy all resources
terraform destroy
# Format HCL files
terraform fmt -recursive
# Validate configuration syntax
terraform validate
# Show current state
terraform show
# List resources in state
terraform state list
# Import existing resource into state
terraform import aws_s3_bucket.my_bucket my-existing-bucket-name
# Remove resource from state (without destroying it)
terraform state rm aws_s3_bucket.my_bucket
# Targeted apply/destroy (use sparingly)
terraform apply -target=aws_instance.web
terraform destroy -target=aws_s3_bucket.dataExpressions & Functions
# String interpolation
bucket_name = "app-${var.environment}-${random_id.suffix.hex}"
# Conditional expression
instance_type = var.environment == "prod" ? "t3.medium" : "t3.micro"
# for_each: create multiple resources
resource "aws_subnet" "private" {
for_each = {
"us-east-1a" = "10.0.10.0/24"
"us-east-1b" = "10.0.11.0/24"
"us-east-1c" = "10.0.12.0/24"
}
vpc_id = aws_vpc.main.id
availability_zone = each.key
cidr_block = each.value
}
# count: simple repetition
resource "aws_instance" "worker" {
count = var.worker_count
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
tags = {
Name = "worker-${count.index + 1}"
}
}
# Built-in functions
locals {
upper_env = upper(var.environment) # "PROD"
joined = join("-", ["app", "v1", "prod"]) # "app-v1-prod"
subnet_ids = tolist(aws_subnet.private[*].id)
merged = merge(var.tags, local.common_tags)
cidr = cidrsubnet("10.0.0.0/16", 8, 1) # "10.0.1.0/24"
}A Complete Working Example
# main.tf ā simple S3 static website
terraform {
required_version = ">= 1.6"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" { default = "us-east-1" }
variable "environment" { default = "dev" }
variable "bucket_name" { description = "S3 bucket name (must be globally unique)" }
resource "aws_s3_bucket" "website" {
bucket = var.bucket_name
tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_website_configuration" "website" {
bucket = aws_s3_bucket.website.id
index_document { suffix = "index.html" }
error_document { key = "error.html" }
}
resource "aws_s3_bucket_public_access_block" "website" {
bucket = aws_s3_bucket.website.id
block_public_acls = false
block_public_policy = false
ignore_public_acls = false
restrict_public_buckets = false
}
resource "aws_s3_bucket_policy" "website" {
bucket = aws_s3_bucket.website.id
depends_on = [aws_s3_bucket_public_access_block.website]
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = "*"
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.website.arn}/*"
}]
})
}
output "website_url" {
value = aws_s3_bucket_website_configuration.website.website_endpoint
}terraform init
terraform plan -var="bucket_name=my-site-20260416"
terraform apply -var="bucket_name=my-site-20260416"
# ā website_url = "my-site-20260416.s3-website-us-east-1.amazonaws.com"Common Pitfalls
Pitfall 1: Destroying and recreating resources Some resource attribute changes force a replacement (destroy + create). Terraform shows this as
# aws_instance.web must be replaced. Review plans carefully ā replacements cause downtime.
Pitfall 2: State drift If someone manually changes AWS resources, Terraform's state no longer matches reality. Run
terraform planto detect drift. Fix by importing the manual change or reverting it in the console.
Pitfall 3: Hardcoded credentials Never put
access_keyandsecret_keyin provider blocks in committed code. Use environment variables, named profiles, or IAM roles.
Pitfall 4: Missing
depends_onTerraform infers dependencies from references, but sometimes side effects create implicit dependencies. Usedepends_on = [...]to make implicit ordering explicit.
Summary
| Concept | What It Does | |---------|-------------| | HCL blocks | Declare infrastructure declaratively | | Provider | Plugin that talks to an API (AWS, GCP, etc.) | | Resource | An infrastructure object (VPC, EC2, S3) | | Data source | Read existing infrastructure | | Variable | Parameterise configuration | | Local | Computed values within a module | | Output | Export values for display or other modules | | State file | Maps HCL to real resource IDs | | Remote backend | S3 + DynamoDB for team state management |
Next up: Terraform for AWS Serverless ā provisioning Lambda, API Gateway, and DynamoDB with HCL.
Enjoyed this article?
Explore the Cloud & DevOps learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.