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
🎯 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 questions that should be here? Share them and help the community!
Open Google Form