Skip to main content
BlogDevOps

Infrastructure as Code with Terraform

Provision and manage cloud infrastructure declaratively using Terraform. Master HCL syntax, remote state, reusable modules, and automated CI/CD pipelines for multi-cloud deployments.

Matthews Wong

Software Engineer

October 202411 min read
Infrastructure as Code with Terraform

Photo by HashiCorp

Infrastructure as Code with Terraform has transformed how DevOps teams provision and manage cloud resources. Instead of clicking through cloud consoles or running ad-hoc scripts, Terraform lets you describe your entire infrastructure in human-readable HCL files that can be reviewed, versioned, and reused across every environment — from local dev to multi-region production.

Understanding Terraform's Core Concepts

Terraform is a declarative tool: you describe the desired end-state of your infrastructure and Terraform figures out how to get there. The workflow is always the same — write configuration in HCL, run terraform plan to preview changes, and terraform apply to execute them. The real power lies in how Terraform tracks everything it manages through a state file.

HCL Syntax and Resource Blocks

Every Terraform configuration is built from resource blocks that map directly to cloud API objects — an aws_instance, a google_storage_bucket, an azurerm_virtual_network. Variables, outputs, and data sources complete the language. The backend block tells Terraform where to store its state file — always use a remote backend (S3, GCS, Terraform Cloud) so state is shared across your team.

# main.tf — Provision an AWS EC2 instance
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
  backend "s3" {
    bucket = "my-terraform-state"
    key    = "prod/ec2/terraform.tfstate"
    region = "ap-southeast-1"
  }
}

provider "aws" {
  region = var.aws_region
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.instance_type

  tags = {
    Name        = "web-server"
    Environment = var.environment
    ManagedBy   = "Terraform"
  }
}

variable "aws_region"    { default = "ap-southeast-1" }
variable "instance_type" { default = "t3.micro" }
variable "environment"   { default = "production" }

Modules: The Key to Reusable Infrastructure

Modules are self-contained, reusable packages of Terraform resources. They accept input variables and expose outputs, letting you encapsulate complex infrastructure like a VPC with public and private subnets behind a clean interface. Well-designed modules make it trivial to replicate environments — spin up a staging environment that mirrors production with a single module call.

Designing a Reusable VPC Module

A good VPC module accepts cidr_block, environment name, and a list of availability zones. It creates the VPC, public and private subnets, route tables, and an internet gateway — outputting the VPC ID and subnet IDs for other modules to consume. The count meta-argument makes subnets dynamic: one subnet per AZ without duplicate code.

# modules/vpc/main.tf — Reusable VPC module
variable "cidr_block"   {}
variable "environment"  {}
variable "azs"          { type = list(string) }

resource "aws_vpc" "main" {
  cidr_block           = var.cidr_block
  enable_dns_hostnames = true

  tags = { Name = "${var.environment}-vpc" }
}

resource "aws_subnet" "public" {
  count             = length(var.azs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.cidr_block, 8, count.index)
  availability_zone = var.azs[count.index]

  tags = { Name = "${var.environment}-public-${count.index}" }
}

output "vpc_id"     { value = aws_vpc.main.id }
output "subnet_ids" { value = aws_subnet.public[*].id }

# Usage in root module
module "vpc" {
  source      = "./modules/vpc"
  cidr_block  = "10.0.0.0/16"
  environment = "prod"
  azs         = ["ap-southeast-1a", "ap-southeast-1b"]
}

Always run terraform plan before apply in CI/CD and review the diff. Add -detailed-exitcode to distinguish 'no changes' (exit 0) from 'changes pending' (exit 2) — this lets you skip unnecessary apply runs and speeds up your pipeline.

State Management and Workspaces

Terraform state is what allows the tool to know what it manages and what changed since the last apply. Remote state in an S3 bucket with DynamoDB locking prevents concurrent writes from corrupting your infrastructure. Workspaces are an ergonomic way to maintain separate state files for staging and production while sharing the same configuration codebase.

Environment-Specific Configuration

