Terraform Interview Questions & Answers (2026) Part 02
45+ Terraform interview questions and answers covering state management, modules, lifecycle, drift detection, and advanced scenarios — Basic to Advanced.
🟣 Terraform × ☁️ AWS — Scenario-Based Interview Guide
45+ scenario-based questions · Real-world answers · Production patterns · HCL code snippets
Answer:
Terraform is an open-source Infrastructure-as-Code (IaC) tool by HashiCorp that uses HCL (HashiCorp Configuration Language) to define and provision infrastructure across any cloud provider.
| Feature | Terraform | CloudFormation |
|---|---|---|
| Cloud support | Multi-cloud (AWS, Azure, GCP…) | AWS only |
| Language | HCL (readable, concise) | JSON/YAML (verbose) |
| State management | External state file (flexible) | Managed by AWS |
| Plan before apply | ✅ terraform plan | ❌ Change sets (limited) |
| Module ecosystem | Rich Terraform Registry | Nested stacks (complex) |
| Drift detection | ✅ terraform plan shows drift | Stack drift detection |
💡 Key difference: CloudFormation is AWS-only. Terraform is cloud-agnostic — the same patterns work on AWS, Azure, GCP, and 300+ providers simultaneously.
Answer:
The Terraform workflow has four key phases:
- Write — Define infrastructure in
.tffiles using HCL - Init — Download providers and modules
- Plan — Compute and show a diff (create/change/destroy)
- Apply — Execute the plan and update state
# Step 1: Initialize providers
terraform init
# Step 2: Validate configuration
terraform validate
# Step 3: Preview changes (SAFE — no AWS calls that modify infra)
terraform plan -out=tfplan
# Step 4: Apply the saved plan to AWS
terraform apply tfplan
# Step 5: Destroy infrastructure
terraform destroy
✅ Best practice: Always use
terraform plan -out=tfplanthenterraform apply tfplanin production — this ensures exactly what was reviewed gets applied, even if infra changes between plan and apply.
Answer:
# main.tf
# Provider configuration
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# Latest Amazon Linux 2023 AMI
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
# EC2 Instance
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
tags = {
Name = "web-server"
Environment = var.environment
ManagedBy = "terraform"
}
}
# S3 Bucket
resource "aws_s3_bucket" "app_bucket" {
bucket = "${var.project_name}-${var.environment}-assets"
tags = {
Name = "app-assets"
Environment = var.environment
}
}
resource "aws_s3_bucket_versioning" "app_bucket_versioning" {
bucket = aws_s3_bucket.app_bucket.id
versioning_configuration {
status = "Enabled"
}
}
# variables.tf
variable "aws_region" {
description = "AWS region to deploy into"
type = string
default = "us-east-1"
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "environment" {
description = "Deployment environment"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Must be dev, staging, or prod."
}
}
Answer:
Use count or for_each meta-arguments:
# count approach
resource "aws_instance" "web" {
count = 5
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
tags = {
Name = "web-server-${count.index + 1}"
}
}
# Outputs: aws_instance.web[0] … aws_instance.web[4]
# for_each approach (PREFERRED)
variable "servers" {
type = map(string)
default = {
web-1 = "t3.micro"
web-2 = "t3.micro"
api-1 = "t3.small"
}
}
resource "aws_instance" "servers" {
for_each = var.servers
ami = data.aws_ami.amazon_linux.id
instance_type = each.value
tags = { Name = each.key }
}
# Outputs: aws_instance.servers["web-1"], ["web-2"], ["api-1"]
💡 Prefer
for_eachovercount— if you remove an item from the middle of a count list, Terraform renumbers everything and may destroy/recreate resources.for_eachuses stable keys.
Answer:
# outputs.tf
output "ec2_public_ip" {
description = "Public IP of the web server"
value = aws_instance.web.public_ip
}
output "s3_bucket_name" {
description = "Name of the S3 asset bucket"
value = aws_s3_bucket.app_bucket.bucket
}
output "db_password" {
description = "RDS password"
value = aws_db_instance.rds.password
sensitive = true # Hides from CLI output
}
# View all outputs
terraform output
# Get specific output as raw string (useful in scripts)
terraform output -raw ec2_public_ip
# Get outputs as JSON (for CI/CD pipelines)
terraform output -json
Answer:
Use terraform import to import existing AWS resources into Terraform state:
# 1. Write the resource block in your .tf file first
# 2. Import the existing resource into state
terraform import aws_db_instance.mydb my-production-db
terraform import aws_s3_bucket.existing my-existing-bucket-name
terraform import aws_instance.web i-0abcd1234efgh5678
terraform import aws_vpc.main vpc-0a1b2c3d4e5f67890
⚠️ After importing, run
terraform plan— if it shows changes, your HCL configuration doesn’t exactly match the live resource. Adjust until plan shows “No changes.”
Terraform 1.5+ declarative import block:
import {
to = aws_db_instance.mydb
id = "my-production-db"
}
Answer:
# Launch Template
resource "aws_launch_template" "web" {
name_prefix = "web-lt-"
image_id = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
network_interfaces {
associate_public_ip_address = false
security_groups = [aws_security_group.web.id]
}
user_data = base64encode(<<-EOF
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
EOF
)
}
# Auto Scaling Group
resource "aws_autoscaling_group" "web" {
desired_capacity = 2
min_size = 2
max_size = 6
vpc_zone_identifier = aws_subnet.private[*].id
target_group_arns = [aws_lb_target_group.web.arn]
launch_template {
id = aws_launch_template.web.id
version = "$Latest"
}
tag {
key = "Name"
value = "web-asg"
propagate_at_launch = true
}
}
# Application Load Balancer
resource "aws_lb" "web" {
name = "web-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = aws_subnet.public[*].id
}
resource "aws_lb_target_group" "web" {
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
path = "/health"
healthy_threshold = 2
unhealthy_threshold = 3
}
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.web.arn
port = "443"
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = aws_acm_certificate.cert.arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.web.arn
}
}
Answer:
# Fetch password from AWS Secrets Manager (never hardcode!)
data "aws_secretsmanager_secret_version" "db" {
secret_id = "prod/rds/password"
}
resource "aws_db_subnet_group" "main" {
name = "main-db-subnet-group"
subnet_ids = aws_subnet.private[*].id
}
# Primary RDS instance
resource "aws_db_instance" "primary" {
identifier = "prod-postgres"
engine = "postgres"
engine_version = "15.4"
instance_class = "db.t3.medium"
allocated_storage = 100
storage_encrypted = true
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
username = "dbadmin"
password = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)["password"]
multi_az = true
backup_retention_period = 7
deletion_protection = true
skip_final_snapshot = false
final_snapshot_identifier = "prod-postgres-final"
}
# Read Replica
resource "aws_db_instance" "replica" {
identifier = "prod-postgres-replica"
replicate_source_db = aws_db_instance.primary.identifier
instance_class = "db.t3.small"
publicly_accessible = false
}
🚨 Never store database passwords in
terraform.tfvarsor hardcode them in HCL. Terraform state stores values in plaintext — always enable encryption on your S3 state backend.
Answer:
# IAM Role for Lambda
resource "aws_iam_role" "lambda_exec" {
name = "lambda-exec-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy" "lambda_dynamo" {
role = aws_iam_role.lambda_exec.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:Scan"]
Resource = aws_dynamodb_table.main.arn
}]
})
}
# Lambda Function
resource "aws_lambda_function" "api" {
filename = "lambda.zip"
function_name = "my-api-handler"
role = aws_iam_role.lambda_exec.arn
handler = "index.handler"
runtime = "nodejs20.x"
timeout = 30
memory_size = 256
source_code_hash = filebase64sha256("lambda.zip")
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.main.name
}
}
}
# API Gateway v2 (HTTP API)
resource "aws_apigatewayv2_api" "main" {
name = "my-api"
protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
api_id = aws_apigatewayv2_api.main.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.api.invoke_arn
payload_format_version = "2.0"
}
resource "aws_lambda_permission" "apigw" {
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.api.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.main.execution_arn}/*/*"
}
Answer:
# EKS Cluster IAM Role
resource "aws_iam_role" "eks_cluster" {
name = "eks-cluster-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "eks.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "eks_cluster_policy" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
role = aws_iam_role.eks_cluster.name
}
# EKS Cluster
resource "aws_eks_cluster" "main" {
name = "prod-eks"
role_arn = aws_iam_role.eks_cluster.arn
version = "1.29"
vpc_config {
subnet_ids = aws_subnet.private[*].id
endpoint_private_access = true
endpoint_public_access = false
}
depends_on = [aws_iam_role_policy_attachment.eks_cluster_policy]
}
# Node Group IAM Role
resource "aws_iam_role" "eks_nodes" {
name = "eks-node-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
# Managed Node Group
resource "aws_eks_node_group" "main" {
cluster_name = aws_eks_cluster.main.name
node_group_name = "main-ng"
node_role_arn = aws_iam_role.eks_nodes.arn
subnet_ids = aws_subnet.private[*].id
instance_types = ["t3.medium"]
scaling_config {
desired_size = 3
min_size = 2
max_size = 10
}
update_config {
max_unavailable = 1
}
}
Answer:
resource "aws_s3_bucket" "website" {
bucket = "my-static-website-bucket"
}
resource "aws_cloudfront_origin_access_control" "oac" {
name = "s3-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_cloudfront_distribution" "website" {
enabled = true
default_root_object = "index.html"
price_class = "PriceClass_100"
origin {
domain_name = aws_s3_bucket.website.bucket_regional_domain_name
origin_id = "S3Origin"
origin_access_control_id = aws_cloudfront_origin_access_control.oac.id
}
default_cache_behavior {
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "S3Origin"
viewer_protocol_policy = "redirect-to-https"
compress = true
forwarded_values {
query_string = false
cookies { forward = "none" }
}
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html" # SPA routing
}
restrictions {
geo_restriction { restriction_type = "none" }
}
viewer_certificate {
acm_certificate_arn = aws_acm_certificate.cert.arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
}
Answer:
resource "aws_elasticache_subnet_group" "main" {
name = "redis-subnet-group"
subnet_ids = aws_subnet.private[*].id
}
resource "aws_elasticache_replication_group" "redis" {
replication_group_id = "prod-redis"
description = "Production Redis cluster"
node_type = "cache.t3.medium"
num_cache_clusters = 2 # Primary + 1 replica
automatic_failover_enabled = true
multi_az_enabled = true
port = 6379
subnet_group_name = aws_elasticache_subnet_group.main.name
security_group_ids = [aws_security_group.redis.id]
at_rest_encryption_enabled = true
transit_encryption_enabled = true
tags = { Environment = "prod" }
}
output "redis_endpoint" {
value = aws_elasticache_replication_group.redis.primary_endpoint_address
}
Answer:
# SNS Topic
resource "aws_sns_topic" "orders" {
name = "orders-topic"
}
# SQS Queues with Dead Letter Queues
resource "aws_sqs_queue" "orders_dlq" {
name = "orders-dlq"
message_retention_seconds = 1209600 # 14 days
}
resource "aws_sqs_queue" "orders" {
name = "orders-queue"
visibility_timeout_seconds = 300
message_retention_seconds = 86400
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.orders_dlq.arn
maxReceiveCount = 3
})
}
# SNS → SQS subscription
resource "aws_sns_topic_subscription" "orders_sqs" {
topic_arn = aws_sns_topic.orders.arn
protocol = "sqs"
endpoint = aws_sqs_queue.orders.arn
}
# SQS policy to allow SNS to send messages
resource "aws_sqs_queue_policy" "orders" {
queue_url = aws_sqs_queue.orders.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "sns.amazonaws.com" }
Action = "sqs:SendMessage"
Resource = aws_sqs_queue.orders.arn
Condition = {
ArnEquals = { "aws:SourceArn" = aws_sns_topic.orders.arn }
}
}]
})
}
Answer:
Configure a remote backend using S3 + DynamoDB for centralized state storage and locking:
# backend.tf
terraform {
backend "s3" {
bucket = "my-company-terraform-state"
key = "prod/us-east-1/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
kms_key_id = "arn:aws:kms:us-east-1:123456789:key/abc-123"
}
}
# Bootstrap the S3 + DynamoDB backend
resource "aws_s3_bucket" "tf_state" {
bucket = "my-company-terraform-state"
}
resource "aws_s3_bucket_versioning" "tf_state" {
bucket = aws_s3_bucket.tf_state.id
versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_server_side_encryption_configuration" "tf_state" {
bucket = aws_s3_bucket.tf_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}
# DynamoDB table for state locking
resource "aws_dynamodb_table" "tf_lock" {
name = "terraform-state-lock"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
| Component | Purpose |
|---|---|
| S3 Bucket | Stores terraform.tfstate files centrally |
| S3 Versioning | Allows state rollback if something goes wrong |
| S3 Encryption | Encrypts state file at rest (contains sensitive data) |
| DynamoDB Table | Provides state locking — prevents concurrent applies |
Answer:
# Check the lock info
terraform force-unlock --help
# Force unlock using the Lock ID from the error message
terraform force-unlock LOCK_ID_FROM_ERROR_MESSAGE
# Or check DynamoDB directly for the lock record
aws dynamodb scan --table-name terraform-state-lock
# Delete the lock record manually if needed
aws dynamodb delete-item \
--table-name terraform-state-lock \
--key '{"LockID": {"S": "my-bucket/prod/terraform.tfstate"}}'
⚠️ Warning: Only force-unlock if you’re 100% sure no other
terraform applyis actually running. Unlocking during a live apply can corrupt your state file.
After unlocking, run terraform plan to see the current state. Use terraform refresh to sync state with actual AWS resources.
Answer:
Option 1 — Terraform Workspaces (simple scenarios):
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
terraform workspace select dev
terraform apply
# Use workspace name in resources
locals {
instance_type = terraform.workspace == "prod" ? "t3.large" : "t3.micro"
}
resource "aws_instance" "web" {
instance_type = local.instance_type
tags = { Environment = terraform.workspace }
}
Option 2 — Directory per environment (recommended for production):
infra/
├── modules/
│ ├── vpc/
│ ├── ec2/
│ └── rds/
├── envs/
│ ├── dev/
│ │ ├── main.tf # calls modules
│ │ ├── terraform.tfvars # dev-specific values
│ │ └── backend.tf # dev state: s3/dev/terraform.tfstate
│ ├── staging/
│ └── prod/
✅ For production at scale, use directory-per-environment. It provides complete isolation, different IAM roles per env, and prevents accidentally applying dev config to prod.
Answer:
Use remote state data sources to read outputs from another Terraform state file:
# networking/outputs.tf — networking team exposes these
output "vpc_id" { value = aws_vpc.main.id }
output "private_subnet_ids" { value = aws_subnet.private[*].id }
output "public_subnet_ids" { value = aws_subnet.public[*].id }
# app/main.tf — app team reads networking state
data "terraform_remote_state" "networking" {
backend = "s3"
config = {
bucket = "my-company-terraform-state"
key = "prod/networking/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.networking.outputs.private_subnet_ids[0]
vpc_security_group_ids = [aws_security_group.app.id]
}
💡 Alternative: Use AWS SSM Parameter Store or
aws_vpcdata sources with tag filters to reduce tight coupling between state files.
Answer:
# Step 1: Check S3 versioning for previous state versions
aws s3api list-object-versions \
--bucket my-company-terraform-state \
--prefix prod/terraform.tfstate
# Step 2: Restore a previous version
aws s3api get-object \
--bucket my-company-terraform-state \
--key prod/terraform.tfstate \
--version-id "VERSION_ID_HERE" \
terraform.tfstate.backup
# Step 3: Push the restored state back
terraform state push terraform.tfstate.backup
# Step 4: Verify state is healthy
terraform plan # Should show no unexpected changes
✅ This is why S3 versioning is non-negotiable. Always enable versioning on your state bucket so you can roll back to any previous state version.
📦 Modules & Reusability
Answer:
Create a reusable Terraform module:
modules/
└── ecs-service/
├── main.tf # Resources
├── variables.tf # Input variables
├── outputs.tf # Output values
└── README.md
# modules/ecs-service/variables.tf
variable "service_name" { type = string }
variable "cluster_id" { type = string }
variable "desired_count" { type = number; default = 2 }
variable "log_retention_days" { type = number; default = 30 }
# modules/ecs-service/main.tf
resource "aws_ecs_service" "this" {
name = var.service_name
cluster = var.cluster_id
task_definition = aws_ecs_task_definition.this.arn
desired_count = var.desired_count
}
resource "aws_cloudwatch_log_group" "this" {
name = "/ecs/${var.service_name}"
retention_in_days = var.log_retention_days
}
resource "aws_iam_role" "task_execution" {
name = "${var.service_name}-execution-role"
assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
}
# Call the module for each service — using for_each
variable "services" {
type = map(object({ count = number }))
default = {
user-service = { count = 2 }
payment-service = { count = 3 }
order-service = { count = 2 }
}
}
module "services" {
for_each = var.services
source = "./modules/ecs-service"
service_name = each.key
desired_count = each.value.count
cluster_id = aws_ecs_cluster.main.id
}
Answer:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.1" # Pin to minor version (allows patches)
name = "production-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
single_nat_gateway = false # One per AZ for HA
enable_dns_hostnames = true
enable_dns_support = true
tags = { Environment = "prod" }
}
# Use module outputs in other resources
resource "aws_instance" "web" {
subnet_id = module.vpc.private_subnets[0]
vpc_security_group_ids = [aws_security_group.web.id]
}
⚠️ Always pin module versions with
version = "~> 5.1". Without a pin,terraform init -upgrademight pull a breaking major version change.
Answer:
# modules/app/main.tf
terraform {
required_providers {
aws = { source = "hashicorp/aws" }
}
}
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
}
# root/main.tf
provider "aws" {
alias = "us"
region = "us-east-1"
}
provider "aws" {
alias = "eu"
region = "eu-west-1"
}
module "app_us" {
source = "./modules/app"
providers = { aws = aws.us }
bucket_name = "my-app-us"
}
module "app_eu" {
source = "./modules/app"
providers = { aws = aws.eu }
bucket_name = "my-app-eu"
}
Answer:
Use a Git tag-based versioning pattern with a private Git registry:
# Tag your module for versioning
git tag v1.0.0
git push origin v1.0.0
git tag v1.1.0 # Minor feature
git tag v2.0.0 # Breaking change
# App teams consume from Git with version pinning
module "rds" {
# GitHub source with specific tag
source = "git::https://github.com/my-org/terraform-modules.git//rds?ref=v1.1.0"
db_name = "myapp"
instance_class = "db.t3.medium"
}
# Or use Terraform Cloud/Enterprise private registry
module "rds" {
source = "app.terraform.io/my-org/rds/aws"
version = "~> 1.1"
}
Answer:
Immediate response:
- Revoke the exposed AWS access keys immediately via IAM console
- Check AWS CloudTrail logs for any unauthorized usage since the commit
- Remove keys from git history:
git filter-branchor BFG Repo Cleaner - Force-push to GitHub and notify all team members to re-clone
- Audit all other files for hardcoded secrets using
trufflehogorgitleaks
Prevention — use IAM roles instead of access keys:
# provider.tf — NO hardcoded keys!
provider "aws" {
region = "us-east-1"
# On EC2/ECS/Lambda: credentials come from instance profile automatically
# In CI/CD: use OIDC (see Q33)
}
# Scan for secrets in your repo
trufflehog git https://github.com/my-org/my-repo
gitleaks detect --source .
🚨 The AWS provider should NEVER have
access_keyorsecret_keyhardcoded. Add*.tfvarsand.envto.gitignore. Use environment variables, AWS profiles, or IAM roles.
Answer:
# IAM Role
resource "aws_iam_role" "ec2_s3_reader" {
name = "ec2-s3-reader"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
# Least-privilege policy — read from ONE specific bucket only
resource "aws_iam_role_policy" "s3_read" {
name = "s3-read-specific-bucket"
role = aws_iam_role.ec2_s3_reader.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["s3:GetObject", "s3:ListBucket"]
Resource = [
aws_s3_bucket.app.arn,
"${aws_s3_bucket.app.arn}/*"
]
},
{
Effect = "Deny" # Explicit deny — belt AND suspenders
Action = ["s3:DeleteObject", "s3:PutObject"]
Resource = "*"
}
]
})
}
# Instance Profile (attaches role to EC2)
resource "aws_iam_instance_profile" "ec2_profile" {
name = "ec2-s3-reader-profile"
role = aws_iam_role.ec2_s3_reader.name
}
resource "aws_instance" "web" {
iam_instance_profile = aws_iam_instance_profile.ec2_profile.name
# ... other config
}
Answer:
resource "aws_db_instance" "production" {
identifier = "prod-postgres"
deletion_protection = true # AWS-level protection
lifecycle {
prevent_destroy = true # Terraform-level protection
ignore_changes = [engine_version, password]
}
}
# Also protect critical S3 buckets
resource "aws_s3_bucket" "critical_data" {
bucket = "company-critical-data"
lifecycle {
prevent_destroy = true
}
}
# create_before_destroy: for zero-downtime replacement
resource "aws_security_group" "web" {
lifecycle {
create_before_destroy = true
}
}
| Lifecycle Argument | Use Case |
|---|---|
prevent_destroy | Block any destroy of critical resources |
create_before_destroy | Zero-downtime replacement (new before delete) |
ignore_changes | Ignore drift in specified attributes |
replace_triggered_by | Force replacement when another resource changes |
Answer:
# Approach 1: AWS Secrets Manager
data "aws_secretsmanager_secret_version" "app_secrets" {
secret_id = "prod/myapp/secrets"
}
locals {
secrets = jsondecode(data.aws_secretsmanager_secret_version.app_secrets.secret_string)
}
resource "aws_ecs_task_definition" "app" {
container_definitions = jsonencode([{
name = "app"
environment = [
{ name = "DB_PASSWORD", value = local.secrets["db_password"] },
{ name = "API_KEY", value = local.secrets["api_key"] }
]
}])
}
# Approach 2: SSM Parameter Store
data "aws_ssm_parameter" "db_password" {
name = "/prod/myapp/db-password"
with_decryption = true
}
resource "aws_instance" "app" {
user_data = <<-EOF
export DB_PASSWORD=${data.aws_ssm_parameter.db_password.value}
EOF
}
Answer:
# CloudTrail
resource "aws_cloudtrail" "main" {
name = "prod-cloudtrail"
s3_bucket_name = aws_s3_bucket.cloudtrail.id
include_global_service_events = true
is_multi_region_trail = true
enable_log_file_validation = true
cloud_watch_logs_group_arn = "${aws_cloudwatch_log_group.cloudtrail.arn}:*"
cloud_watch_logs_role_arn = aws_iam_role.cloudtrail.arn
event_selector {
read_write_type = "All"
include_management_events = true
data_resource {
type = "AWS::S3::Object"
values = ["arn:aws:s3:::"]
}
}
}
# AWS Config
resource "aws_config_configuration_recorder" "main" {
name = "config-recorder"
role_arn = aws_iam_role.config.arn
recording_group {
all_supported = true
include_global_resource_types = true
}
}
resource "aws_config_delivery_channel" "main" {
name = "config-delivery"
s3_bucket_name = aws_s3_bucket.config.bucket
depends_on = [aws_config_configuration_recorder.main]
}
resource "aws_config_configuration_recorder_status" "main" {
name = aws_config_configuration_recorder.main.name
is_enabled = true
depends_on = [aws_config_delivery_channel.main]
}
Answer:
locals {
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
private_subnets = ["10.0.11.0/24", "10.0.12.0/24", "10.0.13.0/24"]
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "prod-vpc" }
}
# Public Subnets
resource "aws_subnet" "public" {
count = length(local.azs)
vpc_id = aws_vpc.main.id
cidr_block = local.public_subnets[count.index]
availability_zone = local.azs[count.index]
map_public_ip_on_launch = true
tags = { Name = "public-${count.index + 1}" }
}
# Private Subnets
resource "aws_subnet" "private" {
count = length(local.azs)
vpc_id = aws_vpc.main.id
cidr_block = local.private_subnets[count.index]
availability_zone = local.azs[count.index]
tags = { Name = "private-${count.index + 1}" }
}
# Internet Gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
}
# Elastic IPs for NAT Gateways
resource "aws_eip" "nat" {
count = length(local.azs)
domain = "vpc"
}
# NAT Gateways — one per AZ for HA
resource "aws_nat_gateway" "main" {
count = length(local.azs)
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
depends_on = [aws_internet_gateway.main]
}
# Public Route Table
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
}
# Private Route Tables (one per AZ — routes to respective NAT GW)
resource "aws_route_table" "private" {
count = length(local.azs)
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[count.index].id
}
}
# Route Table Associations
resource "aws_route_table_association" "public" {
count = length(local.azs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
count = length(local.azs)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
Answer:
# ALB Security Group — accepts internet
resource "aws_security_group" "alb" {
name = "alb-sg"
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"] }
}
# App Server SG — only from ALB
resource "aws_security_group" "app" {
name = "app-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.alb.id] # Source = ALB only
}
egress { from_port = 0; to_port = 0; protocol = "-1"; cidr_blocks = ["0.0.0.0/0"] }
}
# Database SG — only from App Server
resource "aws_security_group" "db" {
name = "db-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.app.id] # Source = App only
}
}
Answer:
# VPC Peering Connection
resource "aws_vpc_peering_connection" "app_to_shared" {
vpc_id = aws_vpc.app.id
peer_vpc_id = aws_vpc.shared_services.id
auto_accept = true # Works for same-account peering
tags = { Name = "app-to-shared-services" }
}
# Add routes on BOTH sides
resource "aws_route" "app_to_shared" {
route_table_id = aws_route_table.private.id
destination_cidr_block = "10.1.0.0/16" # shared-services CIDR
vpc_peering_connection_id = aws_vpc_peering_connection.app_to_shared.id
}
resource "aws_route" "shared_to_app" {
route_table_id = aws_route_table.shared_private.id
destination_cidr_block = "10.0.0.0/16" # app VPC CIDR
vpc_peering_connection_id = aws_vpc_peering_connection.app_to_shared.id
}
Answer:
# Transit Gateway — central hub
resource "aws_ec2_transit_gateway" "main" {
description = "Central TGW"
default_route_table_association = "enable"
default_route_table_propagation = "enable"
auto_accept_shared_attachments = "enable"
tags = { Name = "central-tgw" }
}
# Attach each VPC to the TGW
resource "aws_ec2_transit_gateway_vpc_attachment" "app" {
transit_gateway_id = aws_ec2_transit_gateway.main.id
vpc_id = aws_vpc.app.id
subnet_ids = aws_subnet.private[*].id
}
resource "aws_ec2_transit_gateway_vpc_attachment" "shared" {
transit_gateway_id = aws_ec2_transit_gateway.main.id
vpc_id = aws_vpc.shared_services.id
subnet_ids = aws_subnet.shared_private[*].id
}
# Route traffic to TGW from each VPC
resource "aws_route" "app_to_tgw" {
route_table_id = aws_route_table.private.id
destination_cidr_block = "10.0.0.0/8" # All internal networks
transit_gateway_id = aws_ec2_transit_gateway.main.id
}
Answer:
# .github/workflows/terraform.yml
name: Terraform CI/CD
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
id-token: write # Required for OIDC
contents: read
pull-requests: write # To post plan as PR comment
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Authenticate to AWS via OIDC — no access keys!
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-terraform
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.7.0"
- name: Terraform Init
run: terraform init
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
id: plan
run: terraform plan -no-color -out=tfplan 2>&1 | tee plan.txt
# Post plan as PR comment
- name: Comment Plan on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const plan = require('fs').readFileSync('plan.txt', 'utf8')
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '## Terraform Plan\n```\n' + plan.substring(0, 60000) + '\n```'
})
# Apply ONLY on merge to main
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve tfplan
✅ Use OIDC authentication instead of storing AWS access keys as GitHub secrets. The IAM role trusts GitHub’s identity token, making key rotation unnecessary.
Answer:
# GitHub OIDC Provider in AWS
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
# IAM Role for GitHub Actions
resource "aws_iam_role" "github_actions_terraform" {
name = "github-actions-terraform"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
# Only allow from specific repo and branch
"token.actions.githubusercontent.com:sub" = "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}]
})
}
resource "aws_iam_role_policy_attachment" "terraform_permissions" {
role = aws_iam_role.github_actions_terraform.name
policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess"
# Use a custom least-privilege policy in real scenarios
}
Answer:
# Use assume_role to deploy into target accounts
provider "aws" {
alias = "dev"
region = "us-east-1"
assume_role {
role_arn = "arn:aws:iam::111111111111:role/TerraformRole"
}
}
provider "aws" {
alias = "prod"
region = "us-east-1"
assume_role {
role_arn = "arn:aws:iam::222222222222:role/TerraformRole"
}
}
# Deploy to dev account
module "app_dev" {
source = "./modules/app"
providers = { aws = aws.dev }
env = "dev"
}
# Deploy to prod account
module "app_prod" {
source = "./modules/app"
providers = { aws = aws.prod }
env = "prod"
}
Answer:
# .github/workflows/tf-quality.yml
name: Terraform Quality Gates
on:
pull_request:
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Terraform Format Check
run: terraform fmt -check -recursive
# Fails if any .tf file isn't formatted
- name: Terraform Validate
run: |
terraform init -backend=false
terraform validate
- name: TFLint
uses: terraform-linters/setup-tflint@v4
- run: |
tflint --init
tflint --recursive
- name: Checkov Security Scan
uses: bridgecrewio/checkov-action@v12
with:
directory: .
framework: terraform
soft_fail: false
- name: Trivy IaC Scan
uses: aquasecurity/trivy-action@master
with:
scan-type: config
scan-ref: .
Answer:
# .github/workflows/drift-detection.yml
name: Terraform Drift Detection
on:
schedule:
- cron: '0 8 * * *' # Run daily at 8 AM UTC
jobs:
drift-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-terraform
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
- name: Check for drift
id: plan
run: |
terraform init
terraform plan -detailed-exitcode -out=drift.tfplan
continue-on-error: true
- name: Alert on drift
if: steps.plan.outputs.exitcode == '2' # 2 = changes detected
uses: actions/github-script@v7
with:
script: |
github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: '⚠️ Terraform Drift Detected!',
body: 'Terraform plan detected infrastructure drift. Check the workflow run for details.',
labels: ['infrastructure', 'drift']
})
Answer:
You have three options depending on what should be the source of truth:
Option A — Accept the manual change (update config to match AWS):
# Refresh state to see what AWS actually has
terraform refresh
# Update your .tf file instance_type to match live value
# Then verify plan shows no changes
terraform plan
Option B — Revert to Terraform’s desired state:
# Apply will change instance type back
terraform apply -target=aws_instance.web
Option C — Ignore that attribute going forward:
resource "aws_instance" "web" {
lifecycle {
ignore_changes = [instance_type] # Ops team manages sizing manually
}
}
Answer:
A cycle error means two resources depend on each other circularly. Fix by using separate aws_security_group_rule resources:
# Create SGs WITHOUT inline rules (no circular dependency)
resource "aws_security_group" "app" {
name = "app-sg"
vpc_id = aws_vpc.main.id
}
resource "aws_security_group" "db" {
name = "db-sg"
vpc_id = aws_vpc.main.id
}
# Add rules AFTER both SGs exist — breaks the cycle
resource "aws_security_group_rule" "app_to_db" {
type = "egress"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_group_id = aws_security_group.app.id
source_security_group_id = aws_security_group.db.id
}
resource "aws_security_group_rule" "db_from_app" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_group_id = aws_security_group.db.id
source_security_group_id = aws_security_group.app.id
}
Answer:
# List all resources tracked in state
terraform state list
# Remove the orphaned resource from state
# (does NOT touch AWS — only removes from .tfstate)
terraform state rm aws_instance.old_server
# Verify it's gone
terraform state list | grep old_server
# Plan should now show no reference to deleted resource
terraform plan
| Command | Use Case |
|---|---|
terraform state rm | Remove manually deleted resource from state |
terraform state mv | Rename a resource or move it into a module |
terraform state show | View all attributes of a tracked resource |
terraform state pull | Download remote state to local for inspection |
terraform state push | Upload modified state back to remote |
Answer:
# Plan only a specific resource
terraform plan -target=aws_instance.web
# Apply only a specific resource
terraform apply -target=aws_instance.web
# Target multiple resources
terraform apply \
-target=aws_instance.web \
-target=aws_security_group.web
# Target a whole module
terraform apply -target=module.vpc
# Destroy only a specific resource
terraform destroy -target=aws_instance.old_server
⚠️ Use
-targetsparingly — it can leave your state out of sync. Always run a fullterraform planafter targeted operations.
Answer:
# Enable debug logging
export TF_LOG=DEBUG
terraform apply
# Log levels: TRACE, DEBUG, INFO, WARN, ERROR
export TF_LOG=TRACE
terraform plan 2> terraform-debug.log
# Log to a file
export TF_LOG_PATH=./terraform.log
export TF_LOG=DEBUG
terraform apply
# Useful debug commands
terraform console # Interactive expression evaluation
terraform show # Show current state
terraform graph | dot -Tsvg > graph.svg # Visualize dependency graph
# Test expressions in terraform console
> aws_instance.web.public_ip
"54.12.34.56"
> [for s in aws_subnet.private : s.id]
["subnet-abc", "subnet-def", "subnet-ghi"]
Answer:
variable "ingress_rules" {
type = list(object({
port = number
cidr_blocks = list(string)
description = string
}))
default = [
{ port = 80, cidr_blocks = ["0.0.0.0/0"], description = "HTTP" },
{ port = 443, cidr_blocks = ["0.0.0.0/0"], description = "HTTPS" },
{ port = 22, cidr_blocks = ["10.0.0.0/8"], description = "SSH internal" },
]
}
resource "aws_security_group" "web" {
name = "web-sg"
vpc_id = aws_vpc.main.id
# Dynamic block generates one ingress block per rule in the list
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = ingress.value.cidr_blocks
description = ingress.value.description
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Answer:
Terragrunt — a thin wrapper that DRYs up repetitive Terraform configuration:
infra/
├── terragrunt.hcl # Root config — auto backend, provider
├── dev/
│ ├── terragrunt.hcl # Dev account settings
│ ├── vpc/
│ │ └── terragrunt.hcl # Inherits root + dev config
│ └── eks/
│ └── terragrunt.hcl
├── staging/
└── prod/
├── terragrunt.hcl
└── vpc/
└── terragrunt.hcl
# Root terragrunt.hcl — auto-generates backend config per module
remote_state {
backend = "s3"
config = {
bucket = "tf-state-${local.account_id}"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-lock"
encrypt = true
}
generate = {
path = "backend.tf"
if_exists = "overwrite"
}
}
# Generate provider configuration
generate "provider" {
path = "provider.tf"
if_exists = "overwrite"
contents = <<EOF
provider "aws" {
region = "${local.aws_region}"
assume_role {
role_arn = "arn:aws:iam::${local.account_id}:role/TerraformRole"
}
}
EOF
}
# Run across all environments at once
terragrunt run-all plan # Plans all modules
terragrunt run-all apply # Applies all in dependency order
Answer:
# Primary region
provider "aws" {
region = "us-east-1"
}
# Secondary region — aliased
provider "aws" {
alias = "eu"
region = "eu-west-1"
}
# Deploy in primary (uses default provider)
resource "aws_s3_bucket" "primary" {
bucket = "my-app-us-east-1"
}
# Deploy in EU — note provider = aws.eu
resource "aws_s3_bucket" "replica" {
provider = aws.eu
bucket = "my-app-eu-west-1"
}
# S3 cross-region replication
resource "aws_s3_bucket_replication_configuration" "replica" {
bucket = aws_s3_bucket.primary.id
role = aws_iam_role.replication.arn
rule {
id = "replicate-all"
status = "Enabled"
destination {
bucket = aws_s3_bucket.replica.arn
storage_class = "STANDARD_IA"
}
}
}
# Pass aliased provider into a module
module "app_eu" {
source = "./modules/app"
providers = { aws = aws.eu }
region = "eu-west-1"
}
Answer:
| Command | Terraform Version | What it does |
|---|---|---|
terraform taint | Deprecated in 0.15.2 | Marks resource as “tainted” in state — forces destroy+recreate on next apply |
terraform apply -replace | 0.15.2+ (replacement) | Forces destroy+recreate in a single operation |
# OLD way (deprecated)
terraform taint aws_instance.web
terraform apply
# NEW way — Terraform 0.15.2+
terraform apply -replace=aws_instance.web
# Replace multiple resources
terraform apply \
-replace=aws_instance.web \
-replace=aws_launch_template.web
When to use -replace:
- EC2 instance is in a bad state and needs a fresh start
- SSL certificate needs to be rotated (force new cert resource)
- Instance has configuration drift that can’t be fixed in-place
- Testing replacement behavior for a resource type
📋 Quick Reference Cheatsheet
Core Workflow
terraform init # Initialize providers & backend
terraform validate # Validate HCL syntax
terraform fmt -recursive # Format all .tf files
terraform plan -out=plan.tfplan # Preview changes (save plan)
terraform apply plan.tfplan # Apply the saved plan
terraform destroy # Destroy all resources
State Commands
terraform state list # List all tracked resources
terraform state show aws_instance.web # Inspect a resource in state
terraform state rm aws_instance.old # Remove from state (not AWS)
terraform state mv old_name new_name # Rename resource in state
terraform state pull # Download remote state
terraform state push # Upload state to remote
terraform refresh # Sync state with AWS
Import & Replace
terraform import aws_s3_bucket.b name # Import existing resource
terraform apply -replace=aws_instance.x # Force recreate a resource
terraform apply -target=module.vpc # Apply only specific resource
terraform force-unlock LOCK_ID # Remove stuck state lock
Workspaces
terraform workspace list # List all workspaces
terraform workspace new dev # Create workspace
terraform workspace select prod # Switch workspace
terraform workspace show # Show current workspace
terraform workspace delete dev # Delete workspace
Debug & Inspect
terraform console # Interactive HCL console
terraform show # Show current state
terraform output -json # Get outputs as JSON
terraform graph | dot -Tsvg > g.svg # Dependency graph
TF_LOG=DEBUG terraform plan # Verbose debug logging
Providers & Versions
terraform providers # List providers in use
terraform providers lock # Lock provider versions
terraform init -upgrade # Upgrade provider versions
terraform version # Show Terraform version
Useful Variable Patterns
# Default with validation
variable "environment" {
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Must be dev, staging, or prod."
}
}
# Complex object variable
variable "instances" {
type = map(object({
instance_type = string
count = number
}))
}
# Sensitive variable (hidden from logs)
variable "db_password" {
type = string
sensitive = true
}
Common HCL Functions
length(list) # Count items
contains(list, value) # Check if value in list
lookup(map, key, default) # Map lookup with default
merge(map1, map2) # Merge maps
flatten(list_of_lists) # Flatten nested lists
toset(list) # Convert list to set (dedup)
jsonencode(object) # Encode to JSON string
jsondecode(string) # Decode JSON string
base64encode(string) # Base64 encode
filebase64sha256("file.zip") # SHA256 hash of file
format("%-10s %s", a, b) # String formatting
Answer:
Terraform state (terraform.tfstate) is a JSON file that maps every resource block in your .tf files to the real-world resource it created (IDs, ARNs, current attribute values). Terraform uses it to know what already exists, so terraform plan can compute a diff instead of blindly recreating everything on every run.
Why local state is dangerous in a team:
- Only one person has the “truth” — teammates’
plan/applyruns are working from stale or missing data - No locking — two people running
applysimultaneously can corrupt state or double-create resources - State often contains secrets in plaintext (e.g., a generated DB password) — a local file is easy to accidentally commit to Git
Managing state safely — remote backend with locking:
terraform {
backend "s3" {
bucket = "my-company-terraform-state"
key = "prod/us-east-1/terraform.tfstate"
region = "us-east-1"
encrypt = true # encrypt at rest
dynamodb_table = "terraform-state-lock" # prevents concurrent applies
}
}
Safety checklist:
| Practice | Why |
|---|---|
| S3 bucket + versioning | Centralized state, rollback to any previous version |
| Server-side encryption (KMS) | State can contain sensitive values in plaintext |
| DynamoDB locking | Blocks concurrent apply from corrupting state |
| Restrict bucket access via IAM | State reveals your entire infrastructure’s shape — treat it as sensitive |
| Never edit state by hand | Use terraform state mv/rm/pull/push commands instead |
Answer:
At scale, teams use a multi-account landing zone (via AWS Organizations / Control Tower) rather than one shared account, then point Terraform at each account via assume_role:
AWS Organizations
├── Management Account (billing, SCPs only — no workloads)
├── Security Account (centralized CloudTrail, GuardDuty, Config)
├── Shared Services Account (CI/CD runners, shared Terraform state bucket)
├── Dev Account
├── Staging Account
└── Prod Account
# One state bucket in the shared-services account, one state KEY per target account
terraform {
backend "s3" {
bucket = "central-terraform-state"
key = "accounts/prod/terraform.tfstate" # different key per account/env
region = "us-east-1"
}
}
provider "aws" {
assume_role {
role_arn = "arn:aws:iam::${var.target_account_id}:role/TerraformDeployRole"
}
}
Pipeline pattern: a single CI/CD pipeline (in the shared-services account) assumes a deploy role scoped to each target account, one stage per environment, so no account ever holds long-lived credentials for another. This mirrors the “directory-per-environment” pattern from an earlier question, but the isolation boundary is a full AWS account instead of just a Terraform workspace — which also gives you hard billing and blast-radius separation between dev/staging/prod.
Answer:
Drift happens when the real infrastructure diverges from what’s in your .tf files — usually because someone made a manual change in the AWS Console during an incident, or another automation tool touched the same resources.
Detection — run scheduled, read-only plans:
# Daily drift-check job (CI cron, not a human running this ad-hoc)
terraform plan -detailed-exitcode
# Exit code 0 = no drift, 1 = error, 2 = drift detected → alert the team
Once drift is found, you have three remediation paths:
# 1. Accept the manual change — update your .tf to match reality
# (then terraform plan should show "no changes")
# 2. Reject it — re-apply to force infrastructure back to the declared state
terraform apply -target=aws_instance.web
# 3. Explicitly allow that one attribute to drift going forward
resource "aws_instance" "web" {
lifecycle {
ignore_changes = [instance_type] # e.g., an ops team manually right-sizes this
}
}
Prevention is better than detection:
- Restrict Console/CLI write access via IAM — only the CI/CD pipeline role can modify managed resources
- Alert immediately (CloudTrail + EventBridge) on any change to a Terraform-managed resource made outside the pipeline
- Treat drift as an incident, not routine noise — investigate why someone bypassed the pipeline (usually points to a process gap, like the pipeline being too slow for genuine emergencies)
Answer:
A failed apply doesn’t roll back what already succeeded — Terraform applies resources incrementally and updates state as each one completes, so anything created before the failure is real, tracked infrastructure. The recovery process is about assessing and resuming safely, not starting over:
# Step 1: DON'T immediately re-run apply blind — read the error first
# Terraform tells you exactly which resource failed and why
terraform apply
# Error: Error creating DB instance: InvalidParameterValue ...
# Step 2: Check what state actually reflects now
terraform state list
# Confirms which resources Terraform believes exist (created before the failure)
# Step 3: Verify those resources are healthy in the real AWS console/CLI —
# don't just trust state, since a resource can be "created" but misconfigured
aws ec2 describe-instances --instance-ids <id-from-state>
# Step 4: Run a plan to see exactly what Terraform intends to do next
terraform plan
# This shows the REMAINING resources to create, since existing ones are
# already in state and won't be recreated — Terraform naturally resumes
# from where it left off, it doesn't start over
# Step 5: Fix the root cause of the failure (bad parameter, quota limit,
# naming conflict, etc.) in your .tf files or variables
# Step 6: Re-apply — Terraform only creates what's still missing
terraform apply
If a partially-created resource is actually broken (e.g., an RDS instance stuck in a failed state that AWS won’t finish provisioning):
# Remove just that resource from state, then let apply recreate it cleanly
terraform state rm aws_db_instance.broken
terraform apply # recreates that one resource from scratch
Key point for an interview: the safety net here is that Terraform’s plan/apply model is inherently resumable — state already reflects what succeeded, so a second apply after fixing the actual error will only touch what’s still missing, not blow away and recreate everything from zero.
Answer:
State locking prevents two terraform apply runs from modifying the same state file simultaneously — without it, two concurrent applies could both read the same “before” state, make conflicting changes, and the second write would silently clobber the first, corrupting your state and potentially leaving real infrastructure that state no longer accurately describes.
How it works with the S3 + DynamoDB backend:
1. You run `terraform apply`
2. Terraform attempts to write a lock record into the DynamoDB table
(a conditional write — succeeds ONLY if no lock record already exists)
3. If the write succeeds → Terraform proceeds with plan/apply
4. If the write FAILS (a lock record already exists) → Terraform immediately
errors out: "Error acquiring the state lock" — it does NOT wait or retry
5. When apply finishes (success OR failure), Terraform deletes the lock record
6. Now the next `apply` can acquire the lock
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
dynamodb_table = "terraform-state-lock" # the lock mechanism
encrypt = true
}
}
The DynamoDB table only needs one column — LockID (string, primary key) — Terraform writes the state file path as the key, so the lock is scoped per state file, not globally across your whole account.
Why it’s important, concretely:
- Prevents a CI pipeline run and a developer’s local
applyfrom racing each other - Prevents two CI pipeline runs (e.g., triggered by two rapid merges) from corrupting state
- Fails loudly and immediately rather than silently producing an inconsistent state file that’s hard to debug later
What happens if a process crashes mid-apply without releasing the lock: the lock record is orphaned in DynamoDB — this is exactly the “state is locked” scenario handled via terraform force-unlock <LOCK_ID>, used only after confirming no apply is genuinely still running.
Answer:
Immutable infrastructure as a general concept (replace, don’t patch — covered in an earlier question) is a practice; what’s worth understanding here is the specific mechanism Terraform itself uses to enforce it — this isn’t optional behavior you turn on, it’s baked into how Terraform’s provider model works for most attributes.
Terraform’s default: many attribute changes force replacement, not in-place mutation:
resource "aws_instance" "web" {
ami = "ami-0abc123" # changing this → FORCES a new resource
instance_type = "t3.micro" # this one CAN be modified in-place
}
terraform plan
# -/+ aws_instance.web (new resource required)
# ~ ami = "ami-0abc123" -> "ami-0def456" # forces replacement
Terraform providers mark certain attributes as ForceNew in their schema — for those, there is no in-place update path at all. Terraform’s only option is destroy-and-recreate, which is exactly immutable infrastructure’s core idea: you never patch a running instance’s AMI, you replace the whole instance with one built from a new AMI.
create_before_destroy — making that replacement safe:
resource "aws_launch_template" "web" {
name_prefix = "web-" # name_prefix (not name) avoids naming collision
image_id = var.ami_id
instance_type = "t3.micro"
lifecycle {
create_before_destroy = true # build the NEW one before destroying the OLD one
}
}
Without this, Terraform destroys the old resource first — causing downtime for anything depending on it. With it, the new resource is created and validated before the old one is torn down (the standard pattern for Launch Templates feeding an Auto Scaling Group).
How this plays out for a full immutable deployment pipeline:
1. Packer bakes a new AMI (new code baked in, nothing mutated on old servers)
2. Terraform creates a NEW Launch Template version pointing at that AMI
3. ASG instance refresh gradually replaces old instances with new ones
— one at a time, respecting min-healthy-percentage, never patching in place
4. Old AMI is kept around — instant rollback is "point the Launch Template
back at the old AMI ID," not "figure out how to undo a patch"
Interview one-liner: “Terraform enforces immutability at the attribute level — many resource attributes simply have no update API, so a change to them is always destroy-and-recreate by design, not a Terraform limitation. create_before_destroy is what makes that replacement safe instead of just causing downtime.”
🎯 Interview Tips
| Topic | What Interviewers Want to Hear |
|---|---|
| State | Remote backend (S3+DynamoDB), locking, encryption, versioning |
| Secrets | Never hardcode. Use Secrets Manager, SSM, or OIDC |
| Modules | DRY code, versioned, well-documented, for_each usage |
| CI/CD | OIDC auth, plan on PR, apply on merge, checkov/tflint gates |
| Environments | Directory-per-env or workspaces, separate state files |
| Safety | prevent_destroy, create_before_destroy, plan -out |
| Debugging | TF_LOG=DEBUG, terraform console, state commands |
| Drift | Daily plan schedules, ignore_changes, terraform refresh |
Good luck with your Terraform × AWS interviews! 🟣☁️
Add More Questions to This Guide
Know a question that should be here? Share it and help the community!
Open Google Form