Terraform - Infrastructure as Code in Practice. A Complete Guide
Terraform Infrastructure as
devopsTerraform - Infrastructure as Code in Practice
Managing IT infrastructure has evolved over the years from manual server configuration, through automation scripts, to a full Infrastructure as Code approach. Terraform, created by HashiCorp, is one of the most popular IaC tools that allows you to define, version, and automatically deploy infrastructure in a declarative manner. In this guide, we will cover all the key aspects of working with Terraform -- from HCL syntax basics to advanced production patterns.
What Is Infrastructure as Code and Why Do You Need It?#
Infrastructure as Code (IaC) is the practice of managing IT infrastructure through configuration files instead of manual processes. Instead of logging into the AWS or Azure console and clicking buttons, you define your entire infrastructure in text files that can be versioned in Git, reviewed in pull requests, and automatically deployed through CI/CD pipelines.
Problems Without IaC#
Without an IaC approach, teams encounter a range of problems:
- Snowflake servers -- each manually configured server is unique, making it difficult to reproduce environments
- Lack of repeatability -- there is no guarantee that staging is identical to production
- Configuration drift -- over time, server configurations diverge from the intended state
- No audit trail -- it is hard to trace who changed what, when, and why
- Slow deployments -- manual configuration is slow and prone to human error
Benefits of IaC#
Adopting an IaC approach solves these problems:
- Version control -- all infrastructure in Git with full change history
- Repeatability -- identical environments created with a single command
- Automation -- deployments without human intervention
- Documentation -- the code itself serves as infrastructure documentation
- Code review -- infrastructure changes reviewed like application code
- Fast recovery -- disaster recovery comes down to running a script
Terraform vs CloudFormation vs Pulumi#
Several IaC tools exist on the market. Let us compare the three most popular ones:
AWS CloudFormation#
CloudFormation is AWS's native IaC tool. Its main advantage is deep integration with the AWS ecosystem, but it works exclusively with Amazon services. Configuration files are written in JSON or YAML, which becomes unreadable at scale.
Pulumi#
Pulumi lets you define infrastructure using popular programming languages -- TypeScript, Python, Go, or C#. It is a great solution for teams that prefer to write infrastructure in a language they already know. The downsides are greater complexity and a smaller community base.
Terraform#
Terraform combines the advantages of both approaches. It uses its own language, HCL (HashiCorp Configuration Language), which is declarative, readable, and designed specifically for describing infrastructure. Terraform's key advantages include:
- Multi-cloud -- one language for AWS, Azure, GCP, and hundreds of other providers
- Huge community -- thousands of modules in the Terraform Registry
- Mature ecosystem -- a stable tool with years of history
- Plan before deploy --
terraform planshows exactly what will change - State management -- tracking the current state of infrastructure
HCL Syntax -- The Basics#
HashiCorp Configuration Language (HCL) is a declarative language designed specifically for HashiCorp tools. Its syntax is intuitive and easy to learn.
File Structure#
A typical Terraform project consists of several files:
# main.tf - main resource configuration
# variables.tf - variable definitions
# outputs.tf - output values
# providers.tf - provider configuration
# terraform.tf - backend and required providers configuration
Configuration Blocks#
HCL is based on configuration blocks. Each block has a type, optional labels, and a body:
# Resource block
resource "aws_instance" "web_server" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "WebServer"
Environment = "production"
}
}
# Data block
data "aws_ami" "ubuntu" {
most_recent = true
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
owners = ["099720109477"] # Canonical
}
# Variable block
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
validation {
condition = contains(["t3.micro", "t3.small", "t3.medium"], var.instance_type)
error_message = "Allowed types are: t3.micro, t3.small, t3.medium."
}
}
Providers -- AWS, Azure, GCP#
Providers are plugins that allow Terraform to communicate with cloud platforms and other services. Each provider exposes resources and data sources.
Provider Configuration#
# providers.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.0"
}
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
# AWS Provider
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
}
# Azure Provider
provider "azurerm" {
features {}
subscription_id = var.azure_subscription_id
}
# GCP Provider
provider "google" {
project = var.gcp_project_id
region = var.gcp_region
}
Provider Aliases#
When you need multiple instances of the same provider (e.g., different AWS regions):
provider "aws" {
alias = "eu_west"
region = "eu-west-1"
}
provider "aws" {
alias = "us_east"
region = "us-east-1"
}
resource "aws_s3_bucket" "eu_bucket" {
provider = aws.eu_west
bucket = "my-eu-bucket"
}
resource "aws_s3_bucket" "us_bucket" {
provider = aws.us_east
bucket = "my-us-bucket"
}
Resources and Data Sources#
Resources#
Resources are the main building blocks in Terraform. Each resource represents a piece of infrastructure that Terraform creates and manages:
# Creating a VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project_name}-vpc"
}
}
# Creating subnets
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 = "${var.project_name}-public-${count.index + 1}"
Type = "public"
}
}
Data Sources#
Data Sources let you retrieve information about existing resources that are not managed by Terraform:
# Get current AWS account information
data "aws_caller_identity" "current" {}
# Get available availability zones
data "aws_availability_zones" "available" {
state = "available"
}
# Get the latest AMI
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
}
# Using a data source in a resource
resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
availability_zone = data.aws_availability_zones.available.names[0]
}
Variables and Outputs#
Input Variables#
Variables allow you to parameterize your Terraform configuration:
# variables.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 "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "instance_count" {
description = "Number of EC2 instances"
type = number
default = 2
}
variable "allowed_cidrs" {
description = "List of CIDRs allowed in the Security Group"
type = list(string)
default = ["0.0.0.0/0"]
}
variable "tags" {
description = "Map of tags to assign to resources"
type = map(string)
default = {}
}
variable "db_config" {
description = "Database configuration"
type = object({
engine = string
engine_version = string
instance_class = string
allocated_storage = number
})
default = {
engine = "postgres"
engine_version = "15.4"
instance_class = "db.t3.micro"
allocated_storage = 20
}
}
Sensitive Variables#
Mark variables containing secrets as sensitive:
variable "db_password" {
description = "Database password"
type = string
sensitive = true
}
Outputs#
Outputs let you export values from your configuration:
# outputs.tf
output "vpc_id" {
description = "VPC ID"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "List of public subnet IDs"
value = aws_subnet.public[*].id
}
output "instance_public_ips" {
description = "Public IP addresses of instances"
value = aws_instance.app[*].public_ip
}
output "rds_endpoint" {
description = "RDS database endpoint"
value = aws_db_instance.main.endpoint
sensitive = true
}
Terraform Modules#
Modules let you organize and reuse Terraform code. A module is simply a directory containing .tf files.
Module Structure#
modules/
├── vpc/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── README.md
├── ec2/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── rds/
├── main.tf
├── variables.tf
└── outputs.tf
Creating a Module#
# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.tags, {
Name = "${var.name}-vpc"
})
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = merge(var.tags, {
Name = "${var.name}-igw"
})
}
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 = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = merge(var.tags, {
Name = "${var.name}-public-${count.index + 1}"
})
}
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 = var.availability_zones[count.index]
tags = merge(var.tags, {
Name = "${var.name}-private-${count.index + 1}"
})
}
Using a Module#
# main.tf
module "vpc" {
source = "./modules/vpc"
name = var.project_name
vpc_cidr = "10.0.0.0/16"
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnet_cidrs = ["10.0.10.0/24", "10.0.11.0/24"]
availability_zones = ["eu-central-1a", "eu-central-1b"]
tags = local.common_tags
}
module "ec2" {
source = "./modules/ec2"
name = var.project_name
instance_type = var.instance_type
subnet_ids = module.vpc.public_subnet_ids
vpc_id = module.vpc.vpc_id
tags = local.common_tags
}
Modules from the Terraform Registry#
You can also use ready-made modules from the registry:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.4.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
azs = ["eu-central-1a", "eu-central-1b", "eu-central-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
single_nat_gateway = true
}
State Management#
Terraform stores the state of your infrastructure in a terraform.tfstate file. This file is critical -- it maps the resources defined in your code to the real resources in the cloud.
Remote State#
In a team environment, state must be stored remotely:
# terraform.tf
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "prod/infrastructure/terraform.tfstate"
region = "eu-central-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
State Locking#
DynamoDB provides state locking, preventing concurrent modifications:
# Creating a DynamoDB table for state locking
resource "aws_dynamodb_table" "terraform_lock" {
name = "terraform-state-lock"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
tags = {
Name = "Terraform State Lock"
}
}
Azure Backend#
For Azure, state is stored in Blob Storage:
terraform {
backend "azurerm" {
resource_group_name = "tfstate-rg"
storage_account_name = "tfstateaccount"
container_name = "tfstate"
key = "prod.terraform.tfstate"
}
}
Workflow: plan, apply, destroy#
terraform init#
Initializes the project -- downloads providers and modules:
terraform init
terraform plan#
Plan shows what changes will be made without executing them:
terraform plan -out=tfplan
# Plan with a variable file
terraform plan -var-file="prod.tfvars" -out=tfplan
terraform apply#
Applies changes:
# Apply a saved plan
terraform apply tfplan
# Direct apply with confirmation
terraform apply -var-file="prod.tfvars"
# Auto-approve (CI/CD)
terraform apply -auto-approve -var-file="prod.tfvars"
terraform destroy#
Destroys all resources:
# With confirmation
terraform destroy -var-file="prod.tfvars"
# Destroy a specific resource
terraform destroy -target=aws_instance.web_server
Example: Provisioning AWS Infrastructure#
Below is a complete example of creating AWS infrastructure with VPC, EC2, RDS, and S3:
# main.tf - Complete AWS Infrastructure
locals {
common_tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
# ==================== VPC ====================
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(local.common_tags, {
Name = "${var.project_name}-vpc"
})
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = merge(local.common_tags, {
Name = "${var.project_name}-igw"
})
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 1}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = merge(local.common_tags, {
Name = "${var.project_name}-public-${count.index + 1}"
})
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 10}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = merge(local.common_tags, {
Name = "${var.project_name}-private-${count.index + 1}"
})
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = merge(local.common_tags, {
Name = "${var.project_name}-public-rt"
})
}
resource "aws_route_table_association" "public" {
count = 2
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
# ==================== Security Groups ====================
resource "aws_security_group" "web" {
name_prefix = "${var.project_name}-web-"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = merge(local.common_tags, {
Name = "${var.project_name}-web-sg"
})
lifecycle {
create_before_destroy = true
}
}
resource "aws_security_group" "rds" {
name_prefix = "${var.project_name}-rds-"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.web.id]
}
tags = merge(local.common_tags, {
Name = "${var.project_name}-rds-sg"
})
}
# ==================== EC2 ====================
resource "aws_instance" "web" {
count = var.instance_count
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = aws_subnet.public[count.index % 2].id
vpc_security_group_ids = [aws_security_group.web.id]
user_data = base64encode(templatefile("${path.module}/scripts/user_data.sh", {
environment = var.environment
db_endpoint = aws_db_instance.main.endpoint
}))
root_block_device {
volume_type = "gp3"
volume_size = 20
encrypted = true
}
tags = merge(local.common_tags, {
Name = "${var.project_name}-web-${count.index + 1}"
})
}
# ==================== RDS ====================
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-db-subnet"
subnet_ids = aws_subnet.private[*].id
tags = merge(local.common_tags, {
Name = "${var.project_name}-db-subnet-group"
})
}
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-db"
engine = "postgres"
engine_version = "15.4"
instance_class = "db.t3.micro"
allocated_storage = 20
max_allocated_storage = 100
storage_encrypted = true
db_name = var.db_name
username = var.db_username
password = var.db_password
multi_az = var.environment == "prod" ? true : false
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
backup_retention_period = 7
skip_final_snapshot = var.environment != "prod"
tags = local.common_tags
}
# ==================== S3 ====================
resource "aws_s3_bucket" "assets" {
bucket = "${var.project_name}-assets-${var.environment}"
tags = merge(local.common_tags, {
Name = "${var.project_name}-assets"
})
}
resource "aws_s3_bucket_versioning" "assets" {
bucket = aws_s3_bucket.assets.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "assets" {
bucket = aws_s3_bucket.assets.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
Workspaces#
Workspaces let you manage multiple environments from a single configuration:
# Creating workspaces
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
# Switching workspaces
terraform workspace select prod
# Listing workspaces
terraform workspace list
Using workspaces in code:
locals {
environment = terraform.workspace
instance_type = {
dev = "t3.micro"
staging = "t3.small"
prod = "t3.medium"
}
}
resource "aws_instance" "app" {
instance_type = local.instance_type[local.environment]
# ...
}
CI/CD Integration#
GitHub Actions#
# .github/workflows/terraform.yml
name: Terraform CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
TF_VERSION: "1.6.0"
AWS_REGION: "eu-central-1"
jobs:
terraform:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./infrastructure
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
run: terraform init
- name: Terraform Format Check
run: terraform fmt -check -recursive
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
if: github.event_name == 'pull_request'
run: terraform plan -no-color -var-file="prod.tfvars"
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve -var-file="prod.tfvars"
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
GitLab CI#
# .gitlab-ci.yml
stages:
- validate
- plan
- apply
variables:
TF_ROOT: "./infrastructure"
validate:
stage: validate
script:
- terraform init
- terraform fmt -check
- terraform validate
plan:
stage: plan
script:
- terraform init
- terraform plan -out=tfplan -var-file="prod.tfvars"
artifacts:
paths:
- ${TF_ROOT}/tfplan
apply:
stage: apply
script:
- terraform init
- terraform apply tfplan
when: manual
only:
- main
Best Practices#
1. Version Pin Your Providers#
Always pin provider versions to avoid unexpected changes:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.31.0" # Allows patch updates only
}
}
}
2. State Locking#
Always use state locking in a team environment. DynamoDB (AWS) or Blob Lease (Azure) prevents concurrent state modifications.
3. Small, Composable Modules#
Split your infrastructure into small, independent modules. One module per logical component (VPC, database, compute).
4. Use Variables and Locals#
Never hardcode values. Use variables for input parameters and locals for computed values:
locals {
name_prefix = "${var.project_name}-${var.environment}"
common_tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
Team = var.team_name
}
}
5. Formatting and Validation#
Always run formatting and validation before committing:
terraform fmt -recursive
terraform validate
6. Use .tfvars for Environments#
# environments/prod.tfvars
environment = "prod"
instance_type = "t3.medium"
instance_count = 3
db_instance_class = "db.r6g.large"
# environments/dev.tfvars
environment = "dev"
instance_type = "t3.micro"
instance_count = 1
db_instance_class = "db.t3.micro"
7. Import Existing Resources#
Terraform 1.5+ supports import blocks:
import {
to = aws_s3_bucket.existing
id = "my-existing-bucket"
}
resource "aws_s3_bucket" "existing" {
bucket = "my-existing-bucket"
}
Terraform Cloud#
Terraform Cloud is HashiCorp's managed platform that offers:
- Remote state management -- secure state storage with encryption
- Remote execution -- plans and applies run on HashiCorp servers
- Private module registry -- a private module registry for your organization
- Policy as Code -- Sentinel policies to enforce security rules
- Team management -- access control and permissions
- Cost estimation -- cost estimates before deployment
- Drift detection -- automatic detection of changes made outside Terraform
terraform {
cloud {
organization = "my-organization"
workspaces {
name = "my-app-prod"
}
}
}
Summary#
Terraform is a tool that fundamentally changes how IT infrastructure is managed. The key takeaways are:
- Infrastructure as Code -- all infrastructure as versioned code
- Multi-cloud -- one language to manage all platforms
- Modules -- reuse and standardization of configurations
- State management -- precise tracking of infrastructure state
- CI/CD -- full automation of infrastructure deployments
- Security -- state encryption, state locking, policy as code
Adopting Terraform in your organization is an investment that quickly pays off through increased repeatability, faster deployments, and a reduction in human error.
Need Help with Terraform and Infrastructure?#
At MDS Software Solutions Group, we specialize in designing and deploying cloud infrastructure with Terraform. We offer:
- Cloud architecture design (AWS, Azure, GCP)
- Infrastructure as Code implementation with Terraform
- Migration of existing infrastructure to an IaC approach
- CI/CD pipeline configuration for infrastructure
- Terraform and DevOps training
- Audit and optimization of existing Terraform configurations
Contact us to discuss your project!
Team of programming experts specializing in modern web technologies.