Terraform & AWS DevOps · Lesson 1 of 6

Terraform Fundamentals: HCL, State & Providers

Terraform

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

Bash
# 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.x

HCL: 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

HCL
# 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 *.tfstate in .gitignore. Always.


Providers

Providers are plugins that let Terraform interact with APIs. The AWS provider translates Terraform resources into AWS API calls.

HCL
# 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:

  1. Environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
  2. AWS credentials file: ~/.aws/credentials
  3. IAM instance role (EC2/ECS/Lambda)
  4. AWS SSO / profile
Bash
# Best practice: use named profiles, never hardcode keys
export AWS_PROFILE=my-dev-account
terraform plan

Resources

Resources are the core building blocks — they represent real infrastructure objects.

HCL
# 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.

HCL
# 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):

Bash
# 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)
HCL
# terraform.tfvars (DO NOT commit to git  contains environment-specific values)
aws_region   = "us-east-1"
environment  = "dev"
instance_count = 2

Locals

Locals are computed values used within a module. Unlike variables, they can't be overridden from outside.

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

HCL
# 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
}
Bash
# View outputs after apply
terraform output
terraform output vpc_id
terraform output -json   # Machine-readable

Data Sources

Data sources let you read existing infrastructure that Terraform doesn't manage — like looking up an AMI ID or an existing VPC.

HCL
# 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.

JSON
// 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

HCL
# 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):

Bash
# 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_REQUEST

Core Commands

Bash
# 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.data

Expressions & Functions

HCL
# 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

HCL
# 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
}
Bash
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 plan to detect drift. Fix by importing the manual change or reverting it in the console.

Pitfall 3: Hardcoded credentials Never put access_key and secret_key in provider blocks in committed code. Use environment variables, named profiles, or IAM roles.

Pitfall 4: Missing depends_on Terraform infers dependencies from references, but sometimes side effects create implicit dependencies. Use depends_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.