Terraform has become the standard tool for Infrastructure as Code (IaC). It allows you to define, provision, and manage cloud resources across providers using declarative configuration. This guide covers practical Terraform patterns for production use.
Core Concepts
Terraform uses a declarative approach -- you describe the desired state, and Terraform figures out how to reach it:
# main.tf
terraform {
required_version = ">= 1.8"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "myapp-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
}
}
provider "aws" {
region = var.aws_region
}
resource "aws_s3_bucket" "app_data" {
bucket = "myapp-production-data"
tags = {
Name = "Application Data"
Environment = "production"
}
}
Key components: providers connect to cloud APIs, resources define infrastructure components, and the backend stores state.
State Management
State is the most critical part of Terraform. It maps configuration to real-world resources.
Remote State Backend
Always use remote state storage with locking:
# backend configuration during init: terraform init -backend-config=backend.hcl
bucket = "company-terraform-state"
key = "env:/${environment}/networking/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
DynamoDB provides state locking to prevent concurrent modifications. S3 versioning provides state history for rollback.
State Access for Other Configurations
Share outputs across configurations:
data "terraform_remote_state" "vpc" {
backend = "s3"
config = {
bucket = "company-terraform-state"
key = "env:/production/vpc/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.vpc.outputs.private_subnet_ids[0]
}
Module Design
Modules are reusable Terraform configurations. Design them for composability:
# modules/vpc/main.tf
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "Must be a valid CIDR notation."
}
}
variable "environment" {
description = "Environment name for tagging"
type = string
}
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "vpc-${var.environment}"
Environment = var.environment
}
}
output "vpc_id" {
value = aws_vpc.this.id
}
output "vpc_cidr" {
value = aws_vpc.this.cidr_block
}
Use input validation to catch errors early. Document all variables and outputs with descriptions.
Workspace and Environment Management
Use workspaces or directory structure for environment isolation:
# Directory structure:
terraform/
env/
production/
main.tf
terraform.tfvars
staging/
main.tf
terraform.tfvars
Or use Terraform workspaces:
terraform workspace new staging
terraform workspace new production
terraform workspace select staging
terraform plan -var-file=staging.tfvars
Workspaces are simpler but can become confusing with many environments. Directory-based separation is clearer for complex setups.
Terraform Plan and Apply Workflow
The standard workflow in CI/CD:
# Initialize with backend
terraform init -backend-config=backend-$ENV.hcl
# Format and validate
terraform fmt -check
terraform validate
# Plan
terraform plan -out=tfplan -var-file=$ENV.tfvars
# Apply (typically in CI with approval gate)
terraform apply tfplan
Never run `terraform apply` without a plan file in CI. Always review the plan output before applying.
Managing Secrets
Never hardcode secrets. Use variables with sensitive flag:
variable "db_password" {
description = "Database administrator password"
type = string
sensitive = true
}
For secrets that must be in state, encrypt with a key management service:
resource "aws_db_instance" "main" {
password = var.db_password # Still goes to state, but you can encrypt state
}
Better approach: use AWS Secrets Manager or Vault and reference secrets via data sources:
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = "production/db/password"
}
Testing Terraform Code
`terraform plan` as a Test
Run `terraform plan` in CI to detect drift and validate changes without applying:
terraform plan -detailed-exitcode
# Exit code 0: no changes
# Exit code 1: error
# Exit code 2: changes needed
Terratest for Integration Tests
// test/vpc_test.go
func TestVPC(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"vpc_cidr": "10.0.0.0/16",
"environment": "test",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
output := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, output)
}
Common Pitfalls
```hcl
resource "aws_db_instance" "production" {
lifecycle {
prevent_destroy = true
}
}
```
Sentinel and Policy as Code
For teams, enforce policies with Sentinel (HashiCorp Enterprise) or Open Policy Agent:
# Deny public S3 buckets
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
resource.change.after.acl == "public-read"
msg := "S3 buckets must not be publicly readable"
}
Summary
Terraform brings software engineering practices to infrastructure. Use remote state with locking, compose resources into modules, separate environments, and integrate planning into CI/CD. Never hardcode secrets, always validate configurations, and protect critical resources from accidental destruction. With these practices, Terraform enables infrastructure that is versioned, reviewable, reproducible, and auditable.