Terraform for Infrastructure as Code: HCL Modules, Providers, Remote State, Workspaces and a VPC Example

Key takeaways

Terraform is a declarative Infrastructure as Code (IaC) tool: you describe the desired state in HCL and providers create, update, and destroy cloud resources consistently. This post covers core concepts, HCL and modules, AWS/Azure/GCP providers, state and remote backends, workspaces and environment separation, security practices, and a practical configuration example.

At a glance

Terraform is an Infrastructure as Code (IaC) tool from HashiCorp. Infrastructure built by clicking in a console is hard to reproduce and leaves a weak audit trail. With Terraform you write down the desired end state, Terraform computes the difference between that and what it has recorded in its state, and then it makes only the changes needed to close the gap.

A note on licensing: Terraform was open source (MPL 2.0) until version 1.5. From 1.6 onward HashiCorp releases it under the Business Source License, which allows most internal use but restricts building competing products. OpenTofu is the community fork of the last MPL release and is largely compatible with the configuration shown here.

The examples assume you have the Terraform CLI installed and a cloud account with billing and permissions set up. They are written for learning, so check cost, quotas, and your organization’s security policy before applying anything to a real account.


Terraform and Infrastructure as Code

Declarative model and idempotency

Terraform is declarative. You describe what infrastructure should exist, not the sequence of API calls to create it. Applying the same configuration twice should produce no changes the second time, because the resources already match.

That property is what makes a few things possible. Code in Git can recreate the same environment. Infrastructure changes can go through pull requests and review like application code. And terraform plan shows exactly which resources will be created, changed, or destroyed before anything happens.

Core building blocks

ConceptDescription
ProviderPlugin that talks to a cloud or service API (AWS, Azure, GCP, etc.)
ResourceInfrastructure object Terraform creates and manages (VPC, VM, bucket)
Data sourceRead-only lookup of something that already exists
StateTerraform’s record of which real objects correspond to which resources
ModuleDirectory of .tf files used as a reusable unit, with variables and outputs as its interface

Terraform builds a dependency graph from the references between resources and creates independent resources in parallel (10 operations at a time by default, adjustable with -parallelism). When a dependency exists that Terraform cannot see from references, for example an IAM policy that must exist before a service can use it, declare it with depends_on.

Basic workflow

  1. Write resource and other blocks in .tf files.
  2. Run terraform init to download providers and modules and configure the backend.
  3. Run terraform plan to see what would change.
  4. Run terraform apply to make the changes.
  5. In lab environments, terraform destroy removes everything the configuration manages.

In production, teams commonly save the plan with terraform plan -out=tfplan, have someone review it, and then run terraform apply tfplan from CI. Applying a saved plan guarantees that what gets applied is exactly what was reviewed; if the state has changed in the meantime, Terraform refuses the stale plan.


HCL syntax and modules

terraform block and required_providers

Pinning versions keeps everyone on the same provider behavior. The ~> operator allows only the rightmost version component to increase.

terraform {
  required_version = ">= 1.5.0"

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

This requires Terraform 1.5 or later and allows any AWS provider 5.x release but not 6.0. terraform init also writes a .terraform.lock.hcl file recording the exact provider versions and checksums it selected; commit that file so CI and teammates install the same builds.

Variables, outputs, and locals

Input variables (variable) hold values that differ per environment, outputs (output) expose values to callers or other stacks, and locals (locals) name expressions you would otherwise repeat.

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

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

locals {
  name_prefix = "app-${var.environment}"
}

output "name_prefix" {
  value       = local.name_prefix
  description = "Resource name prefix"
}

Giving every variable a type and description, and a validation block where the allowed values are known, turns a confusing provider error during apply into a clear message during plan.

Module design

A module is just a directory of Terraform configuration. The root module calls child modules with module blocks.

module "network" {
  source = "./modules/network"

  cidr_block = "10.0.0.0/16"
  az_count   = 3
}

Modules work best when they are small and composable: a network module, a security group module, a database module, combined in the root module, rather than one module that builds an entire application. Keep the interface in variables.tf and outputs.tf, take environment-specific values such as account IDs and regions as inputs instead of hard-coding them, and when sourcing modules from Git or the Terraform Registry, pin a version or tag so an upstream change cannot alter your plan unexpectedly.


AWS, Azure, and GCP providers

Each cloud has its own resource naming rules, ID formats, tagging conventions, and IAM model. Terraform gives you one workflow, not one abstraction over all clouds, so each provider’s documentation still matters.

AWS (hashicorp/aws)

The provider finds credentials through the standard AWS chain: environment variables, the shared credentials and config files (including SSO profiles), and instance or task roles.

provider "aws" {
  region = "ap-northeast-2"

  default_tags {
    tags = {
      ManagedBy = "terraform"
    }
  }
}

resource "aws_s3_bucket" "logs" {
  bucket = "example-logs-unique-suffix"
}

S3 bucket names are globally unique across all AWS accounts, so a hard-coded name will collide sooner or later. Since provider v4, settings such as encryption, versioning, public access block, and lifecycle rules are separate resources (aws_s3_bucket_versioning, aws_s3_bucket_public_access_block, and so on) rather than arguments on aws_s3_bucket; older examples that put them inline no longer work. default_tags applies the given tags to every taggable resource the provider creates.

Azure (hashicorp/azurerm)

Azure organizes resources by tenant, subscription, and resource group. For CI, authenticate with a service principal, a managed identity, or OIDC workload identity federation rather than a personal login.

provider "azurerm" {
  features {}
  subscription_id = var.subscription_id  # required since azurerm 4.0 (or set ARM_SUBSCRIPTION_ID)
}

resource "azurerm_resource_group" "main" {
  name     = "rg-example"
  location = "Korea Central"
}

Azure resource types have different naming rules and length limits (storage account names, for instance, allow only lowercase letters and digits, 3 to 24 characters). Validating those in module variables catches the problem at plan time.

Google Cloud (hashicorp/google)

On GCP you usually need a project ID, a region or zone, and the relevant service APIs enabled before resources can be created. APIs can themselves be managed with google_project_service.

provider "google" {
  project = var.gcp_project_id
  region  = "asia-northeast3"
}

resource "google_storage_bucket" "assets" {
  name                        = "${var.gcp_project_id}-assets"
  location                    = "ASIA-NORTHEAST3"
  uniform_bucket_level_access = true
  force_destroy               = false
}

Like S3, GCS bucket names are globally unique, which is why prefixing with the project ID is common. Organization policies and VPC Service Controls can block resources that Terraform’s plan considers valid, so a successful plan does not guarantee a successful apply on GCP.

Multi-cloud in practice

The HCL syntax is the same everywhere, but the resources are not. A “VPC” on GCP is global with regional subnets, while on AWS it is regional; load balancers and firewalls differ even more. Teams that run several clouds usually write separate modules per cloud and document the equivalences, rather than building one module that pretends the clouds are the same.


State management and backends

Why state exists

A configuration file says “there should be a bucket called logs”, but it does not know which real bucket that is. State records that mapping from each resource address to a real object ID, along with the attributes Terraform last saw. On each plan, Terraform refreshes those attributes from the cloud APIs and compares them with the configuration. If the state is lost, Terraform no longer knows the resources exist and will try to create them again; if it is out of date, plans can propose wrong changes.

Remote backends and locking

A local terraform.tfstate file is fine for solo experiments. As soon as more than one person or a CI system runs Terraform, move the state to a remote backend that supports locking.

Without locking, two terraform apply runs at the same time can both read the same state, make overlapping changes, and then one write overwrites the other, leaving state that no longer matches the real infrastructure. With locking, the second run waits or fails until the first releases the lock. If a crashed run leaves a stale lock behind, terraform force-unlock <LOCK_ID> removes it, but only after you have confirmed that no other apply is actually still running.

BackendLocking mechanismNotes
AWS S3S3 native lockfile (use_lockfile), or a DynamoDB tableDynamoDB locking is deprecated as of Terraform 1.11
Azure Blob StorageBlob leasesBuilt in, no extra resource
Google Cloud StorageBuilt inGrant IAM on the bucket carefully
HCP Terraform (formerly Terraform Cloud)Managed by the serviceAdds remote runs, RBAC, policy checks

An S3 backend using the native lockfile:

terraform {
  backend "s3" {
    bucket       = "my-org-terraform-state"
    key          = "prod/network/terraform.tfstate"
    region       = "ap-northeast-2"
    encrypt      = true
    use_lockfile = true  # Terraform 1.10+; older setups use dynamodb_table instead
  }
}

Give each stack its own key. Turn on bucket versioning so a corrupted or accidentally overwritten state can be recovered, and restrict read access: anyone who can read the state can read every value stored in it.

Splitting state, and importing existing resources

One large state makes every plan slower, because Terraform refreshes every resource, and makes every apply riskier, because one mistake can touch anything. Splitting state by domain (network, data, applications) or by team keeps each plan small. Stacks can read each other’s outputs with the terraform_remote_state data source, though many teams prefer looking resources up directly with data sources or a parameter store so that consumers do not need read access to another stack’s entire state.

Resources created by hand can be brought under management. Since Terraform 1.5, the declarative import block lets you do this in a plan, and terraform plan -generate-config-out=generated.tf can draft the matching resource blocks:

import {
  to = aws_s3_bucket.logs
  id = "example-logs-unique-suffix"
}

The generated code is a starting point; review it and clean it up before committing, because it includes every attribute, including defaults you would normally leave out.


Workspaces and environment separation

Workspaces

Workspaces keep multiple states for the same configuration and the same backend. terraform workspace new staging creates one, terraform workspace select prod switches, and terraform.workspace can be used in names.

They are quick to set up when environments really are identical apart from a few variables. The weaknesses show up in operations: every workspace shares the same backend and therefore the same credentials, the currently selected workspace is easy to forget, and it is entirely possible to run apply in prod when you meant dev. HashiCorp’s own documentation says workspaces are not suitable when environments need separate credentials or access controls.

Directories and pipelines

Many teams instead create a root module per environment, such as env/dev and env/prod, each with its own backend configuration, variable values, and credentials, all calling the same shared modules. The pipeline can then require approval for env/prod and allow applies there only from the main branch. The cost is some duplicated wiring in each root module; tools like Terragrunt exist mainly to reduce that duplication.

Secrets

Do not put passwords or API keys in .tf or committed .tfvars files. Pass them through environment variables (TF_VAR_name), CI secrets, or read them at plan time from a secret manager (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager).

Marking a variable or output sensitive = true only hides it from CLI output; the value is still written to state in plain text. If a resource needs a secret, either let the cloud service generate and store it (for example RDS-managed master passwords via manage_master_user_password), or, on Terraform 1.10 and later, use ephemeral values and write-only arguments where the provider supports them, which are designed to keep the value out of state and plan files.


Security practices

Least privilege and separation of duties

Give the CI identity only the permissions the stack needs, and prefer short-lived credentials through OIDC federation (GitHub Actions to AWS, Azure, or GCP) over long-lived access keys stored as secrets. If developers apply to production with personal credentials, the audit log shows who ran a command but not which reviewed change it corresponded to.

State

State files can contain database passwords, private keys, and connection strings. Encrypt the backend at rest, restrict read access to the people and pipelines that need it, and keep access logs. Add *.tfstate, *.tfstate.backup, and .terraform/ to .gitignore, and keep secret-bearing .tfvars files out of the repository, ideally with a pre-commit secret scanner as a backstop.

Supply chain

Prefer official and verified providers and well-maintained modules. Pin module sources to a tag or commit, pin provider versions, and commit .terraform.lock.hcl so provider checksums are verified on every init.

Policy as code

At larger scale, policies such as “no public S3 buckets” or “every resource has an owner tag” can be enforced automatically against the plan with Sentinel (HCP Terraform and Terraform Enterprise) or Open Policy Agent (for example through Conftest).


Hands-on infrastructure example

Below is a shortened AWS example for learning. A real deployment adds NAT gateways, an ALB listener and target group, TLS certificates, health checks, WAF, logging, and backups.

Target shape

  • A VPC with two public and two private subnets across two availability zones
  • An internet-facing Application Load Balancer in the public subnets
  • Application servers in the private subnets, reachable only through the load balancer

Network and ALB skeleton

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

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  tags = {
    Name = "app-vpc"
  }
}

resource "aws_subnet" "public" {
  count                   = 2
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(aws_vpc.main.cidr_block, 4, count.index)
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = true
  tags = {
    Name = "public-${count.index}"
  }
}

resource "aws_subnet" "private" {
  count             = 2
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(aws_vpc.main.cidr_block, 4, count.index + 8)
  availability_zone = data.aws_availability_zones.available.names[count.index]
  tags = {
    Name = "private-${count.index}"
  }
}

resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.main.id
}

# A subnet is only "public" if its route table sends 0.0.0.0/0 to the internet gateway
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }
}

resource "aws_route_table_association" "public" {
  count          = 2
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_security_group" "alb" {
  name        = "alb-sg"
  description = "ALB ingress"
  vpc_id      = aws_vpc.main.id

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

resource "aws_lb" "app" {
  name               = "app-alb"
  load_balancer_type = "application"
  subnets            = aws_subnet.public[*].id
  security_groups    = [aws_security_group.alb.id]

  # Nothing in the arguments references the gateway, but an internet-facing
  # ALB cannot be created in a VPC that has no internet gateway attached yet.
  depends_on = [aws_internet_gateway.igw]
}

cidrsubnet(aws_vpc.main.cidr_block, 4, n) adds 4 bits to the /16 prefix, giving /20 subnets; the public subnets take indexes 0 and 1 and the private ones 8 and 9, which leaves room to add more of each later without renumbering. count creates one subnet per availability zone.

Two details in this example are easy to get wrong. First, creating an internet gateway does not make a subnet public; the subnet needs a route table with a default route to the gateway, which is what the aws_route_table and its associations provide. Second, depends_on on the load balancer: Terraform orders resources by references, and since the ALB never references the gateway, without the explicit dependency Terraform may try to create the ALB first and AWS rejects it.

The private subnets have no outbound internet access as written. Instances there that need to download packages or call external APIs require a NAT gateway in a public subnet and a private route table pointing to it. A NAT gateway has an hourly charge plus per-GB processing, so the usual decision is between one NAT gateway (cheaper, but outbound traffic from every AZ depends on one AZ) and one per AZ (more resilient, more expensive). VPC endpoints for S3 and other AWS services can cut NAT traffic further.

The application tier would typically be aws_instance resources, an Auto Scaling group, or ECS/EKS. Whether the cluster and the applications on it belong in the same Terraform stack is mostly an organizational question about where the platform team’s responsibility ends.

The same shape on Azure or GCP

On Azure the equivalents are azurerm_virtual_network, azurerm_subnet, network security groups, and azurerm_lb or Application Gateway, all inside a resource group. On GCP they are google_compute_network, google_compute_subnetwork, firewall rules, and the set of resources that make up a load balancer (backend service, URL map, target proxy, forwarding rule). The load balancer models in particular are different enough that writing a separate module per cloud is usually simpler than abstracting over them.


For a shorter hands-on walkthrough on AWS, see Terraform on AWS in Practice: Resources, Variables, Remote State, Modules and Workspaces.