Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 083 / 100

Terraform learning roadmap: patterns, pitfalls and fixes

What to learn, in order.

Expona Admin7 min read

Day 83: Terraform learning roadmap: patterns, pitfalls and fixes

After 80+ days working with infrastructure-as-code, you've likely deployed resources, managed state, and hit a few gotchas. Today we map the terrain you should have covered, highlight the patterns that scale, and show you where most teams stumble. This Terraform learning roadmap: patterns, pitfalls and fixes follows a logical progression from first principles through multi-account production setups, grounded in what actually works at scale.

HCL, providers and the core workflow

Before anything else, you need to understand HashiCorp Configuration Language and how Terraform talks to your cloud provider. HCL is declarative, not procedural—you describe the desired state, and Terraform figures out the changes. This mindset shift is critical.

The core workflow is simple: write → init → plan → apply. The terraform init command sets up your working directory, downloads provider plugins, and initializes state. The terraform plan shows you exactly what will change before you commit anything. The terraform apply executes the plan.

Consider a basic AWS EC2 instance:

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

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

resource "aws_instance" "app_server" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.medium"
  
  tags = {
    Name = "production-app"
  }
}

output "instance_ip" {
  value = aws_instance.app_server.private_ip
}

The pattern here matters: you declare the terraform block (versioning and requirements), the provider (authentication and region), resources, and outputs. Outputs are how downstream systems or team members consume values created by your infrastructure.

Pitfall: Teams often hardcode values (AMI IDs, instance types, tags) directly in the resource block, then wonder why environments drift. Always think about what changes between dev, staging, and production.

Variables, outputs, state and modules

Once you have the basics, parameterize. Variables let you reuse configurations. Outputs expose values for consumption. State is Terraform's source of truth about what exists. Modules package reusable logic.

Here's a variable definition in variables.tf:

hcl
variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.micro"
  
  validation {
    condition     = can(regex("^t3\\.", var.instance_type))
    error_message = "Instance type must be t3 family."
  }
}

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

Then in your resource:

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

  tags = {
    Name        = "app-${var.environment}"
    Environment = var.environment
  }
}

State is Terraform's internal database. It maps your resource IDs in code to actual cloud resources. State is JSON, human-readable, and contains sensitive data—treat it with care. Never commit it to version control.

Modules are directories with their own main.tf, variables.tf, and outputs.tf. A module for a VPC with subnets looks like:

hcl
module "vpc" {
  source = "./modules/vpc"
  
  cidr_block = "10.0.0.0/16"
  azs        = ["eu-west-1a", "eu-west-1b"]
  environment = var.environment
}

resource "aws_instance" "app_server" {
  subnet_id = module.vpc.private_subnet_ids[0]
}

Pitfall: Modules without clear input/output contracts become black boxes. Always document what a module needs and what it returns. Avoid deep nesting—modules calling modules calling modules gets hard to debug.

Remote state, environments and CI/CD

Local state works for learning, but production demands remote state. A single source of truth stored in S3 (with locking via DynamoDB), Terraform Cloud, or equivalent ensures your team never corrupts state or applies conflicting changes.

Set up remote state with a backend block:

hcl
terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "prod/terraform.tfstate"
    region         = "eu-west-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"
  }
}

For environments, use workspace or separate directories. The workspace approach works for small teams:

hcl
locals {
  env = terraform.workspace
  config = {
    dev = {
      instance_type = "t3.micro"
      instance_count = 1
    }
    prod = {
      instance_type = "t3.large"
      instance_count = 3
    }
  }
}

resource "aws_instance" "app" {
  count         = local.config[local.env].instance_count
  instance_type = local.config[local.env].instance_type
}

However, many teams prefer separate directories per environment to avoid accidental prod changes:

hcl
infrastructure/
├── dev/
│   ├── main.tf
│   ├── terraform.tfvars
│   └── backend.tf
├── prod/
│   ├── main.tf
│   ├── terraform.tfvars
│   └── backend.tf
└── modules/
    └── vpc/

CI/CD integration is where Terraform scales. A GitHub Actions or GitLab CI pipeline runs terraform plan on every pull request, letting reviewers see changes before merge. On merge to main, terraform apply executes. This enforces code review for infrastructure.

Pitfall: Storing AWS credentials or secrets in CI/CD is a nightmare. Use OIDC (OpenID Connect) to let Terraform assume an IAM role without storing credentials. Avoid committing .tfvars files with sensitive data; use Terraform Cloud or a secrets manager instead.

Policies, testing and multi-account setups

At scale, you need guardrails. HashiCorp Sentinel (or cheaper alternatives like Checkov) lets you write policy-as-code to prevent risky changes. For example, enforce encryption on all S3 buckets:

hcl
import "tfplan/v2" as tfplan

deny_unencrypted_buckets = rule {
  all tfplan.resource_changes.aws_s3_bucket as _, buckets {
    all buckets as _, bucket {
      (bucket.after.server_side_encryption_configuration else []) != []
    }
  }
}

main = rule {
  deny_unencrypted_buckets
}

Testing Terraform code sounds abstract, but Terratest (Go-based) lets you provision real infrastructure, run assertions, then destroy it. A simple test:

hcl
func TestVpcModule(t *testing.T) {
  opts := &terraform.Options{
    TerraformDir: "../modules/vpc",
    Vars: map[string]interface{}{
      "cidr_block": "10.0.0.0/16",
    },
  }

  terraform.InitAndApply(t, opts)
  vpcId := terraform.Output(t, opts, "vpc_id")
  assert.NotEmpty(t, vpcId)
  terraform.Destroy(t, opts)
}

Multi-account setups are essential for isolation. Each environment (dev, staging, prod) lives in its own AWS account. Terraform in account A uses a cross-account IAM role to manage resources in account B. This limits blast radius—a dev mistake stays in dev.

Use separate AWS provider aliases:

hcl
provider "aws" {
  alias  = "prod"
  region = "eu-west-1"
  
  assume_role {
    role_arn = "arn:aws:iam::PROD_ACCOUNT_ID:role/TerraformRole"
  }
}

resource "aws_instance" "prod_app" {
  provider      = aws.prod
  instance_type = "t3.large"
}

Common mistakes

  • Committing state to Git: State contains secrets and provider IDs. Use remote state and add *.tfstate to .gitignore.
  • Using terraform destroy lightly: It deletes real resources. Use targeted destroy (terraform destroy -target=aws_instance.app) if you must.
  • Mixing manual changes with Terraform: Someone manually editing a security group via the console breaks your state. Treat Terraform as the single source of truth.
  • Not validating inputs: Use variable validation blocks to catch bad configs early, before apply fails.
  • Forgetting outputs: Outputs are how your infrastructure shares data. A database endpoint, load balancer DNS, or role ARN should always be an output.
  • Copying modules without understanding them: Registry modules are great, but read the code and tests. Understand what you're importing.

Key takeaways

  • Start with HCL syntax and the core workflow: init, plan, apply. Understand that Terraform is declarative.
  • Use variables and validation to make configs reusable and robust across environments.
  • Move to remote state early—S3 with DynamoDB locking or Terraform Cloud. Never keep state local in production.
  • Structure projects with modules for reusability and separate backend configs per environment.
  • Automate with CI/CD: plan on PR, apply on merge. Use OIDC for credentials, not secrets in the repo.
  • Add policy-as-code (Sentinel) and testing (Terratest) as you scale.
  • Multi-account setups isolate risk. Each env gets its own AWS account; Terraform assumes cross-account roles.
  • The biggest mistake is treating Terraform as optional or rushing. A day spent designing your state and module structure saves weeks of refactoring.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles