Terraform 2.0: What Changed and How to Migrate Your Infrastructure Code
on Terraform, Infrastructure as code, Devops, Cloud, Hashicorp
Terraform 2.0: What Changed and How to Migrate Your Infrastructure Code
Terraform 2.0 is the most significant release in the tool’s history. After years of incremental improvements under the 1.x series, HashiCorp (now under IBM’s ownership and with OpenTofu splitting the community) finally shipped the breaking changes they’d been deferring. If you manage any non-trivial infrastructure, this migration will require real work — but the improvements are worth it.
Let’s walk through every significant change and exactly what you need to do.
Photo by C Dustin on Unsplash
What Drove Terraform 2.0?
Three forces pushed HashiCorp to finally make breaking changes:
- OpenTofu forked at 1.6 — having the reference implementation release 2.0 reclaims mindshare
- State management has been brittle at scale — the new state backend model addresses years of complaints
- Provider ecosystem fragmentation — Provider Protocol v6 standardizes the extension API
Breaking Changes: The Complete List
1. count and for_each Can No Longer Be Mixed
Before (Terraform 1.x):
# This worked but caused confusing behavior
resource "aws_instance" "servers" {
count = var.use_count ? var.server_count : null
for_each = var.use_count ? null : var.server_map
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.medium"
}
Terraform 2.0 (required):
# Choose ONE: count OR for_each, never conditional switching
resource "aws_instance" "servers" {
for_each = var.servers # Always use for_each for map/set
ami = "ami-0c55b159cbfafe1f0"
instance_type = each.value.instance_type
tags = {
Name = each.key
}
}
Migration: Audit every resource using count with map-like inputs. Convert to for_each.
2. Legacy backend Blocks Removed
The old terraform {} backend configuration is replaced by the new state block.
Before:
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
}
}
After:
terraform {
state {
store "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
}
# New: explicit lock configuration
lock "dynamodb" {
table = "terraform-locks"
}
}
}
The separation of state storage from locking is a major improvement — you can now use S3 for state and Redis for distributed locking, for example.
3. terraform_remote_state Data Source Deprecated
The terraform_remote_state data source is removed. Use the new state_ref resource instead.
Before:
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "my-terraform-state"
key = "network/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_id
}
After:
state_ref "network" {
store "s3" {
bucket = "my-terraform-state"
key = "network/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_instance" "app" {
subnet_id = state_ref.network.outputs.private_subnet_id
}
4. Variable Validation Is Now Enforced at Module Boundaries
In Terraform 1.x, variable validations were suggestions that could be bypassed. In 2.0, they’re hard-enforced at module call sites.
variable "environment" {
type = string
description = "Deployment environment"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
If a caller passes production instead of prod, it now fails at plan time. Update your variable definitions to be explicit.
5. Provider Inheritance Changes
Child modules no longer automatically inherit provider configurations from the root module. You must explicitly pass providers.
Before (implicit, no longer works):
# root/main.tf - provider configured here
provider "aws" {
region = "us-east-1"
}
module "vpc" {
source = "./modules/vpc"
# Provider automatically inherited
}
After (explicit):
provider "aws" {
region = "us-east-1"
alias = "primary"
}
module "vpc" {
source = "./modules/vpc"
providers = {
aws = aws.primary # Explicit pass-through
}
}
This is more verbose but eliminates a whole class of bugs where modules accidentally use the wrong provider configuration.
New Features Worth Knowing
1. Stacks (GA in 2.0)
Terraform Stacks — previously in preview — are now stable. Stacks let you deploy the same configuration to multiple targets without duplicating code.
# stack.tfstack.hcl
deployment "us-east" {
variables = {
region = "us-east-1"
environment = "prod"
}
}
deployment "eu-west" {
variables = {
region = "eu-west-1"
environment = "prod"
}
}
2. Test Framework Improvements
The terraform test framework now supports mocking providers, enabling unit testing without real cloud resources.
# tests/main.tftest.hcl
mock_provider "aws" {
mock_resource "aws_instance" {
defaults = {
id = "i-mock12345678"
private_ip = "10.0.1.100"
public_ip = "1.2.3.4"
}
}
}
run "creates_instance_with_correct_type" {
command = plan
variables {
instance_type = "t3.medium"
environment = "dev"
}
assert {
condition = aws_instance.app.instance_type == "t3.medium"
error_message = "Instance type doesn't match"
}
}
3. Native Drift Detection
# New command: check for drift without planning
terraform drift
# Output:
# DRIFT DETECTED: aws_security_group.allow_ssh
# Rule added outside Terraform: ingress port 22 from 203.0.113.0/24
#
# DRIFT DETECTED: aws_instance.web[0]
# instance_type: t3.medium → t3.large (changed outside Terraform)
Photo by Glenn Carstens-Peters on Unsplash
Migration Strategy
Step 1: Audit Your Current Code
# Install terraform-upgrade tool
brew install terraform-upgrade
# Scan for breaking changes
terraform-upgrade scan --target-version 2.0 ./
# Output shows:
# - Files with legacy backend blocks
# - Mixed count/for_each usage
# - terraform_remote_state usage
# - Implicit provider inheritance
Step 2: Migrate State Backend
# Generate new state block from existing backend
terraform-upgrade migrate-backend --input main.tf --output main.tf.new
# Verify the migration
diff main.tf main.tf.new
# Apply when ready
mv main.tf.new main.tf
terraform init -reconfigure
Step 3: Fix Count/ForEach Issues
# This will flag problematic patterns
terraform-upgrade fix --rules count-foreach ./
# Review suggested changes before accepting
terraform-upgrade fix --rules count-foreach --dry-run ./
Step 4: Update Provider Pass-Through
# Auto-fix implicit provider inheritance
terraform-upgrade fix --rules provider-inheritance ./
Step 5: Test Everything
# Run full test suite
terraform test
# Check plans look correct
terraform plan -out=tfplan
terraform show tfplan
OpenTofu Compatibility
If your organization has moved to OpenTofu (the open-source Terraform fork), the situation is nuanced:
- OpenTofu 1.x is mostly compatible with Terraform 1.x HCL
- OpenTofu 2.0 (in development) will adopt some but not all of Terraform 2.0’s changes
- The
stateblock syntax differs between the two
For teams maintaining code that needs to work with both, use feature detection:
# Compatible with both Terraform 2.x and OpenTofu 2.x
terraform {
required_version = ">= 2.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
CI/CD Updates
Update your pipeline to use Terraform 2.0:
# .github/workflows/terraform.yml
name: Terraform
on:
push:
branches: [main]
pull_request:
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "2.0.0"
- name: Terraform Init
run: terraform init
- name: Terraform Validate
run: terraform validate
- name: Terraform Test
run: terraform test
- name: Terraform Plan
run: terraform plan -out=tfplan
- name: Upload Plan
uses: actions/upload-artifact@v4
with:
name: terraform-plan
path: tfplan
Conclusion
Terraform 2.0 is a significant but manageable migration. The breaking changes — while annoying — address real pain points that have frustrated teams for years. The new state backend model alone is worth the migration effort.
Migration priority:
- 🔴 High: Backend block migration (required, state becomes inaccessible)
- 🔴 High:
terraform_remote_stateremoval (breaks cross-stack references) - 🟡 Medium: Provider inheritance (causes plan failures on first run)
- 🟡 Medium: Variable validation enforcement (may break existing modules)
- 🟢 Low: count/for_each (works in most cases, clean up for correctness)
Start with a non-production environment, use the terraform-upgrade tooling, and you’ll be through in a few days of focused work.
이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)
