smaple.tr
terraform

Terraform IaC: Infrastructure Automation with HCL, Modules, and GitOps [2026]

Mehmet Kurtipek
June 6, 2026
11 min read
terraform
terraform IaC
infrastructure automation
HCL
terraform modules
pulumi
OpenTofu

Terraform has become the de facto standard for infrastructure as code. Its provider ecosystem spans 4,000+ providers — AWS, Azure, GCP, Kubernetes, Cloudflare, Datadog, GitHub, PagerDuty, and hundreds more. Its declarative HCL syntax is readable without being verbose. Its plan/apply workflow makes infrastructure changes transparent and reviewable. For teams managing cloud infrastructure at any scale beyond a few dozen resources, Terraform is the operational foundation.

This guide is a deep technical reference for Terraform practitioners: HCL syntax and advanced patterns, provider architecture, state management strategies, module design, policy as code, CI/CD pipeline integration, and comparisons with Pulumi and OpenTofu. By the end, you will have the Terraform knowledge to design and operate production infrastructure at scale.

Terraform IaC: Architecture and Core Concepts

Terraform operates through a provider model. Each provider is a plugin that translates Terraform resource definitions into API calls for a specific platform. The AWS provider manages EC2, S3, RDS, and hundreds of other AWS resources. The Kubernetes provider manages clusters, namespaces, deployments, and services. The GitHub provider manages repositories, teams, and branch protection rules.

Providers and Version Pinning

Provider configuration specifies credentials and endpoint settings:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.25"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

Version pinning (~> 5.0 accepts 5.x but not 6.x) prevents unexpected breaking changes when provider versions are released. In production environments, pin to patch versions (= 5.31.0) and update deliberately.

The provider alias mechanism allows using the same provider with different configurations:

provider "aws" {
  alias  = "us_east"
  region = "us-east-1"
}

provider "aws" {
  alias  = "eu_west"
  region = "eu-west-1"
}

resource "aws_s3_bucket" "us_bucket" {
  provider = aws.us_east
  bucket   = "my-us-bucket"
}

This is essential for multi-region and multi-account architectures.

Resources and Data Sources

Resources define infrastructure to create, update, or delete:

resource "aws_db_instance" "primary" {
  identifier        = "prod-postgres"
  engine            = "postgres"
  engine_version    = "15.4"
  instance_class    = "db.t3.medium"
  allocated_storage = 100

  db_name  = "appdb"
  username = var.db_username
  password = var.db_password

  backup_retention_period = 7
  multi_az                = true
  storage_encrypted       = true

  lifecycle {
    prevent_destroy = true  # Prevents accidental deletion
  }
}

Data sources read existing infrastructure without creating or modifying it:

data "aws_vpc" "existing" {
  filter {
    name   = "tag:Name"
    values = ["production-vpc"]
  }
}

# Reference in a resource
resource "aws_subnet" "app" {
  vpc_id     = data.aws_vpc.existing.id
  cidr_block = "10.0.1.0/24"
}

The prevent_destroy lifecycle argument is critical for production databases and other irreplaceable resources: it causes terraform destroy and any plan that would destroy the resource to fail with an explicit error.

HCL Advanced Patterns

Variables with validation:

variable "environment" {
  type        = string
  description = "Deployment environment"

  validation {
    condition     = contains(["dev", "staging", "production"], var.environment)
    error_message = "Environment must be dev, staging, or production."
  }
}

variable "instance_count" {
  type    = number
  default = 2

  validation {
    condition     = var.instance_count >= 1 && var.instance_count <= 100
    error_message = "Instance count must be between 1 and 100."
  }
}

For_each for dynamic resource creation:

locals {
  services = {
    api     = { port = 8080, cpu = 512, memory = 1024 }
    worker  = { port = 9090, cpu = 1024, memory = 2048 }
    cron    = { port = 0,    cpu = 256,  memory = 512 }
  }
}

