Introduction
If you're preparing for a Terraform interview or leading a team through infrastructure-as-code adoption, you'll likely encounter a set of questions that separate theoretical knowledge from hands-on production experience. Terraform interview questions: best practices explained focuses on the scenarios that matter most—state management, resource iteration patterns, multi-environment setups, and integrating existing infrastructure into your codebase. These aren't trivia questions; they're practical challenges we solve daily in real projects, and how you handle them shapes the reliability and maintainability of your infrastructure.
What is state and why does it matter?
State is Terraform's memory. It's a JSON file that maps your declared infrastructure code to real-world resources created in AWS, Azure, GCP, or any other provider. Without state, Terraform has no way to know what it previously created, what's changed, or which resources to destroy when you remove code.
State mattering fundamentally comes down to three concerns: sharing, locking, and protection.
Sharing state across your team
Local state files are fine for single-developer projects, but they break down immediately in a team environment. If two engineers apply different configuration changes locally without a shared source of truth, you'll end up with diverged state and resources that Terraform believes are destroyed when they're actually running (or vice versa). This is catastrophic.
Remote state backends—Terraform Cloud, S3 with DynamoDB, Azurerm Storage Account, or similar—store state centrally so all team members work against the same infrastructure snapshot.
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "eu-west-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
Locking state during operations
If two engineers run terraform apply simultaneously against the same remote state, corruption is nearly guaranteed. State locking prevents concurrent modifications. The DynamoDB table in the example above handles this automatically when using S3 as the backend—Terraform acquires and releases locks around each operation.
Without locking, you'll see mysterious resource deletion, duplicated resources, or apply failures that are extremely difficult to debug.
Protecting sensitive data in state
State files contain everything: database passwords, API keys, private key material, SSH keys. A developer checking a state file into version control or leaving it in an unencrypted S3 bucket has just compromised your entire infrastructure. Always encrypt state at rest, restrict access with IAM policies, and never version-control local state files.
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
count vs for_each: choosing the right iteration pattern
Both count and for_each let you create multiple resources from a single declaration, but they handle change differently—and that difference matters enormously in production.
count and address instability
With count, resources are addressed by index: aws_instance.server[0], aws_instance.server[1], etc. This is simple at first, but fragile. If you remove an item from a 5-item list, indices shift: what was [4] is now [3]. Terraform sees this as destroying the old instance and creating a new one, even though you only meant to remove one item from the middle of the list.
variable "instance_count" {
default = 3
}
resource "aws_instance" "server" {
count = var.instance_count
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
tags = {
Name = "server-${count.index}"
}
}
If you change instance_count from 3 to 2, Terraform will destroy server[2]. Realistic scenario: you're managing 5 web servers, you remove one from the list, and suddenly Terraform terminates a running instance. This hurts.
for_each and stable addresses
for_each uses a map or set of strings as keys. Resources are addressed by key: aws_instance.server["web-1"], aws_instance.server["web-2"]. Keys remain stable even when you add, remove, or reorder items. Removing one key only destroys that resource; others are unaffected.
variable "instances" {
type = map(object({
ami = string
instance_type = string
}))
default = {
"web-1" = {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
}
"web-2" = {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
}
}
}
resource "aws_instance" "server" {
for_each = var.instances
ami = each.value.ami
instance_type = each.value.instance_type
tags = {
Name = each.key
}
}
Remove "web-1" from the map, and only that instance is destroyed. Remove "web-2", add "web-3", and Terraform handles each operation correctly. This is why production Terraform code almost always prefers for_each: it's predictable and safe.
How do you manage environments?
Most teams run at least three environments: development, staging, and production. The goal is to keep configuration DRY while ensuring each environment is isolated and immutable in deployment.
Separate state files per environment
Each environment must have its own state file. This prevents a typo in development from accidentally destroying production resources. Use different backend configurations or different key paths within the same backend.
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "${var.environment}/terraform.tfstate"
region = "eu-west-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
Note: Terraform doesn't interpolate variables in backend blocks, so you'll typically configure this via backend config flags at init time:
terraform init -backend-config="key=prod/terraform.tfstate"
Separate AWS accounts per environment
Even better than state separation is account separation. Your dev infrastructure runs in one AWS account, prod in another. This provides hard isolation: even if someone maliciously (or accidentally) runs terraform destroy in the prod backend, they can only destroy resources in the prod account. Cross-account IAM policies prevent a developer's dev credentials from even touching production infrastructure.
Shared modules with environment-specific tfvars
Define your infrastructure once in reusable modules. Each environment then provides its own terraform.tfvars or .auto.tfvars file to parameterize the module:
module "vpc" {
source = "./modules/vpc"
cidr_block = var.cidr_block
enable_nat_gateway = var.enable_nat_gateway
availability_zones = var.availability_zones
environment = var.environment
}
module "rds" {
source = "./modules/rds"
instance_class = var.db_instance_class
allocated_storage = var.db_allocated_storage
multi_az = var.db_multi_az
environment = var.environment
}
Environment-specific vars file (prod.tfvars):
cidr_block = "10.0.0.0/16"
enable_nat_gateway = true
availability_zones = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
environment = "prod"
db_instance_class = "db.r5.xlarge"
db_allocated_storage = 100
db_multi_az = true
Dev vars file (dev.tfvars):
cidr_block = "10.10.0.0/16"
enable_nat_gateway = false
availability_zones = ["eu-west-1a"]
environment = "dev"
db_instance_class = "db.t3.small"
db_allocated_storage = 20
db_multi_az = false
This pattern scales elegantly. You define infrastructure once; each environment gets the compute, storage, and redundancy it actually needs.
How do you import existing resources?
Greenfield infrastructure is rare. Most teams inherit legacy resources created via console clicks, CloudFormation, Terraform versions without import support, or other tools entirely. terraform import brings existing resources into your state file and code.
Basic import workflow
First, write a resource stub in your Terraform code—just enough for Terraform to know what you're importing:
resource "aws_security_group" "legacy_app" {
# Empty for now; import will populate state
}
Then run import with the resource's provider-specific ID. For an AWS security group, it's the group ID:
terraform import aws_security_group.legacy_app sg-0a1b2c3d4e5f6g7h8
Terraform queries AWS, fetches the security group's current configuration, and stores it in state. Now you run terraform plan to see what's missing from your code:
Terraform will perform the following actions:
# aws_security_group.legacy_app will be updated in-place
~ resource "aws_security_group" "legacy_app" {
~ description = "legacy-sg" -> (known after apply)
+ egress = [
+ {
cidr_blocks = ["0.0.0.0/0"]
description = null
from_port = 0
ipv6_cidr_blocks = []
prefix_list_ids = []
protocol = "-1"
security_groups = []
to_self = false
to_port = 0
},
]
+ ingress = [
+ {
cidr_blocks = ["10.0.0.0/8"]
description = "internal-ssh"
from_port = 22
ipv6_cidr_blocks = []
prefix_list_ids = []
protocol = "tcp"
security_groups = []
to_self = false
to_port = 22
},
]
~ name = "default" -> (known after apply)
~ tags = {} -> (known after apply)
}
The output tells you exactly what your code is missing. Fill in those details:
resource "aws_security_group" "legacy_app" {
name = "legacy-app-sg"
description = "legacy-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
description = "internal-ssh"
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Environment = "prod"
ManagedBy = "terraform"
}
}
Refactor as you go
Once imported, resist the urge to leave code as-is. Extract common patterns into modules, consolidate repeated blocks, and align with your team's naming conventions. Imported resources are just like any others—they should be treated as first-class citizens in your codebase, not legacy baggage.
If you're importing dozens of security groups with similar rules, create a module:
module "legacy_security_groups" {
source = "./modules/security_group"
for_each = var.legacy_groups
name = each.value.name
description = each.value.description
vpc_id = aws_vpc.main.id
ingress = each.value.ingress_rules
}
Then import each group and move its code into the module call. This prevents long-term technical debt and keeps your infrastructure maintainable.
Common mistakes
- Checking state files into version control: Always add
*.tfstate*to.gitignore. State is infrastructure data, not code. Version control is for code. - Using count for resources where order matters: Shifting indices cause unnecessary destruction and recreation. Use for_each unless count is truly the right tool (e.g., fixed, never-changing counts like "always create 3 replicas").
- Hardcoding environment-specific values: Your production module shouldn't specify
instance_type = "t2.xlarge"inline. Parameterize everything so the same module works for all environments. - Importing without refactoring: Imported resources with manual configuration are tech debt. Spend the time to align them with your codebase standards and modular structure.
- Neglecting state backup and disaster recovery: Enable S3 versioning, set up backup policies, and test state restoration. A corrupted or deleted state file can take down your infrastructure.
- Sharing production credentials in backend configs: Use IAM roles, assume roles across accounts, or Terraform Cloud's native authentication. Never paste AWS keys into backend configs.
Key takeaways
- State is Terraform's source of truth for real infrastructure. Protect it: use remote backends, enable locking, encrypt at rest, and restrict access with IAM.
- for_each is safer than count in production because resource addresses remain stable when items change, avoiding unintended destruction.
- Manage multiple environments with separate state files (or accounts), shared modules, and environment-specific tfvars files. Keep configuration DRY and environment isolation hard.
- Import existing resources with stubs and the import command, then refactor imported code to align with your team's standards and modular architecture.
- These practices aren't optional—they're the difference between stable, team-friendly infrastructure and chaos that costs time and money to recover from.






Comments
Loading comments…