HCL Basics, Resources & Providers, Variables & Outputs, Modules, State Management, Workspaces, CI/CD — infrastructure as code mastery.
# ── HCL Syntax ──
# Variables (string, number, bool, list, map, object, set, tuple)
variable "environment" {
description = "Deployment environment"
type = string
default = "production"
validation {
condition = contains(["development", "staging", "production"], var.environment)
error_message = "Environment must be development, staging, or production."
}
}
variable "allowed_ports" {
description = "List of allowed ingress ports"
type = list(number)
default = [80, 443, 22]
}
variable "tags" {
description = "Common resource tags"
type = map(string)
default = {
Environment = "production"
Team = "platform"
ManagedBy = "terraform"
}
}
# Locals (computed values, not settable by user)
locals {
common_tags = merge(var.tags, {
Project = "my-app"
CreatedDate = timestamp()
})
name_prefix = "${var.project_name}-${var.environment}"
}
# Outputs
output "instance_ip" {
description = "Public IP of the EC2 instance"
value = aws_instance.web.public_ip
}
output "db_endpoint" {
description = "RDS endpoint (sensitive)"
value = aws_db_instance.main.endpoint
sensitive = true
}
# Expressions
# Condition: condition ? true_val : false_val
# Splat: aws_instance.web[*].public_ip
# For: [for s in var.subnets : s.id]
# For map: { for k, v in var.map : k => upper(v) if v != "" }| Type | Example | Description |
|---|---|---|
| string | "hello" | Text value |
| number | 42 | Numeric (int or float) |
| bool | true | Boolean value |
| list | ["a", "b"] | Ordered sequence |
| map | { key = "val" } | Key-value pairs |
| set | toSet(["a", "b"]) | Unique unordered collection |
| object | { name = str, age = num } | Typed structure |
| tuple | ["a", 42] | Fixed-length, mixed types |
| any | any | Accepts any type |
| Function | Example | Description |
|---|---|---|
| length | length(list) | Returns length of collection |
| join | join(",", list) | Join list into string |
| split | split(",", str) | Split string into list |
| lookup | lookup(map, key, default) | Map lookup with default |
| merge | merge(map1, map2) | Merge maps |
| concat | concat(list1, list2) | Concatenate lists |
| contains | contains(list, elem) | Check membership |
| element | element(list, index) | Get element (wraps) |
| keys / values | keys(map) | Extract keys or values |
| can | can(expr) | Check if expression is possible |
| try | try(expr1, expr2) | Evaluate expressions until success |
| file / templatefile | file("path") | Read file contents |
# ── Provider Configuration ──
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
cloud {
organization = "my-org"
workspaces {
name = "production"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = local.common_tags
}
}
# Dynamic provider (multiple providers)
provider "aws" {
alias = "east"
region = "us-east-1"
}
provider "aws" {
alias = "west"
region = "us-west-2"
}
# ── Resource (creates infrastructure) ──
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${local.name_prefix}-vpc"
}
}
# ── Data Source (reads existing infrastructure) ──
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
data "aws_subnet" "existing" {
id = var.subnet_id
}
# ── Resource with depends_on, lifecycle, count ──
resource "aws_instance" "web" {
provider = aws.east
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
subnet_id = aws_subnet.main.id
depends_on = [aws_internet_gateway.igw]
# Dynamic blocks
dynamic "ebs_block_device" {
for_each = var.ebs_volumes
content {
device_name = ebs_block_device.value.name
volume_size = ebs_block_device.value.size
volume_type = ebs_block_device.value.type
}
}
lifecycle {
create_before_destroy = true
prevent_destroy = false
ignore_changes = [ami, user_data]
}
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-web"
})
}| Feature | resource | data |
|---|---|---|
| Purpose | Creates/updates/destroys infrastructure | Reads existing infrastructure |
| State | Tracked in state file | Read each time, not persisted |
| ID | Auto-generated or explicit | Filters to find resource |
| Mutability | Managed by Terraform | Read-only |
| Syntax | resource "type" "name" {} | data "type" "name" {} |
| Argument | Description |
|---|---|
| count | Create multiple instances |
| for_each | Create instances from a map or set |
| depends_on | Explicit dependency (not needed if ref in args) |
| lifecycle | create_before_destroy, prevent_destroy, ignore_changes |
| provider | Use non-default provider (alias) |
| dynamic | Generate nested blocks dynamically |
# ── Variable types and definitions ──
# Simple variable
variable "aws_region" {
type = string
default = "us-east-1"
}
# Complex type variable
variable "instance_config" {
type = object({
instance_type = string
ami = string
key_name = optional(string)
monitoring = optional(bool, false)
tags = map(string)
})
default = {
instance_type = "t3.micro"
ami = "ami-12345678"
tags = {}
}
}
# Variable with validation
variable "vpc_cidr" {
type = string
description = "CIDR block for the VPC"
validation {
condition = can(cidrnetmask(var.vpc_cidr))
error_message = "Value must be a valid CIDR block."
}
validation {
condition = cidrhost(var.vpc_cidr, 0) != "0.0.0.0"
error_message = "CIDR block cannot be 0.0.0.0/0"
}
}
# List with validation
variable "availability_zones" {
type = list(string)
description = "List of AZs"
validation {
condition = length(var.availability_zones) >= 2
error_message = "At least 2 availability zones required."
}
}
# ── Variable precedence (highest to lowest) ──
# 1. -var flag: terraform apply -var="region=us-west-2"
# 2. *.auto.tfvars files (auto-loaded, no flag needed)
# 3. -var-file flag: terraform apply -var-file="prod.tfvars"
# 4. environment vars: TF_VAR_region=us-west-2
# 5. variable default
# ── Outputs ──
output "vpc_id" {
description = "The ID of the VPC"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "List of public subnet IDs"
value = [for s in aws_subnet.public : s.id]
}
output "rds_connection_string" {
description = "Database connection string"
value = "postgresql://${var.db_username}:${var.db_password}@..."
sensitive = true
# Can still be used with terraform output -raw
}
output "instance_details" {
description = "Full instance info"
value = {
id = aws_instance.web.id
private_ip = aws_instance.web.private_ip
public_ip = aws_instance.web.public_ip
instance_type = aws_instance.web.instance_type
}
}# ── tfvars file (never commit secrets!) ──
aws_region = "us-east-1"
environment = "production"
instance_type = "t3.medium"
tags = {
Project = "my-app"
Environment = "production"
CostCenter = "engineering"
}
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]*.tfvars files with secrets. Use terraform.tfvars.example as a template, and keep real values in a secrets manager. Mark secrets as sensitive = true in variable definitions and outputs.# ── Module Definition (child module) ──
# modules/vpc/variables.tf
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
}
variable "environment" {
type = string
}
variable "public_subnets" {
type = list(string)
}
variable "private_subnets" {
type = list(string)
}
# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
tags = { Name = "${var.environment}-vpc" }
}
resource "aws_subnet" "public" {
count = length(var.public_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnets[count.index]
map_public_ip_on_launch = true
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = { Name = "${var.environment}-public-${count.index + 1}" }
}
resource "aws_subnet" "private" {
count = length(var.private_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.private_subnets[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = { Name = "${var.environment}-private-${count.index + 1}" }
}
# modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.this.id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}# ── Calling a Module (root module) ──
module "vpc" {
source = "./modules/vpc"
vpc_cidr = "10.0.0.0/16"
environment = var.environment
public_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
private_subnets = ["10.0.10.0/24", "10.0.11.0/24", "10.0.12.0/24"]
}
# Use module outputs
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
subnet_id = module.vpc.public_subnet_ids[0]
vpc_security_group_ids = [module.vpc.default_security_group_id]
}
# ── External module sources ──
module "ecs" {
source = "terraform-aws-modules/ecs/aws"
version = "~> 5.0"
# ...
}
module "s3_bucket" {
source = "git::https://github.com/my-org/terraform-modules//s3?ref=v2.1.0"
# ...
}
module "rds" {
source = "app.terraform.io/my-org/rds/aws"
version = "~> 1.0"
# ...
}| Source | Example | Description |
|---|---|---|
| Local path | "./modules/vpc" | Local directory (relative) |
| Registry | "terraform-aws-modules/vpc/aws" | Terraform Registry |
| GitHub | "git::https://github.com/org/repo?ref=v1.0" | Git repo (any ref) |
| GitSSH | "git::ssh://git@github.com/org/repo.git" | SSH access |
| Bitbucket | "git::https://bitbucket.org/org/repo.git" | Bitbucket |
| S3 | "s3::my-bucket/modules/vpc.zip" | S3 bucket |
| GCS | "gcs::my-bucket/modules/vpc.zip" | Google Cloud Storage |
| Constraint | Matches | Example |
|---|---|---|
| >= 1.0.0 | 1.0.0 and above | At least this version |
| ~> 1.0 | >= 1.0.0, < 2.0.0 | Minor version compatible |
| ~> 1.0.0 | >= 1.0.0, < 1.1.0 | Patch version compatible |
| = 1.0.0 | Exactly 1.0.0 | Exact version |
| != 1.0.0 | Everything except 1.0.0 | Not this version |
| > 1.0.0 | Above 1.0.0 | Greater than |
# ── Remote State (S3 + DynamoDB locking) ──
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
kms_key_id = "alias/terraform-state-key"
}
}
# ── State Commands ──
# terraform state show aws_instance.web
# terraform state list
# terraform state list -module=vpc
# terraform state rm aws_instance.web
# terraform state mv aws_instance.old aws_instance.new
# terraform state pull > backup.tfstate
# terraform state push backup.tfstate
# terraform state replace-provider registry.terraform.io/hashicorp/aws
# ── Import existing resource into state ──
# terraform import aws_instance.web i-1234567890abcdef0
# terraform import module.vpc.aws_subnet.public[0] subnet-abc123| Backend | Locking | Encryption | Best For |
|---|---|---|---|
| s3 | DynamoDB (native) | SSE-S3 / SSE-KMS | AWS deployments |
| gcs | Native | Google-managed | GCP deployments |
| azurerm | Blob lease | Azure-managed | Azure deployments |
| http | None (custom) | TLS | Custom REST API |
| pg | Native (advisory) | Connection TLS | Self-hosted, DB-backed |
| kubernetes | ConfigMap | None | K8s-based workflows |
| consul | Native | Consul encryption | HashiCorp stack |
| terraform cloud | Built-in | Built-in | Teams, HCP |
# ── State Operations ──
# Show current state
terraform state show aws_instance.web
# List all resources in state
terraform state list
# Remove resource from state (without destroying it)
terraform state rm aws_instance.web
# Move resource between modules/states
terraform state mv aws_instance.web module.app.aws_instance.web
# Import existing infrastructure
terraform import aws_instance.web i-0abc123def456
# Import with module prefix
terraform import 'module.vpc.aws_subnet.public[0]' subnet-abc123
# Taint (forces recreate on next apply)
terraform taint aws_instance.web
# Untaint (cancel a taint)
terraform untaint aws_instance.web
# Replace provider reference
terraform state replace-provider \
registry.terraform.io/hashicorp/aws \
registry.terraform.io/my-company/awsterraform apply simultaneously can corrupt state. Always enable locking in production.# ── Workspace Management ──
terraform workspace new dev
terraform workspace new staging
terraform workspace new production
terraform workspace list
terraform workspace show
terraform workspace select production
# ── Workspace-specific variables ──
# terraform.tfvars (per workspace)
# dev.tfvars, staging.tfvars, production.tfvars
# ── Use workspace in config ──
# locals.tf
locals {
workspace_env = terraform.workspace
env_config = {
development = { instance_type = "t3.micro", min_size = 1, max_size = 2 }
staging = { instance_type = "t3.small", min_size = 2, max_size = 4 }
production = { instance_type = "t3.medium", min_size = 3, max_size = 10 }
}
config = local.env_config[terraform.workspace]
}| Aspect | Workspaces | Separate Configs |
|---|---|---|
| State files | Same backend, different keys | Separate backends entirely |
| Complexity | Lower | Higher (duplication) |
| Isolation | Logical only (same provider) | Complete isolation |
| Drift risk | Higher (shared variables) | Lower (independent) |
| Best for | Similar environments | Completely different infra |
| Team usage | Same team, same code | Different teams/projects |
| Function | Description |
|---|---|
| terraform.workspace | Current workspace name (string) |
| terraform.workspace == "prod" | Condition in locals/resources |
# ── Makefile for Terraform ──
.PHONY: init plan apply destroy fmt validate lint
init:
terraform init -backend-config=backend-$(ENV).hcl
plan:
terraform plan -var-file="envs/$(ENV).tfvars" -out=tfplan
apply: plan
terraform apply tfplan
destroy:
terraform destroy -var-file="envs/$(ENV).tfvars"
fmt:
terraform fmt -recursive
validate:
terraform validate
lint:
tflint --recursive
check: fmt validate lint# ── GitHub Actions CI/CD for Terraform ──
name: Terraform CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.7.0"
- run: terraform fmt -check -recursive
- run: terraform init -backend=false
- run: terraform validate
plan:
needs: validate
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform plan -out=tfplan
- run: echo "## Terraform Plan" >> $GITHUB_STEP_SUMMARY
- run: terraform show -no-color tfplan >> $GITHUB_STEP_SUMMARY
apply:
needs: plan
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform apply -auto-approve| Command | Description |
|---|---|
| init | Initialize working directory, download providers/modules |
| plan | Show execution plan (dry run) |
| apply | Apply changes to infrastructure |
| destroy | Destroy all infrastructure in state |
| fmt | Format HCL files to canonical style |
| validate | Validate configuration files |
| output | Read outputs from state |
| show | Show current state or plan |
| graph | Generate dependency graph (DOT format) |
| workspace | Manage workspaces |
| import | Import existing infrastructure |
| taint | Mark resource for recreation |
| force-unlock | Manually unlock state (use with caution) |
| Flag | Description |
|---|---|
| -var="x=1" | Set a variable value |
| -var-file="f.tfvars" | Load variables from file |
| -auto-approve | Skip interactive approval (dangerous in CI) |
| -target=resource | Apply only specific resource |
| -replace=resource | Force replacement of resource |
| -refresh=false | Skip state refresh before plan |
| -input=false | Disable interactive prompts |
| -lock=false | Skip state locking |
| -parallelism=N | Limit concurrent operations (default 10) |
| -compact-warnings | Show warnings in compact form |
terraform fmt and terraform validate in pre-commit hooks. Add tflint for additional static analysis (unused variables, deprecated syntax, provider-specific rules). This catches errors before they reach CI.Terraform is an open-source IaC tool by HashiCorp using HCL (declarative). It's cloud-agnostic — supports AWS, Azure, GCP, and 3000+ providers. CloudFormation is AWS-specific and JSON/YAML-based. Terraform's state management, modules, and plan/apply workflow make it more flexible for multi-cloud and heterogeneous environments.
State is a JSON file mapping resource addresses to their real-world IDs and attributes. Terraform uses it to track what it created and detect drift (changes made outside Terraform). State enables plan computation, dependency ordering, and parallel execution. Remote state with locking prevents concurrent modifications. Losing state means Terraform cannot manage existing resources and might recreate them.
Drift occurs when infrastructure is modified outside of Terraform (console, manual CLI). terraform plan detects drift by comparing state against real infrastructure. To handle it: 1) Run terraform plan regularly (or in CI) to detect drift. 2) Either update the Terraform config to match reality, or run terraform apply to re-converge. 3) Use lifecycle { ignore_changes = [...] } for intentional drift.
count creates N identical instances indexed 0..N-1. Best for homogeneous resources. for_each iterates over a map or set of strings, giving each instance a unique key. Best when each instance needs different configuration. Prefer for_each over count when resources are not identical — it produces more readable plans and avoids index shifting when items are added/removed.
1) Mark variables as sensitive = true (hides in CLI/UI output). 2) Use a secrets manager (Vault, AWS Secrets Manager) with a provider. 3) For CI/CD, inject secrets via environment variables (TF_VAR_xxx) or CI secrets. 4) Never store secrets in tfvars files committed to VCS. 5) Enable encryption on the state backend. 6) Consider HashiCorp Vault provider for dynamic secrets.
A plan is a preview of changes Terraform will make: create (+), update (~), destroy (-), or no-op. It compares the desired state (config) with the current state (real infrastructure). Terraform builds a dependency graph, walks it, and determines which resources need changes. The plan can be saved (-out=tfplan) and applied deterministically. In CI/CD, always review plans before applying.
.terraform directory.