Terraform in Practice: From IaC Basics to Production
This article walks readers through using Terraform for Infrastructure as Code, covering installation, core concepts, workflow, remote state management, modular design, variable handling, sensitive data protection, production best practices, troubleshooting, and advanced topics such as Terragrunt, CDK, policy-as-code, testing, multi‑cloud deployment, and import strategies.
Terraform Basics
Install Terraform (Linux/macOS via brew install terraform, Ubuntu/Debian via
wget https://releases.hashicorp.com/terraform/1.6.0/terraform_1.6.0_linux_amd64.zip && unzip terraform_1.6.0_linux_amd64.zip && sudo mv terraform /usr/local/bin/). Verify with terraform version (v1.6.0).
Core Concepts
Provider : Configures API access for a cloud. Example for AWS:
provider "aws" {
region = "us-east-1"
access_key = "your-access-key"
secret_key = "your-secret-key"
}Resource : Declares an infrastructure component. Example EC2 instance:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
tags = {
Name = "web-server"
}
}Data Source : Read‑only lookup, e.g., latest Ubuntu AMI.
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}Variable : Parameterizes configuration. Example with validation:
variable "environment" {
description = "Environment name"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging, or prod."
}
}Output : Exposes resource attributes.
output "instance_ip" {
description = "EC2 public IP"
value = aws_instance.web.public_ip
}Typical Workflow
Write .tf files → terraform init → terraform plan → terraform apply → resources created/updated
|
v
terraform destroy (optional)Basic Configuration Example
Create a project directory and a main.tf that defines required Terraform version, provider, remote S3 backend, variables, and a VPC with subnets, IGW, route tables, security group, and an EC2 instance. The full file is omitted for brevity.
Initialize
terraform initOutput shows backend initialization and provider plugin installation (e.g., hashicorp/aws v5.0.0).
Plan
terraform plan
terraform plan -var-file="prod.tfvars"The command lists resources that will be created or updated.
Apply
terraform apply
terraform apply -auto-approve
terraform apply -var="environment=prod" -var="instance_type=t3.medium"Destroy
terraform destroy
terraform destroy -auto-approveState Management
Local vs Remote State
Local state (default) stores terraform.tfstate in the working directory – suitable only for development/testing.
Remote state (production) stores state in a shared backend such as S3 with DynamoDB locking.
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "env:/dev/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}S3 Remote State Setup
# Create bucket
aws s3 mb s3://my-terraform-state
# Enable versioning
aws s3api put-bucket-versioning --bucket my-terraform-state --versioning-configuration Status=Enabled
# Enable server‑side encryption
aws s3api put-bucket-encryption --bucket my-terraform-state --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefaults":{"SSEAlgorithm":"AES256"}}]}'
# Create DynamoDB lock table
aws dynamodb create-table --table-name terraform-locks --attribute-definitions AttributeName=LockID,AttributeType=S --key-schema AttributeName=LockID,AttributeType=S --billing-mode PAY_PER_REQUESTState Locking
Terraform automatically acquires a lock during apply. If a lock exists, the CLI prints an error similar to:
Error: Error acquiring the state lock
Lock ID: <em>LOCK_ID</em>
Who: user@hostname
Operation: OperationTypeApply
Time: 2025-01-15T10:00:00ZForce unlock can be performed with terraform force-unlock <lock_id> (use with caution).
State Operations
# List resources
terraform state list
# Rename a resource
terraform state mv aws_instance.web aws_instance.app
# Remove a resource from state
terraform state rm aws_instance.old
# Pull remote state to a local file
terraform state pull > terraform.tfstate
# Push a local state file to remote
terraform state push terraform.tfstateModularity
Module Structure
modules/
└── vpc/
├── main.tf
├── variables.tf
├── outputs.tf
└── versions.tfVPC Module Example
variable "environment" { type = string }
variable "vpc_cidr" { type = string }
variable "availability_zones" { type = list(string) }
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "vpc-${var.environment}"
Environment = var.environment
}
}
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = { Name = "subnet-public-${count.index + 1}" }
}
output "vpc_id" { value = aws_vpc.main.id }
output "vpc_cidr" { value = aws_vpc.main.cidr_block }
output "public_subnet_ids" { value = aws_subnet.public[*].id }Using Modules in Root Configuration
terraform {
required_version = ">= 1.0.0"
required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } }
backend "s3" { bucket = "my-terraform-state" key = "project1/terraform.tfstate" region = "us-east-1" }
}
provider "aws" { region = "us-east-1" }
module "vpc" {
source = "./modules/vpc"
environment = "prod"
vpc_cidr = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b"]
}
module "ec2" {
source = "./modules/ec2"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.public_subnet_ids
instance_type = "t3.medium"
environment = "prod"
}Variables and Sensitive Information
Variable Definitions
variable "project_name" { description = "Project name" type = string default = "myapp" }
variable "environment" { description = "Environment" type = string validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "environment must be dev, staging, or prod." } }
variable "instance_type" { description = "EC2 instance type" type = string default = "t2.micro" }
variable "ami_id" { description = "AMI ID map" type = map(string) default = { us-east-1 = "ami-0c55b159cbfafe1f0" cn-north-1 = "ami-0a36940d" } }
variable "tags" { description = "Resource tags" type = map(string) default = { Project = "myapp" ManagedBy = "Terraform" } }
variable "db_password" { description = "Database password" type = string sensitive = true }Variable Assignment Methods (priority high→low)
Command‑line -var arguments
Environment variables prefixed with
TF_VAR_ terraform.tfvarsfile .auto.tfvars files
Default values in variable blocks
# terraform.tfvars example
project_name = "myapp"
environment = "prod"
instance_type = "t3.medium"
tags = {
Environment = "prod"
CostCenter = "IT"
} # Command line
terraform apply -var="environment=prod" -var="instance_type=t3.large" # Environment variables
export TF_VAR_environment=prod
export TF_VAR_db_password="secret123"
terraform applySensitive Data Handling
# AWS Secrets Manager data source
data "aws_secretsmanager_secret_version" "db_creds" { secret_id = "prod/db/password" }
# SSM Parameter Store data source
data "aws_ssm_parameter" "db_password" { name = "/prod/db/password" }
# Use in a resource
resource "aws_db_instance" "main" {
# … other arguments …
password = data.aws_secretsmanager_secret_version.db_creds.secret_string
}Production Best Practices
Multi‑Environment Directory Layout
environments/
├── dev/
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars
│ └── backend.tf
├── staging/
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars
│ └── backend.tf
└── prod/
├── main.tf
├── variables.tf
├── terraform.tfvars
└── backend.tfProduction backend example (S3 + DynamoDB lock):
terraform {
backend "s3" {
bucket = "my-terraform-state-prod"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
}
}Workspaces for Environment Isolation
# Create workspaces
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
# List and select
terraform workspace list
terraform workspace select prod
terraform workspace showDynamic backend key using the workspace name:
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "env:/${terraform.workspace}/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_instance" "web" {
tags = {
Name = "web-${terraform.workspace}"
Environment = terraform.workspace
}
}CI/CD Integration (GitHub Actions)
# .github/workflows/terraform.yml
name: Terraform
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.6.0
- name: Terraform Init
run: terraform init
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Terraform Format
run: terraform fmt -check -recursive
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
run: terraform plan -no-color
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Comment PR with Plan
if: github.event_name == 'pull_request'
uses: actions/github-script@v6
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Terraform plan details: ${{ steps.plan.outputs.stdout }}'
})
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}Resource Lifecycle Protection
resource "aws_db_instance" "main" {
# … other arguments …
lifecycle {
prevent_destroy = true # Must remove config before destroying
ignore_changes = [password] # Password updates ignored
}
}Cost Estimation Integration (Infracost)
# Add Infracost step to the workflow
- name: Run Infracost
run: |
infracost --tfplan "$(terraform show -json tfplan.plan)"
env:
INFRACOST_API_KEY: ${{ secrets.INFRACOST_API_KEY }}FAQ & Troubleshooting
State Drift
When the state file diverges from actual resources:
# 1. List current state
terraform state list
# 2. Inspect real resources (e.g., AWS CLI)
aws ec2 describe-instances
# 3. Import missing resources
terraform import aws_instance.web i-1234567890abcdef0
# 4. Or remove stale resources from state
terraform state rm aws_instance.webConcurrent Conflicts
Enable DynamoDB locking. If a lock is held, wait or use terraform force-unlock <lock_id> cautiously.
Large State Files
Use remote state, avoid storing large artifacts in the state, and clean old versions (e.g., aws s3 ls s3://my-terraform-state/ --recursive). Import existing resources with terraformer import when needed.
Circular Dependencies
# Problem
resource "aws_eip" "lb" { instance = aws_instance.web.id }
resource "aws_instance" "web" { associate_public_ip_address = true }
# Fix with explicit depends_on
resource "aws_eip" "lb" { instance = aws_instance.web.id }
resource "aws_instance" "web" { depends_on = [aws_eip.lb] }Common Commands Cheat‑Sheet
# Init
terraform init
terraform init -upgrade
# Plan
terraform plan
terraform plan -out=tfplan
terraform plan -var-file=prod.tfvars
# Apply
terraform apply
terraform apply -auto-approve
terraform apply tfplan
# Destroy
terraform destroy
terraform destroy -target=aws_instance.web
# State
terraform state list
terraform state mv
terraform state rm
terraform state pull > state.json
terraform state push state.json
terraform import
# Workspaces
terraform workspace new dev
terraform workspace select prod
terraform workspace list
terraform workspace show
# Output
terraform output
terraform output instance_ip
# Misc
terraform show
terraform validate
terraform fmt
terraform versionAdvanced Topics
Terragrunt
# Install Terragrunt
curl -L https://github.com/gruntwork-io/terragrunt/releases/download/v0.48.0/terragrunt_linux_amd64 -o /usr/local/bin/terragrunt
chmod +x /usr/local/bin/terragrunt
# terragrunt.hcl (root)
remote_state {
backend = "s3"
config = {
bucket = "my-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
include { path = find_in_parent_folders() }Terraform CDK (CDKTF) – TypeScript Example
import { Construct } from "constructs";
import { App, TerraformStack, TerraformOutput } from "cdk.tf";
import { AwsProvider, Instance } from "cdk.tf/aws";
class MyStack extends TerraformStack {
constructor(scope: Construct, id: string) {
super(scope, id);
new AwsProvider(this, "aws", { region: "us-east-1" });
const instance = new Instance(this, "web", {
ami: "ami-0c55b159cbfafe1f0",
instanceType: "t3.micro",
});
new TerraformOutput(this, "publicIp", { value: instance.publicIp });
}
}
const app = new App();
new MyStack(app, "my-stack");
app.synth();Policy as Code – OPA
# policy.rego
package terraform
deny[msg] {
resource := input.resource.aws_instance[_]
not resource.ami
msg = "AWS Instance must have an AMI specified"
}
deny[msg] {
resource := input.resource.aws_db_instance[_]
storage_encrypted := resource.storage_encrypted
storage_encrypted == false
msg = "RDS must have encryption enabled"
} # Test with conftest
conftest test terraform plan.jsonTesting with Terratest (Go)
package test
import (
"testing"
"time"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestTerraformWebServer(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../examples/web-server",
Vars: map[string]interface{}{ "environment": "test" },
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
instanceId := terraform.Output(t, terraformOptions, "instance_id")
assert.NotEmpty(t, instanceId)
publicIp := terraform.Output(t, terraformOptions, "public_ip")
assert.NotEmpty(t, publicIp)
time.Sleep(30 * time.Second)
// Additional verification steps …
}Dynamic Provider Configuration
variable "aws_profiles" {
description = "AWS profiles to deploy"
type = map(object({ region = string, env = string }))
default = {
prod = { region = "us-east-1", env = "production" }
staging = { region = "us-west-2", env = "staging" }
}
}
provider "aws" {
for_each = var.aws_profiles
alias = each.key
region = each.value.region
assume_role { role_arn = "arn:aws:iam::${each.value.account_id}:role/terraform" }
}
module "vpc_prod" { providers = { aws = aws.prod } source = "./modules/vpc" env = "prod" }
module "vpc_staging" { providers = { aws = aws.staging } source = "./modules/vpc" env = "staging" }Multi‑Cloud Deployment Example
# providers.tf
provider "aws" { region = "us-east-1" alias = "aws" }
provider "azurerm" { features {} alias = "azure" }
# main.tf
module "aws_vpc" { source = "./modules/vpc" providers = { aws = aws.aws } cidr = "10.0.0.0/16" }
module "azure_vnet" { source = "./modules/vnet" providers = { azurerm = azure.azure } address_space = ["10.1.0.0/16"] }Import Existing Resources
# Single resource import
terraform import aws_instance.web i-1234567890abcdef0
# Import an entire module with terraformer (generate config, then adjust manually)
terraformer import aws --resources=vpc,subnet,instance --connect=trueDebugging
# Enable detailed logs
export TF_LOG=TRACE
export TF_LOG_PATH=/tmp/terraform.log
# Debug a specific target
terraform apply -target=aws_instance.web
# Inspect state JSON
terraform state pull > /tmp/state.json
jq '.resources[] | select(.type == "aws_instance")' /tmp/state.jsonState Versioning & Locking Example
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "env:/${terraform.workspace}/vpc/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
version = 4 # Enable state versioning
}
}Production Checklist Script
#!/bin/bash
set -e
echo "=== Terraform Configuration Checks ==="
# 1. Format
terraform fmt -check -recursive && echo "Format check passed"
# 2. Validate
terraform validate && echo "Validation passed"
# 3. Sensitive info scan (simple grep)
if grep -r "password\|secret\|key" *.tf | grep -v "^#"; then
echo "Warning: Potential sensitive information found"
exit 1
fi
echo "Sensitive info check passed"
# 4. State lock check
terraform show > /dev/null && echo "State is healthy"
# 5. Dependency graph preview
terraform graph | head -20 && echo "Dependency check passed"
echo "=== All checks completed ==="Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Ops Community
A leading IT operations community where professionals share and grow together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
