Infrastructure as Code with Terraform â Modules, State Management, Remote Backends, and Best Practices
In this tutorial, you'll learn about Infrastructure as Code with Terraform. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Infrastructure as Code with Terraform enables teams to define, provision, and manage cloud resources through declarative HCL configurations that are version-controlled, reviewable, and repeatable across environments.
What You'll Learn
Why It Matters
Clicking through cloud provider UIs to create resources is unrepeatable, un-auditable, and error-prone. A misclick in the AWS console can expose a database to the public internet with no record of who did it or how to revert it. Terraform defines infrastructure in code â every change is reviewed through pull requests, every resource is tracked in state files, and the entire environment can be recreated from scratch with one command.
Real-World Use
DodaTech manages Durga Antivirus Pro's cloud infrastructure entirely through Terraform â VPCs, subnets, EC2 instances, RDS databases, S3 buckets, and IAM roles are all defined in modules stored in a Git Repository. Remote state in S3 with DynamoDB locking enables the entire DevOps team to collaborate safely.
flowchart TD
A[Terraform Config] --> B[terraform init]
B --> C[terraform plan]
C --> D[terraform apply]
D --> E[AWS Resources]
E --> F[State File: S3]
F --> G[DynamoDB Lock]
C --> H[Output: Plan Preview]
D --> I[Output: Apply Summary]
style A fill:#844fba,color:#fff
style E fill:#ff9900,color:#fff
Prerequisites: Basic Terraform knowledge (providers, resources, variables), cloud provider account (AWS recommended), and Git for version control.
Module Design
Modules are the building blocks of reusable Terraform configurations. A well-designed module encapsulates a logical infrastructure component with clear inputs and outputs.
# modules/vpc/main.tf
variable "environment" {
description = "Environment name (dev/staging/prod)"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "cidr_block" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidrs" {
description = "CIDR blocks for public subnets"
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
}
variable "private_subnet_cidrs" {
description = "CIDR blocks for private subnets"
type = list(string)
default = ["10.0.10.0/24", "10.0.20.0/24", "10.0.30.0/24"]
}
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "vpc-${var.environment}"
Environment = var.environment
ManagedBy = "Terraform"
}
}
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "public-${var.environment}-${count.index + 1}"
Environment = var.environment
Type = "public"
}
}
resource "aws_subnet" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.this.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "private-${var.environment}-${count.index + 1}"
Environment = var.environment
Type = "private"
}
}
output "vpc_id" {
description = "The ID of the VPC"
value = aws_vpc.this.id
}
output "public_subnet_ids" {
description = "IDs of the public subnets"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "IDs of the private subnets"
value = aws_subnet.private[*].id
}
Expected output: The VPC module creates a VPC with configurable CIDR, public and private subnets across multiple availability zones, and outputs the IDs for use by other modules.
# environments/production/main.tf â using the VPC module
module "vpc" {
source = "../../modules/vpc"
environment = "production"
cidr_block = "10.0.0.0/16"
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
private_subnet_cidrs = ["10.0.10.0/24", "10.0.20.0/24", "10.0.30.0/24"]
}
module "database" {
source = "../../modules/rds"
environment = "production"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
instance_class = "db.t3.large"
allocated_storage = 100
}
module "application" {
source = "../../modules/ecs-service"
environment = "production"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.public_subnet_ids
database_url = module.database.connection_string
desired_count = 3
container_version = var.app_version
}
Expected output: The production environment composes three modules â VPC, database, and application. Each module receives only the inputs it needs and exposes outputs that other modules consume through references like module.vpc.vpc_id.
Remote State Management
Local state files are dangerous for teams â they are lost when a laptop dies and cannot be shared. Remote backends store state in a central, durable location with locking.
# environments/production/backend.tf
terraform {
backend "s3" {
bucket = "myapp-terraform-state"
key = "environments/production/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}
Expected behavior: State is stored in S3 with server-side encryption. DynamoDB provides state locking â if two team members run <a href="/devops/terraform/">Terraform</a> apply simultaneously, one will get a lock error and must wait.
# environments/production/terraform.tf
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Environment = var.environment
ManagedBy = "Terraform"
Project = "MyApp"
}
}
}
# Initialize with the S3 backend
terraform init \
-backend-config="bucket=myapp-terraform-state" \
-backend-config="key=environments/production/terraform.tfstate" \
-backend-config="dynamodb_table=terraform-state-lock"
# Expected output:
# Initializing the backend...
# Successfully configured the backend "s3"! Terraform will automatically
# use this backend for all state operations.
#
# Initializing provider plugins...
# Terraform has been successfully initialized!
Multi-Environment Structure
Organizing Terraform configurations for multiple environments prevents duplication and drift.
terraform/
modules/
vpc/
rds/
ecs-service/
iam/
s3-bucket/
environments/
dev/
main.tf
variables.tf
outputs.tf
backend.tf
terraform.tfvars
staging/
main.tf
variables.tf
outputs.tf
backend.tf
staging.tfvars
production/
main.tf
variables.tf
outputs.tf
backend.tf
production.tfvars
# environments/production/production.tfvars
environment = "production"
aws_region = "us-east-1"
app_version = "2.5.0"
instance_class = "db.t3.large"
desired_count = 5
# Variables that differ from dev/staging
enable_monitoring = true
enable_auto_scaling = true
enable_multi_az = true
backup_retention_period = 30
alarm_notification_email = "ops@example.com"
# Apply to a specific environment
terraform -chdir=environments/production apply \
-var-file=production.tfvars
# Expected output:
# Plan: 15 to add, 0 to change, 0 to destroy.
#
# Do you want to perform these actions?
# Terraform will perform the actions described above.
# Only 'yes' will be accepted to approve.
Policy as Code with Sentinel
Sentinel (or OpenPolicyAgent) enforces policies during <a href="/devops/terraform/">Terraform</a> plan â preventing developers from creating overly permissive security groups or expensive instance types.
# sentinel.hcl
policy "restrict-instance-types" {
source = "./restrict-instance-types.sentinel"
enforcement_level = "hard-mandatory"
}
# restrict-instance-types.sentinel
import "tfplan"
approved_types = [
"t3.micro", "t3.small", "t3.medium",
"t3.large", "t3.xlarge",
"m5.large", "m5.xlarge",
]
all_instances = tfplan.resource_changes["aws_instance.*"]
violations = filter all_instances as _, instance {
not instance.applied.instance_type in approved_types
}
main = rule {
length(violations) == 0
}
Expected behavior: If a developer tries to provision an m5.2xlarge instance (not in the approved list), Sentinel blocks the plan. The policy is hard-mandatory and cannot be overridden.
Refactoring with Moved Blocks
As infrastructure evolves, resources need to be renamed or moved between modules without destroying and recreating them.
# After refactoring a resource into a module
moved {
from = aws_instance.web
to = module.application.aws_instance.this
}
Expected behavior: Terraform understands that the old aws_instance.web resource is now managed by the module. Instead of destroying and recreating it, Terraform updates the state to reflect the new address.
Common Errors
Not using remote state with locking: Local state files cause conflicts when multiple people run Terraform. Without locking, concurrent applies corrupt the state file and lose resource tracking.
Hardcoding values in modules: Modules with hardcoded region, instance type, or CIDR blocks cannot be reused. Every configurable value should be a variable with a sensible default.
Storing secrets in state: Terraform state contains plaintext values of resource attributes. Database passwords and API keys in state must be protected with encryption and access controls on the backend.
Not using
<a href="/devops/terraform/">Terraform</a> planbeforeapply: A plan reveals exactly what will change. Skipping planning leads to surprises â accidental deletions, unintended modifications, and recreation of stateful resources.Ignoring
prevent_destroyon production resources: Settingprevent_destroy = trueon critical resources (databases, S3 buckets) prevents accidental deletion. Terraform refuses to destroy resources with this attribute set.Mixing environments in the same workspace: A single state file for dev, staging, and production is catastrophic â changes to dev affect the same resources. Use separate directories or workspaces per environment.
Practice Questions
Why is remote state with locking important for team collaboration? Answer: Remote state stores infrastructure state centrally where all team members can access it. Locking prevents two people from running
applysimultaneously, which would corrupt the state file.What is the purpose of Terraform modules? Answer: Modules encapsulate reusable infrastructure components with well-defined inputs and outputs. They enable consistent, DRY configurations across multiple environments and projects.
How does
<a href="/devops/terraform/">Terraform</a> planprotect against accidental changes? Answer:planshows every resource that will be created, modified, or destroyed without making any changes. Reviewing the plan catches unintended modifications before they happen.What is the advantage of separate directories per environment? Answer: Each environment has its own state file and configuration. Dev changes cannot affect production resources, and each environment can be updated independently.
Challenge
Design a complete Terraform infrastructure: create modules for VPC (with public/private subnets), RDS PostgreSQL, and ECS Fargate service. Configure remote state in S3 with DynamoDB locking. Implement three environments (dev, staging, prod) with separate tfvars files. Use prevent_destroy on the production database. Refactor a resource into a module using the moved block. Apply Sentinel policies to restrict instance types.
Mini Project
Provision a production-grade three-tier application on AWS using Terraform. Create a VPC module with public and private subnets across three availability zones. Create an RDS module with Multi-AZ deployment for production and single-AZ for dev. Create an ECS Fargate module for containerized workloads. Store state in an S3 bucket with DynamoDB locking. Use separate Terraform.tfvars files for each environment. Apply and destroy the development environment multiple times to verify repeatability.
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro