Przejdź do treści
DevOps

Terraform - Infrastructure as Code in Practice. A Complete Guide

Published on:
·6 min read·Author: MDS Software Solutions Group

Terraform Infrastructure as

devops

Terraform - 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 plan shows 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!

Author
MDS Software Solutions Group

Team of programming experts specializing in modern web technologies.

Terraform - Infrastructure as Code in Practice. A Complete Guide | MDS Software Solutions Group | MDS Software Solutions Group