Rather than duplicating configuration for each environment, use .tfvars files combined with workspaces. A single terraform apply -var-file=environments/production.tfvars selects the right values. The terraform.workspace interpolation lets you embed the environment name in resource names and tags automatically.

# terraform.tfvars — Environment-specific values
environment   = "production"
aws_region    = "ap-southeast-1"
instance_type = "t3.medium"

# Workspace-based state isolation
# terraform workspace new staging
# terraform workspace select production
# terraform apply -var-file="environments/${terraform.workspace}.tfvars"
Never Store Sensitive Values in State or tfvars

Terraform state files contain plaintext resource attributes including passwords and private keys. Enable state encryption (Terraform Cloud encrypts by default; S3 backends use KMS). Never commit .tfvars files containing secrets — use environment variables or a secrets manager like HashiCorp Vault or AWS Secrets Manager instead.

Terraform in CI/CD Pipelines

Running Terraform in CI/CD ensures that every change goes through plan → review → apply with an audit trail. GitHub Actions, GitLab CI, and Atlantis are all popular choices. The key pattern is: validate and plan on every pull request (post the plan as a PR comment), and apply automatically only when merging to the main branch.

GitHub Actions Workflow for Terraform

A standard Terraform GitHub Actions workflow has four steps: checkout → terraform init → terraform validate → terraform plan on pull requests, and adds terraform apply on pushes to main. Use OIDC-based authentication to AWS or GCP to avoid storing long-lived credentials as GitHub Secrets.

# .github/workflows/terraform.yml — CI/CD for Terraform
name: Terraform CI/CD
on:
  push:
    branches: [main]
  pull_request:

jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.7.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 Validate
        run: terraform validate

      - name: Terraform Plan
        run: terraform plan -out=tfplan

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main'
        run: terraform apply -auto-approve tfplan

Terraform Best Practices and Key Terminology

Before going further, let's clarify some core Terraform terminology you'll encounter constantly: HCL, state file, modules, plan, workspaces, and providers.

Follow these practices for production-grade Terraform: pin provider versions with ~> to avoid breaking changes, use remote state with locking, structure code into modules from day one, tag every resource with environment and ManagedBy=Terraform, and run terraform fmt and tflint in CI to keep code consistent. Combine Terraform with Ansible for configuration management — Terraform provisions the infrastructure, Ansible configures the software on top.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What makes Terraform declarative, and why does that matter?

Terraform is declarative because you describe the desired end-state of your infrastructure in HCL files rather than writing step-by-step scripts. You run terraform plan to preview what will change and terraform apply to execute it. This approach means Terraform figures out the correct sequence of API calls — you don't have to.

Why is remote state with DynamoDB locking recommended over local state?

Terraform state tracks every resource it manages, and local state is only accessible to one person. Storing state in an S3 bucket with DynamoDB locking prevents concurrent writes from corrupting the infrastructure — two engineers can't accidentally run conflicting applies at the same time. Workspaces build on this by letting staging and production each have their own state file while sharing the same configuration codebase.

How do modules help teams reuse Terraform configuration?

Modules are self-contained packages of Terraform resources that accept input variables and expose outputs. A well-designed VPC module, for example, accepts a CIDR block, environment name, and availability zones, then creates subnets, route tables, and an internet gateway — outputting IDs for other modules to consume. This lets you spin up a staging environment that mirrors production with a single module call instead of duplicating code.

What is the recommended CI/CD pattern for running Terraform safely?

The recommended pattern is to validate and plan on every pull request — posting the plan output as a PR comment — and apply automatically only when merging to the main branch. Adding -detailed-exitcode lets your pipeline distinguish 'no changes' (exit 0) from 'changes pending' (exit 2), skipping unnecessary apply runs. Using OIDC-based authentication to AWS or GCP avoids storing long-lived credentials as CI secrets.

How should sensitive values like passwords and private keys be handled in Terraform?

State files store resource attributes in plaintext, including passwords and private keys, so state encryption must be enabled — Terraform Cloud encrypts by default and S3 backends can use KMS. You should never commit .tfvars files that contain secrets. Instead, pass sensitive values through environment variables or a dedicated secrets manager such as HashiCorp Vault or AWS Secrets Manager.