resource "aws_ecs_task_definition" "service" {
  for_each = local.services

  family                = "${var.environment}-${each.key}"
  cpu                   = each.value.cpu
  memory                = each.value.memory
  network_mode          = "awsvpc"
  container_definitions = jsonencode([{
    name  = each.key
    image = "${var.ecr_repository}:latest"
    portMappings = each.value.port > 0 ? [{ containerPort = each.value.port }] : []
  }])
}

for_each produces stable references (aws_ecs_task_definition.service["api"]) unlike count which uses numeric indices that shift when items are removed from the middle.

Dynamic blocks for nested configurations:

variable "ingress_rules" {
  type = list(object({
    port        = number
    cidr_blocks = list(string)
  }))
}

resource "aws_security_group" "app" {
  name   = "${var.environment}-app-sg"
  vpc_id = var.vpc_id

  dynamic "ingress" {
    for_each = var.ingress_rules
    content {
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}

State Management

Terraform state is a JSON file that maps Terraform resource addresses to real infrastructure resource IDs. Without state, Terraform cannot determine what exists, what changed, or what needs to be created, updated, or deleted.

Remote State Backends

Local state is suitable only for personal experimentation. Any shared infrastructure — operated by a team or managed by CI/CD — requires remote state with locking:

terraform {
  backend "s3" {
    bucket         = "mycompany-terraform-state"
    key            = "infrastructure/production/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    kms_key_id     = "arn:aws:kms:us-east-1:123456789:key/abc-123"
    dynamodb_table = "terraform-state-locks"
  }
}

The S3 backend configuration:

  • bucket: State file storage with versioning enabled
  • encrypt: KMS encryption for sensitive values in state
  • dynamodb_table: Locking to prevent concurrent applies that would corrupt state

Alternative backends: Azure Blob Storage (with storage account locking), GCS (with object versioning), Terraform Cloud (built-in locking and remote execution).

State Isolation Strategies

Monolithic state — one state file for all infrastructure — creates several problems: long plan/apply times, risk that a change to one component causes issues with unrelated components, and blast radius concerns.

Directory-per-environment provides strong isolation:

infrastructure/
  modules/
    vpc/
    eks-cluster/
    rds/
  environments/
    dev/
      main.tf      # state: infra/dev/terraform.tfstate
    staging/
      main.tf      # state: infra/staging/terraform.tfstate
    production/
      main.tf      # state: infra/production/terraform.tfstate

Each environment has its own state file. A terraform apply in production doesn't touch dev or staging state.

Remote state data sources enable cross-state references:

data "terraform_remote_state" "networking" {
  backend = "s3"
  config = {
    bucket = "mycompany-terraform-state"
    key    = "networking/production/terraform.tfstate"
    region = "us-east-1"
  }
}

# Reference networking outputs in application infrastructure
resource "aws_db_subnet_group" "main" {
  subnet_ids = data.terraform_remote_state.networking.outputs.private_subnet_ids
}

Importing Existing Infrastructure

For organizations with manually-created infrastructure, terraform import brings existing resources under Terraform management:

# Import an existing S3 bucket into Terraform state
terraform import aws_s3_bucket.legacy my-existing-bucket-name

Terraform 1.5+ supports import blocks in HCL for declarative import:

import {
  to = aws_s3_bucket.legacy
  id = "my-existing-bucket-name"
}

After import, terraform plan shows the resource as managed. Any configuration differences between the imported resource and the Terraform definition show up as a planned change.

Module Design

Modules are the abstraction primitive for reusable Terraform code. A module encapsulates a set of resources, exposes input variables, and produces output values.

Composable Module Architecture

Production module structure:

modules/
  vpc/
    main.tf        # VPC, subnets, route tables, internet gateway
    variables.tf   # Input variables with validation
    outputs.tf     # VPC ID, subnet IDs, etc.
    README.md      # Usage examples
  eks-cluster/
    main.tf
    variables.tf
    outputs.tf
  rds-postgres/
    main.tf
    variables.tf
    outputs.tf

Modules should follow the single responsibility principle: one module manages one logical infrastructure component. A module that creates a VPC AND an EKS cluster AND an RDS database is too large.

Versioned Module Registry

Modules stored in a Git repository can be referenced by version tag:

module "vpc" {
  source  = "git::https://github.com/mycompany/terraform-modules.git//vpc?ref=v2.1.0"

  cidr_block  = "10.0.0.0/16"
  environment = var.environment
  az_count    = 3
}

Semantic versioning for modules enables consumers to pin to specific versions and upgrade deliberately. A v2.0.0 release that introduces breaking changes doesn't affect consumers pinned to v1.x.x.

Policy as Code

Policy as code enforces security and compliance requirements automatically, before infrastructure is provisioned:

Checkov scans Terraform files for security misconfigurations:

checkov -d . --framework terraform

A Checkov scan catches: S3 buckets without public access blocking, security groups with unrestricted ingress, RDS instances without encryption, Lambda functions without VPC configuration.

Open Policy Agent (OPA) with Conftest enables custom organizational policies:

# Require all AWS resources to have an "environment" tag
package terraform

deny[msg] {
  resource := input.resource_changes[_]
  resource.change.actions[_] != "no-op"
  not resource.change.after.tags.environment
  msg := sprintf("Resource %v is missing required 'environment' tag", [resource.address])
}

Infracost adds cost estimation to the CI pipeline:

- name: Run Infracost
  uses: infracost/actions/setup@v2

- name: Generate Infracost diff
  run: |
    infracost diff --path=. --format=table

Infracost produces a cost estimate for each plan, shown as a PR comment. A pull request that would increase monthly infrastructure costs by $500 is visible before it merges.

CI/CD Pipeline Integration

A production Terraform CI/CD pipeline:

# .github/workflows/terraform.yml
on:
  pull_request:
    paths: ['infrastructure/**']
  push:
    branches: [main]
    paths: ['infrastructure/**']

jobs:
  validate:
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform fmt -check -recursive
      - run: terraform init
      - run: terraform validate
      - run: checkov -d . --framework terraform --quiet

  plan:
    needs: validate
    if: github.event_name == 'pull_request'
    steps:
      - run: terraform plan -out=tfplan
      - run: terraform show -json tfplan | infracost breakdown --path -
      # Post plan output as PR comment

  apply:
    needs: validate
    if: github.ref == 'refs/heads/main'
    environment: production  # Requires manual approval
    steps:
      - run: terraform apply -auto-approve

The pipeline: validates on every change, plans on pull requests (shows what will change), applies on merge to main (with a required review for the production environment).

Terraform vs. Pulumi vs. OpenTofu

Pulumi

Pulumi allows writing infrastructure in TypeScript, Python, Go, C#, or Java. The same cloud resources available in Terraform are available in Pulumi, via the same underlying provider APIs.

When Pulumi is the better choice:

  • Team has strong programming language skills and finds HCL limiting for complex logic
  • Infrastructure code needs to be tested with standard unit testing frameworks
  • Complex conditional logic, loops, or data transformations are required
  • IDE integration (autocomplete, type checking, refactoring) is a priority

When Terraform is the better choice:

  • Team has existing Terraform expertise
  • Simpler, declarative infrastructure where HCL's expressiveness is sufficient
  • Large existing Terraform module ecosystem is valuable
  • Team prefers a domain-specific language over general-purpose code for infrastructure

OpenTofu

OpenTofu is the open-source fork of Terraform, created after HashiCorp changed Terraform's license from MPL to BSL (Business Source License) in 2023. OpenTofu is maintained by the Linux Foundation and is nearly 100% compatible with Terraform configurations.

OpenTofu is the appropriate choice for:

  • Organizations with license compliance requirements that preclude BSL software
  • Organizations that prefer fully open-source tooling
  • Teams that contribute to or depend on specific Terraform features that might be restricted under BSL

OpenTofu provides state encryption as an open-source feature (available in Terraform only under Enterprise). For most organizations, Terraform and OpenTofu are interchangeable operationally.

Terraform Best Practices for Production

Secrets Management

Never store secrets in Terraform variable files or state. State files contain all resource attributes — including sensitive ones — in plaintext by default. Use secret management integrations:

# Retrieve from AWS Secrets Manager at apply time
data "aws_secretsmanager_secret_version" "db_password" {
  secret_id = "production/database/password"
}

resource "aws_db_instance" "primary" {
  password = data.aws_secretsmanager_secret_version.db_password.secret_string
  # ...
}

Enable state encryption (S3 with KMS, or OpenTofu's native state encryption) to protect sensitive values that end up in state despite best efforts.

Naming and Tagging Standards

Consistent naming enables filtering, cost allocation, and governance:

locals {
  common_tags = {
    environment  = var.environment
    team         = var.team
    cost_center  = var.cost_center
    managed_by   = "terraform"
    repository   = "github.com/mycompany/infrastructure"
  }

  name_prefix = "${var.environment}-${var.team}"
}

resource "aws_instance" "app" {
  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-app-server"
  })
}

Define the tagging standard in a shared locals file and apply it consistently to all resources. Tags are the foundation of cost allocation, security auditing, and automation.

Drift Detection

Schedule regular terraform plan runs to detect infrastructure drift — changes made outside Terraform:

# .github/workflows/drift-detection.yml
on:
  schedule:
    - cron: '0 8 * * 1-5'  # Weekdays at 8 AM

jobs:
  drift-check:
    steps:
      - run: terraform plan -detailed-exitcode
        # Exit code 2 means there are differences (drift detected)
        # Exit code 0 means no changes
      - if: failure()
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: 'Infrastructure drift detected in production',
              body: 'The terraform plan shows differences from expected state.'
            });

Drift detection catches manual changes, API-driven changes from external tooling, and provider-initiated modifications before they cause deployment failures.

Terragrunt for DRY Multi-Environment Configurations

Terragrunt reduces duplication in multi-environment, multi-region Terraform configurations. Instead of repeating backend configuration and provider settings in every directory:

# terragrunt.hcl (root)
remote_state {
  backend = "s3"
  generate = {
    path      = "backend.tf"
    if_exists = "overwrite_terragrunt"
  }
  config = {
    bucket  = "mycompany-terraform-state"
    key     = "${path_relative_to_include()}/terraform.tfstate"
    region  = "us-east-1"
    encrypt = true
  }
}

Each child directory inherits this configuration. Terragrunt's run-all command runs terraform apply across multiple directories in dependency order — provisioning the VPC before the EKS cluster that depends on it, without manual sequencing.

Related Articles

August 11, 2026

MLOps Guide: Taking Machine Learning Models to Production [2026]

87% of machine learning models built by data science teams never reach production. The models work — they pass cross-validation, they score well on holdout sets, they demonstrate genuine predictive value. The problem is not the modeling. The problem is everything that happens between a notebook experiment and a reliable, monitored, production system. MLOps is the discipline that closes that gap. This guide covers the full MLOps stack: maturity levels, tooling choices (MLflow, DVC, Kubeflow

Read More: MLOps Guide: Taking Machine Learning Models to Production [2026]
August 10, 2026

LLM Fine-Tuning Guide: Custom Model Training with LoRA and QLoRA [2026]

General-purpose LLMs are impressive. They can write code, summarize documents, answer questions, and translate between languages with reasonable accuracy. But "reasonable" is not good enough when your application requires consistent output format, domain-specific terminology, a particular tone, or behavior that the base model was never trained to exhibit. That gap is where fine-tuning matters. Fine-tuning updates a model's weights on your specific data, changing how the model behaves — not

Read More: LLM Fine-Tuning Guide: Custom Model Training with LoRA and QLoRA [2026]
August 9, 2026

Computer Vision Applications: Object Detection, OCR, and Industrial AI [2026]

Computer vision has moved well past the research phase. The models are trained, the frameworks are mature, the hardware is accessible, and the use cases are generating measurable returns. What was a specialized capability requiring deep expertise in 2018 is now deployable infrastructure — if you know which component to reach for and where the real complexity lives. This guide covers computer vision applications across industrial, medical, logistics, and document processing domains. It expl

Read More: Computer Vision Applications: Object Detection, OCR, and Industrial AI [2026]