Terraform State Management: Remote State, State Locking, Workspaces, and Modular IaC Best Practices
Terraform state is the source of truth for your infrastructure. Mismanaged state leads to configuration drift, resource duplication, and deployment conflicts. This guide covers production-grade state management patterns, from configuring remote backends to organizing complex infrastructure with reusable modules.
Why State Management Matters
Terraform state tracks the mapping between your configuration and the real-world resources it manages. Without proper state management:
- Team members overwrite each other's changes
- State gets corrupted during interrupted applies
- Sensitive values persist in plain text locally
- Multiple environments share state causing cross-contamination
Remote State Backends
S3 Backend with DynamoDB Locking
# backend.tf
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "production/networking/terraform.tfstate"
region = "us-east-1"
encrypt = true
kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/your-key-id"
# DynamoDB table for state locking
dynamodb_table = "terraform-state-locks"
}
}
Create the S3 bucket and DynamoDB table using a bootstrap configuration:
# bootstrap/main.tf
resource "aws_s3_bucket" "terraform_state" {
bucket = "my-terraform-state"
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_versioning" "state_versioning" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "state_encryption" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}
resource "aws_dynamodb_table" "state_locks" {
name = "terraform-state-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
GCS Backend (Google Cloud)
terraform {
backend "gcs" {
bucket = "my-terraform-state"
prefix = "production/networking"
}
}
GCS provides built-in object versioning and uses Cloud Storage's native locking mechanism.
State Locking Deep Dive
State locking prevents concurrent operations from corrupting state.
Manual Lock/Unlock
# Force-unlock a stuck lock (use with caution)
terraform force-unlock LOCK_ID
# Show current state
terraform state list
# Show specific resource state
terraform state show aws_instance.web_server
Lock Timeout Configuration
terraform {
backend "s3" {
# ... bucket config ...
dynamodb_table = "terraform-state-locks"
}
}
# Apply with custom lock timeout
terraform apply -lock-timeout=300s
Workspaces for Environment Isolation
Workspaces allow a single Terraform configuration to manage multiple environments with separate state files.
Basic Workspace Operations
# Create workspaces
terraform workspace new staging
terraform workspace new production
# Switch workspace
terraform workspace select production
# List workspaces
terraform workspace list
# Show current workspace
terraform workspace show
Workspace-Aware Configuration
locals {
environment = terraform.workspace
instance_type = {
staging = "t3.micro"
production = "t3.xlarge"
}
min_capacity = {
staging = 1
production = 3
}
}
resource "aws_autoscaling_group" "app" {
min_size = local.min_capacity[local.environment]
launch_template {
id = aws_launch_template.app.id
version = "$Latest"
}
}
resource "aws_instance" "bastion" {
instance_type = local.instance_type[local.environment]
tags = {
Name = "bastion-${local.environment}"
Environment = local.environment
}
}
Backend Key per Workspace
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "env:/WORKSPACE/network/terraform.tfstate"
region = "us-east-1"
}
}
Terraform substitutes WORKSPACE with the current workspace name automatically.
Modular Infrastructure
Module Structure
infrastructure/
modules/
vpc/
main.tf
variables.tf
outputs.tf
versions.tf
eks/
main.tf
variables.tf
outputs.tf
rds/
main.tf
variables.tf
outputs.tf
environments/
staging/
main.tf
terraform.tfvars
production/
main.tf
terraform.tfvars
VPC Module
# modules/vpc/variables.tf
variable "cidr_block" {
description = "VPC CIDR block"
type = string
validation {
condition = can(cidrnetmask(var.cidr_block))
error_message = "Must be a valid CIDR block."
}
}
variable "availability_zones" {
description = "List of AZs"
type = list(string)
}
variable "environment" {
type = string
}
# modules/vpc/main.tf
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.environment}-vpc"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_subnet" "private" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.cidr_block, 4, count.index)
availability_zone = var.availability_zones[count.index]
tags = {
Name = "${var.environment}-private-${var.availability_zones[count.index]}"
Type = "private"
}
}
# modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
description = "VPC ID"
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
description = "List of private subnet IDs"
}
Using Modules
# environments/production/main.tf
module "vpc" {
source = "../../modules/vpc"
cidr_block = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
environment = "production"
}
module "eks" {
source = "../../modules/eks"
cluster_name = "production-cluster"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
node_count = 3
node_type = "m5.xlarge"
}
module "rds" {
source = "../../modules/rds"
identifier = "production-db"
engine = "postgres"
engine_version = "16.2"
instance_class = "db.r6g.xlarge"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
}
Remote State Data Sources
Share state between configurations:
# Read VPC outputs from another state file
data "terraform_remote_state" "networking" {
backend = "s3"
config = {
bucket = "my-terraform-state"
key = "production/networking/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.networking.outputs.private_subnet_ids[0]
}
State Migration
Importing Existing Resources
# Import an existing EC2 instance
terraform import aws_instance.web i-1234567890abcdef0
# Import with resource address in module
terraform import module.eks.aws_eks_cluster.main my-cluster
Moving Resources in State
# Move resource to new address (no destroy/recreate)
terraform state mv aws_instance.old_name aws_instance.new_name
# Move resource into a module
terraform state mv aws_security_group.app module.app.aws_security_group.main
Removing Resources from State
# Remove without destroying (manage outside Terraform)
terraform state rm aws_instance.legacy
Sensitive Values in State
variable "db_password" {
type = string
sensitive = true
}
output "db_connection_string" {
value = "postgresql://admin:${var.db_password}@${aws_db_instance.main.endpoint}/app"
sensitive = true # prevents printing in console output
}
Even with sensitive = true, values are stored in plaintext in state. Always use encrypted remote backends.
Drift Detection
# Check for drift without applying
terraform plan -detailed-exitcode
# Exit code 0: no changes
# Exit code 1: error
# Exit code 2: changes present
# Refresh state to match reality
terraform refresh
Integrate into CI:
- name: Detect drift
run: |
terraform init
terraform plan -detailed-exitcode
continue-on-error: false
Conclusion
Terraform state management is the foundation of reliable infrastructure as code. Remote backends with locking prevent race conditions and data loss. Workspaces provide clean environment separation. Modular configurations enable code reuse across projects and teams. Remote state data sources allow loose coupling between infrastructure components. With these patterns, your IaC scales from a single developer to an entire platform engineering team without friction.