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
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]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]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]
