Loading content...
Here's a paradox that trips up every Terraform beginner: If Terraform is declarative—meaning you describe what you want and Terraform figures out how to get there—how does Terraform know what already exists?
You can't just compare configuration to reality because:
The answer to this paradox is state—a persistent record of what Terraform manages, including resource IDs, attribute values, and metadata. State is what enables Terraform's declarative magic.
Unfortunately, state is also where most Terraform disasters originate. Lost state means orphaned resources. Corrupted state means inconsistent infrastructure. Conflicting state means production outages. Mastering state management is essential for production Terraform usage.
By the end of this page, you will understand what state contains and why it exists, how to configure remote backends for team collaboration, how locking prevents concurrent modification conflicts, workspaces for managing multiple environments, and critical state commands for recovery and refactoring scenarios.
Terraform state is a JSON file that contains the mapping between your Terraform configuration and the actual resources in the real world. Without state, Terraform would have no way to know:
By default, Terraform stores state in a local file called terraform.tfstate. For team environments, state is stored remotely in a backend like S3, Azure Blob, or Terraform Cloud.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
{ "version": 4, "terraform_version": "1.6.0", "serial": 42, "lineage": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "outputs": { "vpc_id": { "value": "vpc-0abc123def456789", "type": "string" }, "public_subnet_ids": { "value": [ "subnet-0111111111111111", "subnet-0222222222222222" ], "type": ["tuple", ["string", "string"]] } }, "resources": [ { "mode": "managed", "type": "aws_vpc", "name": "main", "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", "instances": [ { "schema_version": 1, "attributes": { "id": "vpc-0abc123def456789", "arn": "arn:aws:ec2:us-west-2:123456789012:vpc/vpc-0abc123def456789", "cidr_block": "10.0.0.0/16", "enable_dns_hostnames": true, "enable_dns_support": true, "instance_tenancy": "default", "main_route_table_id": "rtb-0111111111111111", "owner_id": "123456789012", "tags": { "Name": "main-vpc", "Environment": "production" }, "tags_all": { "Name": "main-vpc", "Environment": "production", "ManagedBy": "terraform" } }, "sensitive_attributes": [], "private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==", "dependencies": [] } ] }, { "mode": "managed", "type": "aws_subnet", "name": "public", "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", "instances": [ { "index_key": 0, "schema_version": 1, "attributes": { "id": "subnet-0111111111111111", "arn": "arn:aws:ec2:us-west-2:123456789012:subnet/subnet-0111111111111111", "availability_zone": "us-west-2a", "cidr_block": "10.0.1.0/24", "vpc_id": "vpc-0abc123def456789", "map_public_ip_on_launch": true }, "dependencies": [ "aws_vpc.main" ] } ] } ], "check_results": null}State files often contain sensitive data: database passwords, API keys, private keys. Even if you use sensitive variables, Terraform must store the actual values to manage resources. NEVER commit state files to version control. Always use encrypted remote backends with strict access controls.
For production use, local state files are inadequate. You need remote backends that provide:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
# ==============================================================================# AWS S3 BACKEND (Most Popular)# ============================================================================== terraform { backend "s3" { # State file location bucket = "my-terraform-state-bucket" key = "environments/production/terraform.tfstate" region = "us-west-2" # Encryption at rest encrypt = true # Optional: Use a specific KMS key for encryption kms_key_id = "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012" # State locking via DynamoDB (HIGHLY RECOMMENDED) dynamodb_table = "terraform-state-locks" # Authentication (use IAM roles, not access keys) # role_arn = "arn:aws:iam::123456789012:role/TerraformStateRole" }} # Required: Create the DynamoDB table for locking# (This is a chicken-and-egg problem - create manually or in a bootstrap config)/*resource "aws_dynamodb_table" "terraform_locks" { name = "terraform-state-locks" billing_mode = "PAY_PER_REQUEST" hash_key = "LockID" attribute { name = "LockID" type = "S" } tags = { Purpose = "Terraform state locking" }}*/ # ==============================================================================# AZURE BLOB STORAGE BACKEND# ============================================================================== terraform { backend "azurerm" { resource_group_name = "terraform-state-rg" storage_account_name = "terraformstatestorage" container_name = "tfstate" key = "environments/production.tfstate" # Use Azure AD authentication (recommended over storage keys) use_azuread_auth = true }} # ==============================================================================# GOOGLE CLOUD STORAGE BACKEND# ============================================================================== terraform { backend "gcs" { bucket = "my-terraform-state" prefix = "terraform/production" # Optional: Use customer-managed encryption key # encryption_key = "projects/project/locations/us/keyRings/ring/cryptoKeys/key" }} # ==============================================================================# TERRAFORM CLOUD / ENTERPRISE BACKEND# ============================================================================== terraform { cloud { organization = "my-organization" workspaces { name = "production-infrastructure" # Or use prefix for multiple workspaces: # tags = ["production", "infrastructure"] } }} # ==============================================================================# HTTP BACKEND (For Custom Solutions)# ============================================================================== terraform { backend "http" { address = "https://terraform.example.com/state/project" lock_address = "https://terraform.example.com/state/project/lock" unlock_address = "https://terraform.example.com/state/project/lock" # Authentication username = "terraform" # password should be set via TF_HTTP_PASSWORD env var }}| Backend | Locking | Encryption | Versioning | Best For |
|---|---|---|---|---|
| S3 + DynamoDB | Yes (DynamoDB) | Yes (SSE/KMS) | Yes (S3 versioning) | AWS-centric organizations |
| Azure Blob | Yes (blob lease) | Yes (Azure encryption) | Yes (blob versioning) | Azure-centric organizations |
| GCS | Yes (built-in) | Yes (Cloud KMS) | Yes (object versioning) | GCP-centric organizations |
| Terraform Cloud | Yes (built-in) | Yes (built-in) | Yes (built-in) | Multi-cloud, enterprise features |
| Local | No | No | No | Development only |
You face a chicken-and-egg problem: you need S3/DynamoDB to store state, but you want Terraform to create them. Solution: Use a separate 'bootstrap' configuration with local state (stored securely) that creates only the backend resources. Then use remote backends for all other configurations.
What happens when two team members run terraform apply at the same time? Without locking, both read the same state, both calculate plans, and then both try to write—corrupting state and potentially creating duplicate resources or leaving the infrastructure in an inconsistent state.
State locking is a mechanism where Terraform acquires an exclusive lock before any state-modifying operation and releases it after completion.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
# STATE LOCKING WORKFLOW ┌──────────────────────────────────────────────────────────────────────────────┐│ WITHOUT STATE LOCKING │├──────────────────────────────────────────────────────────────────────────────┤│ ││ User A User B ││ ─────── ─────── ││ terraform plan terraform plan ││ (reads state v1) (reads state v1) ││ │ │ ││ ▼ ▼ ││ terraform apply terraform apply ││ (creates resource X) (creates resource X... AGAIN!) ││ │ │ ││ ▼ ▼ ││ writes state v2 writes state v2 (overwrites!) ││ (contains X) (contains different X) ││ ││ RESULT: Duplicate resources, corrupted state, chaos ││ │└──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐│ WITH STATE LOCKING │├──────────────────────────────────────────────────────────────────────────────┤│ ││ User A User B ││ ─────── ─────── ││ terraform apply terraform apply ││ │ │ ││ ▼ │ ││ acquires lock ✓ │ ││ │ ▼ ││ │ tries to acquire lock ││ │ BLOCKED - lock held by User A ││ │ │ ││ creates resource X │ (waiting...) ││ writes state v2 │ ││ releases lock │ ││ │ ▼ ││ │ acquires lock ✓ ││ │ reads state v2 (sees X) ││ │ (no duplicate created) ││ │ writes state v3 ││ │ releases lock ││ │ ││ RESULT: Consistent state, no conflicts ││ │└──────────────────────────────────────────────────────────────────────────────┘1234567891011121314151617181920212223
# State locking happens automatically, but you can control it: # Disable locking (DANGEROUS - only for recovery scenarios)terraform apply -lock=false # Set a custom lock timeout (default: 0, which means wait forever)terraform apply -lock-timeout=5m # Force unlock a stuck lock (requires lock ID)terraform force-unlock LOCK_ID # The lock ID is shown when a lock is stuck:# Error: Error acquiring the state lock# Lock Info:# ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890# Path: my-bucket/path/terraform.tfstate# Operation: OperationTypeApply# Who: user@hostname# Version: 1.6.0# Created: 2024-01-15 10:30:00.000000 +0000 UTC # To unlock:terraform force-unlock a1b2c3d4-e5f6-7890-abcd-ef1234567890Only use force-unlock when you're certain no other process is actually running. A lock might be stuck because Terraform crashed, but it might also be held by a legitimate process you can't see. Force-unlocking during an active apply can corrupt your state.
Terraform workspaces allow multiple distinct state files to be associated with a single configuration. This enables managing multiple environments (dev, staging, production) from the same codebase while keeping their state completely isolated.
123456789101112131415161718192021222324252627282930
# WORKSPACE MANAGEMENT COMMANDS # List all workspaces (asterisk marks current)$ terraform workspace list* default development staging production # Create a new workspace$ terraform workspace new stagingCreated and switched to workspace "staging"! # Switch to an existing workspace$ terraform workspace select productionSwitched to workspace "production". # Show current workspace$ terraform workspace showproduction # Delete a workspace (must switch away first)$ terraform workspace select default$ terraform workspace delete staging # Workspaces in remote backends:# S3: state stored at different keys# default: my-bucket/path/terraform.tfstate# staging: my-bucket/path/env:/staging/terraform.tfstate# production: my-bucket/path/env:/production/terraform.tfstate123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
# ==============================================================================# USING WORKSPACES IN CONFIGURATION# ============================================================================== # The current workspace name is available as: terraform.workspace locals { environment = terraform.workspace # Environment-specific configuration config = { development = { instance_type = "t3.micro" instance_count = 1 enable_monitoring = false domain = "dev.example.com" } staging = { instance_type = "t3.small" instance_count = 2 enable_monitoring = true domain = "staging.example.com" } production = { instance_type = "t3.large" instance_count = 4 enable_monitoring = true domain = "example.com" } } # Get config for current workspace (with fallback to development) current_config = lookup(local.config, local.environment, local.config.development)} resource "aws_instance" "app" { count = local.current_config.instance_count ami = data.aws_ami.ubuntu.id instance_type = local.current_config.instance_type # Enable monitoring only in configured environments monitoring = local.current_config.enable_monitoring tags = { Name = "app-${local.environment}-${count.index}" Environment = local.environment }} resource "aws_route53_record" "app" { zone_id = data.aws_route53_zone.main.zone_id name = local.current_config.domain type = "A" alias { name = aws_lb.app.dns_name zone_id = aws_lb.app.zone_id evaluate_target_health = true }} # ==============================================================================# WORKSPACE VALIDATION# ============================================================================== # Prevent accidental default workspace usageresource "null_resource" "workspace_check" { count = terraform.workspace == "default" ? "ERROR: Do not use the default workspace" : 0} # Or use validation in a variablevariable "environment" { type = string default = "" validation { condition = var.environment == "" ? terraform.workspace != "default" : true error_message = "Either set the environment variable or use a non-default workspace." }}Many teams prefer separate directories (environments/dev, environments/prod) over workspaces because: (1) you can have different module versions per env, (2) Git diff clearly shows what's changing in each env, (3) CI/CD pipelines are simpler. Workspaces are best for truly identical configurations that just differ in scale.
Sometimes you need to manipulate state directly—moving resources between configurations, importing existing infrastructure, or recovering from errors. Terraform provides a comprehensive set of terraform state commands for these operations.
Warning: State manipulation is powerful but dangerous. Always back up your state before performing any state operations.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
# ==============================================================================# STATE INSPECTION COMMANDS# ============================================================================== # List all resources in state$ terraform state listaws_vpc.mainaws_subnet.public[0]aws_subnet.public[1]aws_instance.webmodule.rds.aws_db_instance.main # List resources matching a filter$ terraform state list aws_subnet.*aws_subnet.public[0]aws_subnet.public[1] # Show detailed information about a resource$ terraform state show aws_instance.web# aws_instance.web:resource "aws_instance" "web" { ami = "ami-0c55b159cbfafe1f0" arn = "arn:aws:ec2:us-west-2:123456789012:instance/i-0abc123def456789" id = "i-0abc123def456789" instance_type = "t3.micro" private_ip = "10.0.1.50" public_ip = "52.10.20.30" tags = { "Name" = "web-server" } ...} # Pull (download) state to local file$ terraform state pull > backup.tfstate # Push state from local file (DANGEROUS)$ terraform state push backup.tfstate # ==============================================================================# STATE MODIFICATION COMMANDS# ============================================================================== # Remove a resource from state (doesn't delete the actual resource!)# Use when: Resource should no longer be managed by Terraform$ terraform state rm aws_instance.legacyRemoved aws_instance.legacySuccessfully removed 1 resource instance(s). # Move a resource to a different address within the same state# Use when: Renaming a resource in config$ terraform state mv aws_instance.web aws_instance.web_serverMove "aws_instance.web" to "aws_instance.web_server"Successfully moved 1 object(s). # Move to a different state file (useful for splitting configurations)$ terraform state mv -state-out=other.tfstate aws_instance.web aws_instance.web # Replace a provider in state (useful after provider rename/migration)$ terraform state replace-provider hashicorp/aws registry.terraform.io/hashicorp/aws # ==============================================================================# IMPORT: Bringing Existing Resources Under Terraform Management# ============================================================================== # Traditional import command$ terraform import aws_instance.web i-0abc123def456789aws_instance.web: Importing from ID "i-0abc123def456789"...aws_instance.web: Import prepared! Prepared aws_instance for importaws_instance.web: Refreshing state... [id=i-0abc123def456789] Import successful! # You must write the resource block BEFORE importing# Then run import to populate state with the resource's attributes123456789101112131415161718192021222324252627282930313233343536373839404142434445
# ==============================================================================# IMPORT BLOCKS (Terraform 1.5+) - Declarative Import# ============================================================================== # Instead of running "terraform import" commands, you can use import blocks# This makes imports repeatable and reviewable via code review import { to = aws_instance.web id = "i-0abc123def456789"} import { to = aws_vpc.main id = "vpc-0abc123def456789"} import { to = aws_security_group.web id = "sg-0abc123def456789"} # The resource blocks must exist:resource "aws_instance" "web" { # Terraform will populate this after import # You'll need to fill in the actual attributes after plan/apply # to match the imported resource} resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" # Must match the imported resource} resource "aws_security_group" "web" { name = "web-sg" vpc_id = aws_vpc.main.id} # ==============================================================================# GENERATED CONFIG (Terraform 1.5+)# ==============================================================================# Terraform can generate configuration for imported resources: # Run: terraform plan -generate-config-out=generated.tf# This creates a file with resource blocks matching the imported resources| Operation | Command | Use Case |
|---|---|---|
| View state | state list, state show | Debugging, understanding current state |
| Backup state | state pull > backup.tfstate | Before risky operations |
| Rename resource | state mv old new | Refactoring config without recreation |
| Remove from management | state rm | Hand off to another config or manual management |
| Import existing | import / import block | Bring legacy resources under Terraform |
| Refresh from cloud | terraform refresh | Sync state with actual infrastructure |
| Force recreation | taint (deprecated) / -replace | Force resource to be recreated |
terraform state rm only removes the resource from Terraform's knowledge—it does NOT delete the actual infrastructure. The resource continues to exist in AWS/Azure/GCP, just unmanaged. This is useful for transitioning resources to manual management or to a different Terraform configuration.
State management mistakes are among the most common causes of Terraform disasters. Following these best practices will save you from hours of recovery work.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
{ "Version": "2012-10-17", "Statement": [ { "Sid": "AllowStateBucketAccess", "Effect": "Allow", "Action": [ "s3:GetObject", "s3:PutObject", "s3:DeleteObject" ], "Resource": "arn:aws:s3:::my-terraform-state/*", "Condition": { "StringEquals": { "aws:PrincipalTag/Team": "infrastructure" } } }, { "Sid": "AllowStateBucketList", "Effect": "Allow", "Action": [ "s3:ListBucket" ], "Resource": "arn:aws:s3:::my-terraform-state" }, { "Sid": "AllowDynamoDBLocking", "Effect": "Allow", "Action": [ "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem" ], "Resource": "arn:aws:dynamodb:us-west-2:123456789012:table/terraform-state-locks" }, { "Sid": "AllowKMSDecrypt", "Effect": "Allow", "Action": [ "kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey" ], "Resource": "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012" } ]}If state is lost but infrastructure exists, you can reconstruct state by importing every resource. This is tedious but possible. With Terraform 1.5+ and import blocks, you can script this process. This is why versioning on your state bucket is crucial—you can usually just restore a previous version instead.
Large organizations often split infrastructure across multiple Terraform configurations—networking team manages VPCs, platform team manages Kubernetes, application teams manage their services. The terraform_remote_state data source enables configurations to read outputs from other configurations.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
# ==============================================================================# CONFIGURATION A: Network Team's VPC Configuration# File: infrastructure/networking/main.tf# ============================================================================== resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" tags = { Name = "production-vpc" }} resource "aws_subnet" "public" { count = 3 vpc_id = aws_vpc.main.id cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index) availability_zone = data.aws_availability_zones.available.names[count.index] tags = { Name = "public-${count.index}" }} resource "aws_subnet" "private" { count = 3 vpc_id = aws_vpc.main.id cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index + 10) availability_zone = data.aws_availability_zones.available.names[count.index] tags = { Name = "private-${count.index}" }} # Expose values for other configurations to consumeoutput "vpc_id" { description = "VPC ID for other configurations to use" value = aws_vpc.main.id} output "public_subnet_ids" { description = "Public subnet IDs" value = aws_subnet.public[*].id} output "private_subnet_ids" { description = "Private subnet IDs" value = aws_subnet.private[*].id} output "vpc_cidr_block" { description = "VPC CIDR block" value = aws_vpc.main.cidr_block} # ==============================================================================# CONFIGURATION B: Application Team's Service Configuration# File: applications/my-service/main.tf# ============================================================================== # Read outputs from the networking configurationdata "terraform_remote_state" "networking" { backend = "s3" config = { bucket = "my-terraform-state" key = "networking/terraform.tfstate" region = "us-west-2" }} # Use the VPC and subnets from the networking configurationresource "aws_instance" "app" { ami = data.aws_ami.ubuntu.id instance_type = "t3.micro" # Reference the remote state outputs subnet_id = data.terraform_remote_state.networking.outputs.private_subnet_ids[0] vpc_security_group_ids = [aws_security_group.app.id] tags = { Name = "my-app-server" }} resource "aws_security_group" "app" { name = "my-app-sg" description = "Security group for my application" # Reference VPC from remote state vpc_id = data.terraform_remote_state.networking.outputs.vpc_id ingress { from_port = 8080 to_port = 8080 protocol = "tcp" cidr_blocks = [data.terraform_remote_state.networking.outputs.vpc_cidr_block] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] }} resource "aws_lb" "app" { name = "my-app-alb" load_balancer_type = "application" # Use public subnets from networking team subnets = data.terraform_remote_state.networking.outputs.public_subnet_ids security_groups = [aws_security_group.alb.id]}Using remote state creates a dependency between configurations. If the networking team changes their output names, all consumers break. Consider: (1) Treating outputs as stable APIs with versioning, (2) Using data sources instead where possible (aws_vpc data source), (3) Documenting which outputs are stable vs internal.
State management is the foundation of reliable Terraform operations. Let's consolidate the key points:
What's Next:
With state mastered, the next page covers Modules and Reusability—how to create reusable infrastructure components that can be shared across configurations and teams. You'll learn module structure, input/output design, versioning, and publishing modules for organizational use.
You now understand Terraform state—its purpose, storage, locking, and manipulation. This knowledge is critical for production Terraform usage and will save you from the disasters that plague teams who treat state as an afterthought.