Archive · DevOps

Terraform Basics: Workflow, Variables, Outputs and Modules

A complete introduction to Terraform on AWS: how it works, project layout, providers, variables, locals, outputs, for_each, data sources, modules and environment files.

6 min read

  • Terraform 1.14
  • AWS provider 6.x
  • OpenTofu
  • Region ap-south-1

Terraform describes infrastructure as code: you declare what should exist, and Terraform works out the API calls to make it so. This post covers everything needed to write a small but well-structured AWS project. The next post covers state, testing and pipelines.

How Terraform works

flowchart LR
  C[Configuration<br/>.tf files] --> P[terraform plan]
  S[State<br/>what Terraform manages] --> P
  R[Real infrastructure<br/>via provider APIs] --> P
  P --> D[Diff: create, update,<br/>replace, destroy]
  D --> A[terraform apply]
  A --> R
  A --> S

Three inputs feed every plan: your configuration, the state file (Terraform’s record of what it created), and the real resources read through the provider. The plan is the difference between them. Nothing changes until you apply it.

Command What it does
terraform init Downloads providers and modules, configures the backend
terraform fmt Formats files to the standard style
terraform validate Checks syntax and references
terraform plan Shows what would change
terraform apply Makes the changes
terraform destroy Removes everything in the state

A sensible project layout

infra/
  modules/
    network/
      main.tf
      variables.tf
      outputs.tf
  envs/
    dev/
      main.tf
      backend.tf
      dev.tfvars
    prod/
      main.tf
      backend.tf
      prod.tfvars

Reusable building blocks live in modules/. Each environment is a small root module that calls them with its own values and has its own state, so a change to dev can never touch prod.

Providers and versions

# envs/dev/main.tf
terraform {
  required_version = ">= 1.10, < 2.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.region
  default_tags {
    tags = {
      Project   = "shop"
      Env       = var.env
      ManagedBy = "terraform"
    }
  }
}
  • ~> 6.0 allows any 6.x release but not 7.0, which may contain breaking changes.
  • default_tags tags every resource the provider creates, which makes cost reports and clean-up far easier.
  • terraform init writes .terraform.lock.hcl with exact provider versions and checksums. Commit it, so everyone and every pipeline uses the same build.

Variables, locals and outputs

# variables.tf
variable "env" {
  type        = string
  description = "Environment name"
  validation {
    condition     = contains(["dev", "stage", "prod"], var.env)
    error_message = "env must be dev, stage or prod."
  }
}

variable "region" {
  type    = string
  default = "ap-south-1"
}

variable "vpc_cidr" {
  type = string
}

variable "db_password" {
  type      = string
  sensitive = true
}
# locals: computed values, defined once
locals {
  name_prefix = "shop-${var.env}"
  azs         = slice(data.aws_availability_zones.available.names, 0, 3)
}
# outputs.tf
output "vpc_id" {
  value       = module.network.vpc_id
  description = "ID of the environment VPC"
}

Values come from a .tfvars file per environment:

# dev.tfvars
env      = "dev"
vpc_cidr = "10.41.0.0/16"
terraform plan -var-file=dev.tfvars

Secrets such as db_password should not be in .tfvars in Git. Pass them as environment variables (TF_VAR_db_password) from your CI secret store, or read them from Secrets Manager with a data source. Marking a variable sensitive hides it in plan output, but it can still be stored in state, which is why state must be protected.

Data sources

A data source reads something that already exists instead of creating it:

data "aws_availability_zones" "available" {
  state = "available"
}

data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]   # Canonical
  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
  }
}

Repeating resources with for_each

Use for_each with a map. Each instance is keyed by name, so removing one entry removes only that resource. With count, removing an item from the middle of a list shifts every index and causes needless replacements.

# modules/network/main.tf
resource "aws_vpc" "this" {
  cidr_block           = var.cidr
  enable_dns_hostnames = true
  tags                 = { Name = "${var.name}-vpc" }
}

locals {
  private_subnets = {
    for i, az in var.azs : az => cidrsubnet(var.cidr, 4, i)
  }
  public_subnets = {
    for i, az in var.azs : az => cidrsubnet(var.cidr, 4, i + 8)
  }
}

resource "aws_subnet" "private" {
  for_each          = local.private_subnets
  vpc_id            = aws_vpc.this.id
  cidr_block        = each.value
  availability_zone = each.key
  tags              = { Name = "${var.name}-private-${each.key}", Tier = "private" }
}

resource "aws_subnet" "public" {
  for_each                = local.public_subnets
  vpc_id                  = aws_vpc.this.id
  cidr_block              = each.value
  availability_zone       = each.key
  map_public_ip_on_launch = true
  tags                    = { Name = "${var.name}-public-${each.key}", Tier = "public" }
}
# modules/network/variables.tf
variable "name" { type = string }
variable "cidr" { type = string }
variable "azs"  { type = list(string) }

# modules/network/outputs.tf
output "vpc_id"             { value = aws_vpc.this.id }
output "private_subnet_ids" { value = [for s in aws_subnet.private : s.id] }
output "public_subnet_ids"  { value = [for s in aws_subnet.public : s.id] }

Calling the module

# envs/dev/main.tf (continued)
module "network" {
  source = "../../modules/network"
  name   = local.name_prefix
  cidr   = var.vpc_cidr
  azs    = local.azs
}

resource "aws_security_group" "app" {
  name   = "${local.name_prefix}-app"
  vpc_id = module.network.vpc_id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = [var.vpc_cidr]
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Modules from the Terraform Registry work the same way with a version pin:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 6.0"
  # ...
}

Good module design: few inputs with sensible defaults, outputs for everything a caller may need, no provider block inside the module, and one clear purpose.

Protecting important resources

resource "aws_db_instance" "main" {
  # ...
  deletion_protection = true

  lifecycle {
    prevent_destroy = true            # plan fails if this would be destroyed
    ignore_changes  = [engine_version] # managed by AWS minor upgrades
  }
}

When you rename a resource or move it into a module, tell Terraform instead of letting it destroy and recreate:

moved {
  from = aws_s3_bucket.assets
  to   = module.storage.aws_s3_bucket.assets
}

Reading a plan

  # aws_instance.web must be replaced
-/+ resource "aws_instance" "web" {
      ~ ami = "ami-0aaa" -> "ami-0bbb" # forces replacement
Symbol Meaning
+ create
~ update in place
-/+ destroy then create (replacement)
- destroy

Read every -/+ and - line before applying. “Forces replacement” on a database or load balancer means downtime or data loss.

A note on licensing

Terraform has been under the Business Source License since August 2023, and HashiCorp is now part of IBM. For using Terraform to manage your own infrastructure this changes nothing. OpenTofu, the Linux Foundation fork, accepts the same configuration for most projects and is a valid choice if you prefer an open-source licence.

Summary

  • init, plan, apply: read the plan every time.
  • Pin Terraform and provider versions and commit the lock file.
  • One root module and one state per environment; reusable modules in modules/.
  • for_each over maps, sensitive for secrets, prevent_destroy for data.
  • moved blocks for refactors.

Next: remote state with locking, importing existing resources, testing, and a safe CI pipeline.