25 Terraform intermediate questions with detailed answers, HCL examples, and interview tips.
Quick Answer
Terraform state is a JSON file that maps your HCL configuration to real-world infrastructure resources. Remote state stores this file in a shared backend like S3 or Terraform Cloud, enabling team collaboration, state locking, and disaster recovery.
Detailed Answer
Terraform state is the backbone of how Terraform understands what infrastructure it manages. Think of it like a warehouse inventory ledger: without it, workers would walk into the warehouse every day not knowing what is already on the shelves, what was ordered, or what needs restocking. The state file (terraform.tfstate) is that ledger — it records every resource Terraform has created, its current attributes, metadata about dependencies, and the mapping between your HCL resource blocks and the actual cloud API objects.
Internally, the state file is a JSON document containing a version number, a serial counter that increments on every write, a lineage UUID that uniquely identifies a state chain, and an array of resource objects. Each resource entry stores the provider, the resource type, the resource name, the mode (managed or data), and the full set of attributes returned by the provider API after creation. When you run terraform plan, Terraform reads this state, calls the cloud APIs to refresh the actual status of each resource, and then computes the diff between desired (your HCL) and actual (the refreshed state). Without state, Terraform would have no way to know that aws_rds_instance.payments_db already exists and would try to create a duplicate every time.
Local state works fine for a solo developer experimenting, but it becomes dangerous in production for several reasons. First, if two engineers run terraform apply simultaneously against local state files, they can create conflicting resources or corrupt state entirely — there is no locking mechanism. Second, if your laptop dies or someone accidentally deletes the state file, you lose the mapping between code and infrastructure, making it extremely difficult to recover. Third, local state may contain sensitive outputs like database passwords or API keys stored in plaintext on a developer workstation.
Remote state solves all of these problems. When you configure a backend like S3 with DynamoDB locking, the state file lives in a durable, versioned object store. DynamoDB provides a distributed lock so that only one terraform apply can run at a time, preventing race conditions. S3 versioning gives you automatic backup of every state revision, so you can roll back if something goes wrong. Additionally, remote state enables the terraform_remote_state data source, which lets one Terraform project read outputs from another — for example, a networking project can export VPC IDs that an application project consumes.
In production, teams typically enforce remote state from day one using a backend configuration block, require encryption at rest and in transit, restrict access via IAM policies, and enable state locking. Ignoring remote state is one of the most common causes of Terraform disasters in growing organizations.
Code Example
# Configure S3 backend with DynamoDB locking for the payments infrastructure
terraform {
# Use the S3 backend to store state remotely
backend "s3" {
# S3 bucket dedicated to Terraform state files
bucket = "fintech-terraform-state-prod"
# Path within the bucket for this specific project's state
key = "payments-platform/us-east-1/terraform.tfstate"
# AWS region where the S3 bucket lives
region = "us-east-1"
# DynamoDB table used for state locking to prevent concurrent applies
dynamodb_table = "fintech-terraform-locks"
# Encrypt the state file at rest using AES-256
encrypt = true
# Use a specific KMS key for encryption instead of default S3 key
kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/payments-state-key"
}
}
# Read remote state from the networking project to get VPC details
data "terraform_remote_state" "networking" {
# Use the S3 backend to read another project's state
backend = "s3"
config = {
# Same state bucket but different key path for the networking project
bucket = "fintech-terraform-state-prod"
# The networking team's state file location
key = "networking/us-east-1/terraform.tfstate"
# Region must match the bucket's region
region = "us-east-1"
}
}
# Use the VPC ID from the networking project's remote state
resource "aws_db_subnet_group" "payments_db_subnets" {
# Name the subnet group after the payments database
name = "payments-db-subnet-group"
# Pull private subnet IDs from the networking project's outputs
subnet_ids = data.terraform_remote_state.networking.outputs.private_subnet_ids
# Tag for cost tracking and ownership
tags = {
Team = "payments-backend"
Service = "payments-db"
}
}Interview Tip
A junior engineer typically answers this by saying 'state tracks resources' and stops there. To stand out, explain the internal structure of the state file — the serial counter, lineage UUID, and how the refresh cycle works during plan. Mention the real production risks: race conditions without locking, data loss without versioning, and secret exposure in local state. Bring up a concrete scenario like two engineers running apply simultaneously and how DynamoDB locking prevents that. Interviewers want to see that you have felt the pain of state management, not just read about it.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────┐
│ Developer Workstation │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ main.tf │ │ variables.tf │ │
│ │ (HCL Config) │ │ (Inputs) │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ └─────────┬─────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ terraform plan │ │
│ │ terraform apply │ │
│ └────────┬────────┘ │
└───────────────────┼──────────────────────────────────────┘
│
┌──────────▼──────────┐
│ S3 Backend │
│ ┌───────────────┐ │
│ │ .tfstate file │ │
│ │ (encrypted) │ │
│ └───────────────┘ │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ DynamoDB Lock │
│ ┌───────────────┐ │
│ │ LockID: hash │ │
│ │ Who: engineer │ │
│ │ Created: ts │ │
│ └───────────────┘ │
└─────────────────────┘💬 Comments
Quick Answer
Terraform modules are reusable containers of related resources defined in a directory with its own variables, outputs, and resource blocks. Good module design follows single-responsibility, exposes minimal required variables, uses sensible defaults, and avoids hardcoding environment-specific values.
Detailed Answer
A Terraform module is essentially a directory containing .tf files that encapsulate a logical group of resources. Think of modules like functions in programming: they take inputs (variables), do something (create resources), and return outputs. The root module is your working directory where you run terraform commands, and any module you call from there is a child module. When you write module "payments_vpc" { source = "./modules/vpc" }, Terraform loads that directory as an isolated configuration unit with its own namespace.
Internally, when Terraform processes a module call, it creates a separate resource namespace prefixed with module.payments_vpc. Resources inside the module cannot directly access resources outside it — they communicate only through input variables and output values. This enforced encapsulation is what makes modules safe to reuse. Terraform also supports module sources from Git repositories, the Terraform Registry, S3 buckets, and HTTP URLs, enabling organization-wide module libraries.
Good module design starts with the single-responsibility principle. A VPC module should create a VPC, subnets, route tables, and NAT gateways — it should not also create your RDS database. Each module should represent one logical infrastructure component. Variables should have descriptions, type constraints, and sensible defaults where possible. For example, a VPC module might default to three availability zones and a /16 CIDR block but allow overrides. Outputs should expose the identifiers that downstream modules need — VPC ID, subnet IDs, security group IDs — nothing more.
One critical design principle is avoiding hardcoded provider configurations inside modules. The module should inherit the provider from the calling module, not declare its own. This allows the same module to be used across multiple AWS accounts or regions by simply changing the provider in the root module. Similarly, avoid hardcoding backend configurations or environment-specific values like account IDs inside modules.
Version pinning is essential for module stability. When sourcing modules from a registry or Git, always pin to a specific version or Git tag. Using version = "~> 2.0" ensures you get patch updates but not breaking major version changes. Without version pinning, a terraform init on Monday might pull different module code than the same command on Friday, leading to unpredictable infrastructure changes.
Production-grade modules also include validation blocks for input variables, meaningful error messages, comprehensive README documentation, and example configurations. Teams that invest in a well-designed internal module library see dramatic reductions in infrastructure provisioning time and configuration drift across environments.
Code Example
# Root module calling the reusable VPC module for the payments platform
module "payments_vpc" {
# Source the module from the internal Git repository at a pinned version tag
source = "git::https://github.com/fintech-infra/terraform-modules.git//vpc?ref=v2.4.1"
# Name the VPC after the service and environment
vpc_name = "payments-platform-prod"
# Use a /16 CIDR block giving 65536 addresses for the payments network
vpc_cidr = "10.20.0.0/16"
# Deploy across three availability zones for high availability
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
# Enable NAT gateway for private subnet internet access
enable_nat_gateway = true
# Use a single NAT gateway in non-prod to save costs, one per AZ in prod
single_nat_gateway = false
# Enable DNS hostnames so RDS instances get resolvable DNS names
enable_dns_hostnames = true
# Tags applied to every resource the module creates
common_tags = {
Environment = "production"
Team = "payments-backend"
CostCenter = "CC-4421"
ManagedBy = "terraform"
}
}
# Inside modules/vpc/variables.tf — well-designed module inputs
variable "vpc_name" {
# Human-readable description shown in terraform plan output
description = "Name prefix for all VPC resources"
# Enforce string type at plan time
type = string
# Validate that the name follows the org naming convention
validation {
condition = can(regex("^[a-z][a-z0-9-]+$", var.vpc_name))
error_message = "VPC name must be lowercase alphanumeric with hyphens."
}
}
variable "vpc_cidr" {
# Describe what this CIDR block is used for
description = "CIDR block for the VPC network range"
# Enforce string type
type = string
# Default to a /16 block if not specified
default = "10.0.0.0/16"
}
# Inside modules/vpc/outputs.tf — expose only what consumers need
output "vpc_id" {
# Describe the output for documentation and discoverability
description = "The ID of the created VPC"
# Reference the VPC resource's ID attribute
value = aws_vpc.main.id
}
output "private_subnet_ids" {
# Consumers use these to place databases and internal services
description = "List of private subnet IDs across all availability zones"
# Collect all private subnet IDs into a list
value = aws_subnet.private[*].id
}Interview Tip
A junior engineer typically describes modules as 'reusable code blocks' without going deeper. Elevate your answer by explaining the namespace isolation (module.name.resource), why providers should be inherited not declared inside modules, and the importance of version pinning with real consequences of not doing it. Mention the anti-patterns you have seen — mega-modules that create everything, modules without outputs, hardcoded account IDs. Show that you think about module consumers, not just module authors. If you can reference building an internal module library for your org, that signals real-world experience.
◈ Architecture Diagram
┌─────────────────────────────────────────────────────┐
│ Root Module (Working Dir) │
│ │
│ ┌───────────────┐ ┌────────────────────┐ │
│ │ main.tf │ │ variables.tf │ │
│ │ │ │ env = "prod" │ │
│ │ module call───┼──┐ │ region = "us-east" │ │
│ └───────────────┘ │ └────────────────────┘ │
└─────────────────────┼───────────────────────────────┘
│
┌───────────▼───────────┐
│ Module: payments_vpc │
│ (modules/vpc/) │
│ │
│ ┌─────────────────┐ │
│ │ variables.tf │ │
│ │ (inputs) │ │
│ └────────┬────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ main.tf │ │
│ │ aws_vpc │ │
│ │ aws_subnet │ │
│ │ aws_nat_gateway │ │
│ └────────┬────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ outputs.tf │ │
│ │ vpc_id │ │
│ │ subnet_ids │ │
│ └─────────────────┘ │
└───────────────────────┘💬 Comments
Quick Answer
Terraform plan is a dry-run that shows what changes Terraform would make without modifying any infrastructure, while terraform apply actually executes those changes. Plan reads state and config, computes a diff, and outputs it; apply performs that diff against real cloud APIs.
Detailed Answer
Understanding the difference between plan and apply is fundamental, but the internals reveal much more than just 'one previews, the other executes.' Think of terraform plan like a restaurant printing a receipt before charging your card — it shows exactly what will happen so you can review it. terraform apply is when the charge actually goes through.
When you run terraform plan, Terraform performs several steps internally. First, it loads the configuration by parsing all .tf files in the current directory and resolving module sources. Second, it reads the current state file to understand what resources already exist. Third, it performs a state refresh — making API calls to every cloud provider to check the actual status of each managed resource and updating the in-memory state with real attributes. This refresh step is crucial because someone might have manually changed a security group rule outside of Terraform. Fourth, Terraform builds a dependency graph of all resources, computes the diff between desired state (your HCL) and actual state (refreshed), and produces an execution plan showing creates, updates, and destroys with specific attribute changes.
The plan output uses a clear notation: + for create, ~ for update in-place, - for destroy, and -/+ for destroy-then-recreate (also called a forced replacement). When you see -/+ next to your production database, that is the moment you should stop and investigate — it means Terraform wants to destroy and recreate that resource, which could mean data loss.
terraform apply by default runs a plan first and asks for confirmation before proceeding. Once confirmed, Terraform walks the dependency graph and makes real API calls to create, update, or destroy resources. It processes independent resources in parallel (up to 10 by default, configurable with -parallelism) and sequential resources in dependency order. After each resource operation completes, Terraform immediately writes the updated state file, ensuring that even if apply is interrupted midway, the state reflects what was actually created.
A critical production practice is using saved plan files. You run terraform plan -out=tfplan to save the plan to a binary file, review it, and then run terraform apply tfplan. This guarantees that what you reviewed is exactly what gets applied — no re-computation, no changes from someone else's commit sneaking in between plan and apply. In CI/CD pipelines, this two-stage approach is essential. The plan stage runs in a pull request for review, and the apply stage runs only after merge using the exact saved plan.
One subtle gotcha: terraform apply without a saved plan will re-compute the plan at apply time, meaning the infrastructure could have changed between when you reviewed the plan output and when apply runs. In fast-moving environments with multiple teams, this gap can cause surprises. Always use saved plans in production workflows.
Code Example
# Step 1: Run plan and save the output to a binary plan file
# The -out flag saves the computed plan for exact replay during apply
# terraform plan -out=payments-deploy-2024-03-15.tfplan
# Step 2: Review the plan output carefully before applying
# Look for any -/+ (destroy and recreate) on stateful resources
# terraform show payments-deploy-2024-03-15.tfplan
# Step 3: Apply the exact saved plan without re-computation
# terraform apply payments-deploy-2024-03-15.tfplan
# Production CI/CD pipeline example using saved plans
# This is typically in a Makefile or CI script
# Variable definitions for the payments infrastructure deployment
variable "db_instance_class" {
# The RDS instance size for the payments database
description = "Instance class for the payments RDS cluster"
# Enforce string type to prevent accidental numeric input
type = string
# Default to a production-grade instance size
default = "db.r6g.xlarge"
}
# RDS cluster that plan will evaluate and apply will create/update
resource "aws_rds_cluster" "payments_db" {
# Unique cluster identifier following the naming convention
cluster_identifier = "payments-db-prod-us-east-1"
# Use Aurora PostgreSQL for the payments database engine
engine = "aurora-postgresql"
# Pin to a specific engine version to avoid surprise upgrades
engine_version = "15.4"
# Place the database in the payments VPC private subnets
db_subnet_group_name = aws_db_subnet_group.payments_db_subnets.name
# Use the payments database security group for network access control
vpc_security_group_ids = [aws_security_group.payments_db_sg.id]
# Master username for the database administrator account
master_username = "payments_admin"
# Pull the password from AWS Secrets Manager, never hardcode
master_password = data.aws_secretsmanager_secret_version.db_password.secret_string
# Enable deletion protection to prevent accidental terraform destroy
deletion_protection = true
# Skip final snapshot only in dev; always snapshot in prod
skip_final_snapshot = false
# Name the final snapshot with a timestamp for recovery
final_snapshot_identifier = "payments-db-final-${formatdate("YYYY-MM-DD", timestamp())}"
# Tags for cost allocation and ownership tracking
tags = {
Service = "payments-processing"
Environment = "production"
BackupTier = "critical"
}
}Interview Tip
A junior engineer typically says 'plan shows changes and apply makes them' which is correct but shallow. Differentiate yourself by explaining the four internal steps of plan (load config, read state, refresh, compute diff), why the refresh step matters when someone has made manual changes, and the critical importance of saved plan files in CI/CD. Mention the -/+ notation and why seeing it on a database resource should trigger an immediate investigation. If you can describe a real scenario where the gap between plan and apply caused issues, you demonstrate battle-tested experience that interviewers value.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────┐
│ terraform plan │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Load HCL │──→│ Read │──→│ Refresh │ │
│ │ Config │ │ State │ │ via API │ │
│ └──────────┘ └──────────┘ └────┬─────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ Compute │ │
│ │ Diff │ │
│ └──────┬──────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ Plan Output │ │
│ │ + create │ │
│ │ ~ update │ │
│ │ - destroy │ │
│ └──────┬──────┘ │
└─────────────────────────────────────┼─────────────────────┘
│
┌─────────▼──────────┐
│ Saved Plan File │
│ (.tfplan binary) │
└─────────┬──────────┘
│
┌─────────────────────────────────────┼─────────────────────┐
│ terraform apply │ │
│ ┌──────▼──────┐ │
│ │ Walk Dep │ │
│ │ Graph │ │
│ └──────┬──────┘ │
│ │ │
│ ┌────────────────┬┴────────────────┐ │
│ ┌─────▼─────┐ ┌──────▼─────┐ ┌────────▼┐ │
│ │ Create │ │ Update │ │ Destroy │ │
│ │ Resources │ │ Resources │ │ Removed │ │
│ └─────┬─────┘ └──────┬─────┘ └────────┬┘ │
│ └────────────────┼─────────────────┘ │
│ ┌──────▼──────┐ │
│ │ Write State │ │
│ └─────────────┘ │
└───────────────────────────────────────────────────────────┘💬 Comments
Quick Answer
Terraform workspaces allow you to maintain multiple state files for the same configuration, enabling environment separation (dev/staging/prod) with a single codebase. Separate directories are preferred when environments have significantly different resource compositions or provider configurations.
Detailed Answer
Terraform workspaces are a built-in mechanism for managing multiple instances of the same infrastructure configuration. Think of workspaces like branches in a photo editing app — you have the same source image but can apply different filters and adjustments to each branch independently. Each workspace gets its own state file, so resources in the 'dev' workspace are completely isolated from resources in the 'prod' workspace, even though they share the same Terraform code.
Internally, workspaces work by modifying the state file path. When using the default local backend, Terraform stores state files in a terraform.tfstate.d/ directory with subdirectories for each workspace. With an S3 backend, workspace state files are stored under the key prefix with the workspace name appended — for example, payments-platform/dev/terraform.tfstate and payments-platform/prod/terraform.tfstate. The terraform.workspace variable is available in your HCL code, letting you conditionally set values based on the active workspace.
Workspaces shine when your environments are structurally identical but differ in scale or configuration. If dev, staging, and production all need the same VPC, RDS cluster, ECS service, and ALB, but dev uses smaller instance types and fewer replicas, workspaces with conditional expressions or workspace-specific tfvars files work beautifully. You can use terraform.workspace in locals to set instance sizes, replica counts, and CIDR ranges per environment.
However, workspaces have significant limitations that push many teams toward separate directories. First, all environments share the same provider configuration — you cannot easily use different AWS accounts per workspace without complex provider alias tricks. Second, if your production environment has additional resources that dev does not need (WAF rules, CloudFront distributions, compliance monitoring), you end up with count = terraform.workspace == "prod" ? 1 : 0 scattered throughout your code, which becomes unreadable. Third, workspaces provide no protection against accidentally running apply in the wrong workspace. A sleep-deprived engineer who forgets to run terraform workspace select prod before applying a production hotfix could accidentally modify the dev environment.
Separate directories (often called the directory-per-environment pattern) give you complete isolation. Each environment has its own directory with its own backend configuration, provider configuration, and state. This means prod can use a different AWS account, different provider version constraints, and completely different resource compositions. The tradeoff is code duplication — you need to keep common modules in sync across directories.
The modern consensus in the Terraform community is to use workspaces for lightweight environment differentiation within a single account and team, and separate directories (or separate Terraform Cloud workspaces with VCS integration) for production-grade multi-account setups. Many teams use a hybrid approach: modules contain the shared logic, and each environment directory calls those modules with environment-specific variables.
Code Example
# Using workspaces with conditional configuration for the payments platform
# Define local values that change based on the active workspace
locals {
# Map workspace names to environment-specific configurations
environment_config = {
# Development environment uses minimal resources to save costs
dev = {
instance_class = "db.t3.medium"
replica_count = 1
vpc_cidr = "10.10.0.0/16"
enable_waf = false
backup_retention = 3
}
# Staging mirrors production structure but at reduced scale
staging = {
instance_class = "db.r6g.large"
replica_count = 2
vpc_cidr = "10.20.0.0/16"
enable_waf = true
backup_retention = 7
}
# Production runs at full scale with maximum protection
prod = {
instance_class = "db.r6g.2xlarge"
replica_count = 3
vpc_cidr = "10.30.0.0/16"
enable_waf = true
backup_retention = 35
}
}
# Look up the current workspace's config from the map above
config = local.environment_config[terraform.workspace]
}
# Configure the S3 backend — workspace name is automatically appended to key
terraform {
# S3 backend stores each workspace's state at a separate key path
backend "s3" {
# Shared state bucket for all environments
bucket = "fintech-terraform-state"
# Base key path — workspace name is appended automatically
key = "payments-platform/terraform.tfstate"
# Region where the state bucket resides
region = "us-east-1"
# Lock table shared across all workspaces
dynamodb_table = "fintech-terraform-locks"
# Encrypt state at rest for compliance
encrypt = true
}
}
# VPC sized according to the current workspace
resource "aws_vpc" "payments_vpc" {
# CIDR block varies by environment — dev is /16, staging is /16, prod is /16
cidr_block = local.config.vpc_cidr
# Enable DNS hostnames for service discovery within the VPC
enable_dns_hostnames = true
# Tag with the workspace name so resources are identifiable in the console
tags = {
Name = "payments-vpc-${terraform.workspace}"
Environment = terraform.workspace
ManagedBy = "terraform"
}
}
# RDS cluster scaled per environment using workspace-driven locals
resource "aws_rds_cluster_instance" "payments_db_instances" {
# Create the number of replicas specified for this workspace
count = local.config.replica_count
# Unique identifier includes workspace and instance index
identifier = "payments-db-${terraform.workspace}-${count.index}"
# Associate with the payments database cluster
cluster_identifier = aws_rds_cluster.payments_db.id
# Instance class varies by workspace — t3.medium in dev, r6g.2xlarge in prod
instance_class = local.config.instance_class
# Use the same Aurora PostgreSQL engine as the cluster
engine = aws_rds_cluster.payments_db.engine
# Tag for environment identification and cost tracking
tags = {
Environment = terraform.workspace
Service = "payments-db"
}
}Interview Tip
A junior engineer typically says 'workspaces let you manage dev and prod separately' without discussing the tradeoffs. Stand out by explaining when workspaces break down: different AWS accounts per environment, environments with different resource compositions, and the risk of applying to the wrong workspace. Present the directory-per-environment alternative and when it is preferable. Mentioning the hybrid approach (shared modules called from environment-specific directories) shows architectural maturity. Interviewers are specifically testing whether you can articulate the boundaries of a feature, not just its capabilities.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────┐ │ Workspace Approach │ │ │ │ ┌──────────────────────────────────────────────┐ │ │ │ Shared HCL Configuration │ │ │ │ main.tf │ variables.tf │ outputs.tf │ │ │ └──────────────────┬───────────────────────────┘ │ │ │ │ │ ┌────────────┼────────────┐ │ │ │ │ │ │ │ ┌─────▼─────┐ ┌────▼─────┐ ┌───▼──────┐ │ │ │ Workspace │ │Workspace │ │Workspace │ │ │ │ dev │ │ staging │ │ prod │ │ │ │ │ │ │ │ │ │ │ │ State: A │ │ State: B │ │ State: C │ │ │ │ t3.medium │ │ r6g.large│ │r6g.2xl │ │ │ └───────────┘ └──────────┘ └──────────┘ │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ Directory Approach │ │ │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │ envs/dev/ │ │envs/stage/ │ │ envs/prod/ │ │ │ │ main.tf │ │ main.tf │ │ main.tf │ │ │ │ backend.tf │ │ backend.tf │ │ backend.tf │ │ │ │ State: X │ │ State: Y │ │ State: Z │ │ │ │ AcctID: 111│ │ AcctID: 222│ │ AcctID: 333│ │ │ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ │ │ │ │ │ │ └───────────────┼───────────────┘ │ │ │ │ │ ┌─────────▼─────────┐ │ │ │ Shared Modules │ │ │ │ modules/vpc/ │ │ │ │ modules/rds/ │ │ │ │ modules/ecs/ │ │ │ └───────────────────┘ │ └──────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Data sources read existing infrastructure information without creating or modifying anything. Resources create, update, and manage infrastructure. Data sources use a 'data' block to query providers for information like existing VPC IDs, AMI IDs, or IAM policies that your configuration needs to reference.
Detailed Answer
Data sources in Terraform are read-only queries against your infrastructure provider's API. Think of the difference like this: a resource block is like writing a check at a bank — it changes your account balance. A data source is like checking your account statement — it only reads information without modifying anything. Both interact with the same bank (cloud provider), but their intentions and side effects are fundamentally different.
Internally, when Terraform encounters a data block, it makes an API call to the provider during the refresh phase of plan or apply and stores the returned attributes in state. The provider plugin handles translating the data source's filter criteria into the appropriate API call — for example, data.aws_ami.ubuntu_latest with filters for name and owner translates into an EC2 DescribeImages API call with those filters. The returned attributes become available for reference in other parts of your configuration.
Data sources serve several critical purposes in production Terraform code. First, they bridge the gap between managed and unmanaged infrastructure. If your networking team manages VPCs outside of your Terraform project, you use data.aws_vpc to look up the VPC by tags or ID rather than hardcoding the VPC ID. This makes your code portable and resilient to infrastructure changes. Second, data sources enable dynamic configuration — instead of hardcoding the latest Ubuntu AMI ID and updating it manually, data.aws_ami queries for the most recent AMI matching your criteria every time you plan. Third, they connect separate Terraform projects through terraform_remote_state data sources.
A subtle but important difference is lifecycle behavior. Resources have full lifecycle management: Terraform creates them, tracks them in state, updates them when configuration changes, and destroys them when removed from code. Data sources have no lifecycle — they are read during plan/apply and their results are cached in state, but Terraform never creates, updates, or destroys the underlying infrastructure. If a data source query returns no results, Terraform fails with an error rather than creating anything.
One common gotcha is circular dependencies between data sources and resources. If you create an AWS security group with a resource block and then try to use a data source to read that same security group in the same configuration, you may hit issues during the first apply because the data source tries to read something that does not exist yet. The rule of thumb is: if Terraform creates it, reference it directly as a resource; if something else creates it, use a data source.
Data sources also respect provider authentication and permissions, so your Terraform IAM role needs read access to whatever the data source queries. In production, teams often need broader read permissions than write permissions because data sources query across account boundaries — for example, reading a shared services account's Route53 hosted zone to create DNS records in it.
Code Example
# Data source: Look up the latest Amazon Linux 2023 AMI for payments API servers
data "aws_ami" "amazon_linux_2023" {
# Return only the single most recent AMI matching the filters
most_recent = true
# Filter by the AMI name pattern for Amazon Linux 2023
filter {
name = "name"
values = ["al2023-ami-2023.*-x86_64"]
}
# Filter to only include HVM virtualization type
filter {
name = "virtualization-type"
values = ["hvm"]
}
# Only trust AMIs owned by Amazon's official account
owners = ["137112412989"]
}
# Data source: Look up the existing VPC managed by the networking team
data "aws_vpc" "payments_vpc" {
# Find the VPC using tags set by the networking team's Terraform
filter {
name = "tag:Name"
values = ["payments-platform-prod"]
}
# Ensure we only match VPCs in the available state
filter {
name = "state"
values = ["available"]
}
}
# Data source: Retrieve private subnets within the payments VPC
data "aws_subnets" "private" {
# Filter subnets to only those in the payments VPC
filter {
name = "vpc-id"
values = [data.aws_vpc.payments_vpc.id]
}
# Filter to only private subnets using the networking team's tag convention
filter {
name = "tag:Tier"
values = ["private"]
}
}
# Data source: Read the database password from AWS Secrets Manager
data "aws_secretsmanager_secret_version" "payments_db_creds" {
# Reference the secret by its ARN for cross-account access
secret_id = "arn:aws:secretsmanager:us-east-1:123456789012:secret:payments-db-prod-creds"
}
# Resource: Create an EC2 launch template using data from the sources above
resource "aws_launch_template" "payments_api" {
# Name the launch template after the service
name = "payments-api-prod"
# Use the latest Amazon Linux 2023 AMI discovered by the data source
image_id = data.aws_ami.amazon_linux_2023.id
# Use a compute-optimized instance for payment processing workloads
instance_type = "c6i.xlarge"
# Place instances in the VPC's private subnets discovered by data source
vpc_security_group_ids = [aws_security_group.payments_api_sg.id]
# Tag instances for identification and cost allocation
tag_specifications {
resource_type = "instance"
tags = {
Name = "payments-api-prod"
Service = "payments-processing"
AMI = data.aws_ami.amazon_linux_2023.id
}
}
}Interview Tip
A junior engineer typically conflates data sources with resources or describes them vaguely as 'things that read data.' Sharpen your answer by explaining the lifecycle difference — resources have create, update, destroy; data sources only read. Mention the refresh-phase timing, the error behavior when a data source finds nothing versus a resource, and the circular dependency gotcha. Give a concrete example like looking up a shared VPC managed by another team. Interviewers appreciate when you explain why data sources exist architecturally — they bridge managed and unmanaged infrastructure — rather than just listing what they do.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────┐
│ Terraform Configuration │
│ │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Data Sources │ │ Resources │ │
│ │ (Read Only) │ │ (Full CRUD) │ │
│ │ │ │ │ │
│ │ aws_ami │ │ aws_instance │ │
│ │ aws_vpc │ │ aws_rds_cluster │ │
│ │ aws_subnets │ │ aws_s3_bucket │ │
│ └────────┬────────┘ └────────┬─────────┘ │
│ │ │ │
│ ┌─────▼─────┐ ┌──────▼──────┐ │
│ │ API Call: │ │ API Calls: │ │
│ │ Describe │ │ Create │ │
│ │ (read) │ │ Update │ │
│ └─────┬─────┘ │ Delete │ │
│ │ │ Read │ │
│ │ └──────┬──────┘ │
└───────────┼────────────────────────┼─────────────────┘
│ │
┌────────▼────────┐ ┌────────▼────────┐
│ Existing Infra │ │ Managed Infra │
│ (not managed │ │ (created and │
│ by this TF) │ │ tracked by TF) │
│ │ │ │
│ Shared VPC │ │ Payment API │
│ Base AMIs │ │ RDS Cluster │
│ DNS Zones │ │ S3 Buckets │
└─────────────────┘ └─────────────────┘💬 Comments
Quick Answer
Terraform builds a dependency graph automatically from resource attribute references (implicit dependencies) and supports depends_on for cases where dependencies exist but are not expressed through attribute references (explicit dependencies). The graph determines the order of create, update, and destroy operations.
Detailed Answer
Terraform's dependency management is one of its most powerful features, and understanding it deeply is key to writing correct infrastructure code. Think of dependencies like a construction project: you cannot install electrical wiring (resource B) until the walls are framed (resource A). Terraform figures out this ordering automatically by analyzing which resources reference attributes of other resources.
Implicit dependencies are the primary and preferred mechanism. When you write aws_instance.payments_api.subnet_id = aws_subnet.private_payments.id, Terraform analyzes this reference and understands that the subnet must exist before the instance can be created. It adds an edge in the dependency graph from aws_subnet.private_payments to aws_instance.payments_api. During apply, Terraform walks this directed acyclic graph (DAG) in topological order, creating resources with no dependencies first and working its way up. For destroy operations, Terraform reverses the graph — it destroys the instance before the subnet, because the instance depends on the subnet.
Internally, Terraform's graph builder collects all resource and data source blocks, resolves all interpolation references, and constructs a DAG where nodes are resources and edges are dependencies. It then validates the graph is acyclic — if you accidentally create a circular dependency (A references B, B references A), Terraform detects this and fails with an error showing the cycle. The graph also determines parallelism: resources at the same depth in the graph with no dependencies between them can be created simultaneously.
Explicit dependencies using depends_on are necessary when a dependency exists but is not visible through attribute references. The classic example is an IAM role and an EC2 instance that assumes that role. If the instance references the role ARN directly, the implicit dependency works. But if the instance uses an instance profile that was created elsewhere, and the role's permissions are needed at boot time for a user_data script, Terraform might try to launch the instance before the IAM role's policy attachments are complete. AWS IAM has eventual consistency — a policy attachment might take seconds to propagate. Adding depends_on = [aws_iam_role_policy_attachment.payments_api_s3_access] forces Terraform to wait.
However, depends_on should be used sparingly because it has a hidden cost. When you add depends_on to a resource, Terraform treats it as a more opaque dependency — during plan, it may not be able to determine the full set of changes, which can lead to unnecessary replacements or overly conservative planning. It also makes your code harder to understand because the dependency reason is not self-documenting like an attribute reference.
Another important dependency concept is resource ordering during updates. If you change a security group rule that an RDS instance references, Terraform understands that the security group must be updated before or after the RDS instance depending on the nature of the change. Terraform's graph handles this automatically for most cases, but understanding the graph helps you predict apply behavior and debug issues when operations fail mid-apply.
Code Example
# IMPLICIT DEPENDENCY: Terraform detects this automatically from references
# The security group must exist before the RDS cluster can reference it
resource "aws_security_group" "payments_db_sg" {
# Name follows the service-role-environment convention
name = "payments-db-sg-prod"
# Place the security group in the payments VPC
vpc_id = aws_vpc.payments_vpc.id
# Allow PostgreSQL traffic from the application subnet CIDR
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
cidr_blocks = ["10.20.10.0/24"]
description = "PostgreSQL access from payments API subnet"
}
# Allow all outbound traffic for database updates and backups
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
description = "Allow all outbound for patching and backups"
}
# Tags for resource identification in the AWS console
tags = {
Name = "payments-db-sg-prod"
Service = "payments-db"
}
}
# This RDS cluster IMPLICITLY depends on the security group above
resource "aws_rds_cluster" "payments_db" {
# Unique cluster identifier for the payments database
cluster_identifier = "payments-db-prod"
# Use Aurora PostgreSQL engine for high availability
engine = "aurora-postgresql"
# Reference creates an implicit dependency — SG must exist first
vpc_security_group_ids = [aws_security_group.payments_db_sg.id]
# Place in private subnets managed by the networking module
db_subnet_group_name = aws_db_subnet_group.payments_private.name
# Master credentials pulled from Secrets Manager
master_username = "payments_admin"
master_password = data.aws_secretsmanager_secret_version.db_creds.secret_string
# Protect against accidental destruction in production
deletion_protection = true
# Tag for ownership and cost allocation
tags = {
Service = "payments-processing"
}
}
# EXPLICIT DEPENDENCY: IAM eventual consistency requires depends_on
# The policy attachment must propagate before the instance boots
resource "aws_iam_role_policy_attachment" "payments_api_s3_access" {
# Attach the S3 read policy to the payments API role
role = aws_iam_role.payments_api_role.name
# Grant read-only access to the payments data bucket
policy_arn = "arn:aws:iam::policy/payments-s3-readonly-prod"
}
# Launch template needs the IAM policy to be active before instances boot
resource "aws_launch_template" "payments_api" {
# Name the template for the payments API fleet
name = "payments-api-prod"
# Instance profile for the IAM role (doesn't reference policy directly)
iam_instance_profile {
name = aws_iam_instance_profile.payments_api_profile.name
}
# EXPLICIT dependency: user_data script needs S3 access at boot time
# Terraform cannot infer this from attribute references alone
depends_on = [aws_iam_role_policy_attachment.payments_api_s3_access]
# AMI for the payments API servers
image_id = data.aws_ami.amazon_linux_2023.id
# Instance type optimized for payment processing workloads
instance_type = "c6i.xlarge"
# User data script that reads config from S3 at boot
user_data = base64encode(templatefile("${path.module}/scripts/payments-api-init.sh", {
config_bucket = "fintech-payments-config-prod"
}))
}Interview Tip
A junior engineer typically lists 'implicit uses references, explicit uses depends_on' and moves on. Go deeper by explaining the directed acyclic graph (DAG) Terraform builds, how it determines parallelism from graph depth, and how destroy operations reverse the graph order. The key differentiator is explaining when depends_on is actually necessary — the IAM eventual consistency example is the canonical case. Mention that depends_on has hidden costs like opaque planning and should be used sparingly. If you can draw or describe the DAG for a simple three-resource setup, you demonstrate genuine understanding of Terraform internals.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────┐ │ Terraform Dependency Graph (DAG) │ │ │ │ ┌───────────────────┐ │ │ │ aws_vpc │ ← No dependencies, created first │ │ │ payments_vpc │ │ │ └─────────┬─────────┘ │ │ │ implicit (vpc_id reference) │ │ ┌──────┴──────────────────┐ │ │ │ │ │ │ ┌──▼──────────────┐ ┌───────▼──────────┐ │ │ │ aws_subnet │ │ aws_security_grp │ ← Parallel │ │ │ private_payments│ │ payments_db_sg │ creation │ │ └──────┬──────────┘ └───────┬──────────┘ │ │ │ implicit │ implicit │ │ └──────────┬──────────┘ │ │ │ │ │ ┌──────────▼──────────┐ │ │ │ aws_rds_cluster │ ← Waits for both │ │ │ payments_db │ subnet AND sg │ │ └──────────┬──────────┘ │ │ │ explicit (depends_on) │ │ ┌──────────▼──────────┐ │ │ │ aws_launch_template │ ← Waits for IAM │ │ │ payments_api │ policy propagation │ │ └─────────────────────┘ │ │ │ │ Destroy Order: Reverse of above (bottom → top) │ └───────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Terraform import brings existing infrastructure resources under Terraform management by adding them to the state file. You need it when adopting Terraform for pre-existing infrastructure, recovering from state loss, or integrating manually-created resources into your IaC workflow.
Detailed Answer
terraform import is the bridge between manually-created infrastructure and Infrastructure as Code. Think of it like adopting a rescue dog — the dog already exists, you just need to register it in your system and start managing it properly. The resource is out there in AWS or GCP, and terraform import tells Terraform 'this resource exists, start tracking it in state.'
Internally, terraform import works by taking a resource address (the Terraform resource block identifier) and a cloud provider resource ID (like an AWS instance ID), then making API calls to fetch the current attributes of that resource and writing them into the state file. Critically, import does NOT generate the HCL configuration for you — it only updates state. This means you must first write the resource block in your .tf files that matches the imported resource, then run the import, and then iterate by running terraform plan to see what attributes are missing or mismatched in your HCL until the plan shows no changes.
The traditional import workflow is tedious but important: write an empty or partial resource block, run terraform import aws_rds_cluster.payments_db payments-db-prod-cluster-id, then run terraform plan repeatedly while adding missing attributes to your HCL until the plan is clean. Each plan iteration will show you attributes that differ between your config and the actual resource — things like backup retention periods, maintenance windows, or parameter groups that you forgot to specify.
Starting with Terraform 1.5, the import block was introduced as a declarative alternative. Instead of running an imperative CLI command, you add an import block to your configuration that specifies the resource address and cloud ID. When you run terraform plan, it shows the import as part of the plan, and terraform apply executes it. This is a significant improvement because imports become part of your version-controlled code, reviewable in pull requests, and repeatable across environments.
Common scenarios where you need terraform import include: migrating a legacy infrastructure to Terraform (the most common case), recovering after accidental state deletion, bringing a resource that was created via the AWS console clickops into Terraform management, and splitting a large Terraform project into smaller ones where some resources need to move to a different state file.
There are important gotchas with import. Not all resource attributes are importable — some providers or resource types do not support import at all, and you will get an error. Some resources require specific ID formats that are not obvious — for example, an AWS security group rule import requires a combination of security group ID, type, protocol, port range, and CIDR. After importing, you must verify with terraform plan that your configuration matches the imported resource, because any drift will be 'corrected' on the next apply, potentially causing outages. Finally, import does not handle dependencies — if you import a subnet but not the VPC it belongs to, your configuration must use a data source for the VPC or import it as well.
Code Example
# Step 1: Write the resource block BEFORE importing
# This block must eventually match all attributes of the existing resource
resource "aws_rds_cluster" "payments_db" {
# Cluster identifier must match the existing cluster's identifier exactly
cluster_identifier = "payments-db-prod"
# Engine must match what was configured when the cluster was created
engine = "aurora-postgresql"
# Engine version must match the currently running version
engine_version = "15.4"
# Database name must match the existing default database
database_name = "payments"
# Master username must match the existing master user
master_username = "payments_admin"
# Subnet group must reference the existing subnet group name
db_subnet_group_name = "payments-db-subnet-group-prod"
# Security groups must list all currently attached security group IDs
vpc_security_group_ids = [aws_security_group.payments_db_sg.id]
# Backup retention must match the current setting to avoid plan diff
backup_retention_period = 35
# Preferred backup window must match to avoid unnecessary modification
preferred_backup_window = "03:00-04:00"
# Preferred maintenance window must match the existing schedule
preferred_maintenance_window = "sun:05:00-sun:06:00"
# Deletion protection state must match the current setting
deletion_protection = true
# Skip final snapshot setting must match
skip_final_snapshot = false
# Tags must match all existing tags on the resource
tags = {
Service = "payments-processing"
Environment = "production"
CreatedBy = "manual-import"
ManagedBy = "terraform"
}
}
# Step 2 (Traditional): Run the import command from CLI
# terraform import aws_rds_cluster.payments_db payments-db-prod
# Step 2 (Modern - Terraform 1.5+): Use declarative import block instead
import {
# Specify the Terraform resource address to import into
to = aws_rds_cluster.payments_db
# Specify the cloud provider's resource identifier
id = "payments-db-prod"
}
# Step 3: Run terraform plan to check for attribute drift
# Iterate on the resource block until plan shows zero changes
# terraform plan
# Example: Import a security group that was created via AWS console
import {
# Import the manually-created security group into Terraform state
to = aws_security_group.payments_db_sg
# Use the security group ID from the AWS console
id = "sg-0a1b2c3d4e5f67890"
}
# The corresponding resource block for the imported security group
resource "aws_security_group" "payments_db_sg" {
# Name must match the existing security group's name
name = "payments-db-sg-prod"
# Description must match the existing security group's description
description = "Security group for payments database cluster"
# VPC ID must reference the correct VPC
vpc_id = data.aws_vpc.payments_vpc.id
# Ingress rules must match all existing inbound rules
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
cidr_blocks = ["10.20.0.0/16"]
description = "PostgreSQL from payments VPC"
}
# Egress rules must match all existing outbound rules
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
description = "Allow all outbound"
}
}Interview Tip
A junior engineer typically says 'import adds resources to state' which is true but incomplete. Differentiate yourself by walking through the full workflow: write the resource block first, run import, then iterate with plan until zero diff. Explain the critical gotcha that import does NOT generate HCL code — many engineers learn this the hard way. Mention the Terraform 1.5 import block as a modern improvement and explain why declarative imports in code are better than imperative CLI commands for team workflows. If you can describe a real scenario where you imported a production database cluster and the challenges you faced matching all attributes, that is gold for interviewers.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────┐
│ terraform import Workflow │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Existing Cloud │ │ Empty .tf File │ │
│ │ Resource │ │ │ │
│ │ (created via │ │ resource "aws_ │ │
│ │ console/CLI) │ │ rds_cluster" │ │
│ │ │ │ "payments_db" │ │
│ │ ID: payments- │ │ { ... } │ │
│ │ db-prod │ │ │ │
│ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │
│ └────────────┬───────────┘ │
│ │ │
│ ┌─────────▼──────────┐ │
│ │ terraform import │ │
│ │ (links resource │ │
│ │ to state) │ │
│ └─────────┬──────────┘ │
│ │ │
│ ┌─────────▼──────────┐ │
│ │ terraform plan │←──────────┐ │
│ │ (shows attribute │ │ │
│ │ drift/mismatches) │ │ │
│ └─────────┬──────────┘ │ │
│ │ │ │
│ ┌────▼────┐ ┌─────┴─────┐ │
│ │ Clean │ No │ Update │ │
│ │ plan? ├──────────→│ .tf file │ │
│ └────┬────┘ │ to match │ │
│ Yes │ └───────────┘ │
│ │ │
│ ┌─────────▼──────────┐ │
│ │ Resource is now │ │
│ │ fully managed by │ │
│ │ Terraform │ │
│ └────────────────────┘ │
└───────────────────────────────────────────────────────────┘💬 Comments
Quick Answer
Sensitive values should never be hardcoded in Terraform files. Use the sensitive flag on variables to suppress plan output, store secrets in tools like HashiCorp Vault or AWS Secrets Manager, reference them via data sources, and ensure state encryption since Terraform state stores all values in plaintext.
Detailed Answer
Managing sensitive values in Terraform is one of the most critical aspects of production infrastructure and one of the most commonly mishandled. Think of it like handling classified documents in an office — you need secure storage (Vault), controlled access (IAM policies), no leaving copies around (state encryption), and no reading aloud in public (plan output suppression).
The first layer of defense is the sensitive variable flag introduced in Terraform 0.14. When you mark a variable as sensitive = true, Terraform redacts its value from plan and apply output, replacing it with (sensitive value). This prevents accidental exposure in CI/CD logs, terminal output shared in screenshots, or plan files sent for review. However, this is purely a display-level protection — the actual value is still stored in plaintext in the state file. Many engineers mistakenly believe the sensitive flag encrypts the value, but it does not.
The second and more important layer is keeping secrets out of your Terraform code and version control entirely. Never put passwords, API keys, or tokens in .tf files, .tfvars files, or any file committed to Git. Even if you add them to .gitignore, they can end up in Git history, CI/CD caches, or developer workstations. Instead, use external secret management systems.
HashiCorp Vault is the gold standard for dynamic secrets with Terraform. The Vault provider allows Terraform to read secrets from Vault at plan/apply time using data sources. Better yet, Vault can generate dynamic, short-lived database credentials that automatically expire — meaning even if the state file is compromised, the stolen credentials will be invalid within hours. The Vault provider authenticates using methods like AppRole, AWS IAM, or Kubernetes service accounts, avoiding the chicken-and-egg problem of needing a secret to access secrets.
AWS Secrets Manager and SSM Parameter Store are alternatives that integrate natively with AWS. You store secrets there via the AWS console or CLI, and Terraform reads them with data sources. This approach keeps the secret out of your HCL code and .tfvars files but still stores the value in Terraform state after it is read.
The state file itself is the elephant in the room. Regardless of how carefully you manage inputs, Terraform state contains every attribute of every resource, including sensitive outputs like database connection strings, private keys, and IAM access keys. This is why remote state with encryption at rest (S3 server-side encryption, Terraform Cloud encryption) and strict access controls is non-negotiable. In production, teams typically restrict state file access to CI/CD service accounts and a small group of senior engineers.
Environment variables (TF_VAR_name) provide another mechanism for passing secrets without files. CI/CD systems like GitHub Actions, GitLab CI, and Jenkins can inject secrets as environment variables that Terraform reads automatically. This avoids writing secrets to disk entirely, though they may appear in process listings or CI/CD audit logs.
For the most sensitive operations, teams combine multiple strategies: Vault generates dynamic credentials, Terraform reads them via data sources, the sensitive flag suppresses output, state is encrypted and access-controlled, and CI/CD pipelines run in ephemeral containers that are destroyed after each run.
Code Example
# Mark the database password variable as sensitive to suppress it in output
variable "payments_db_password" {
# Describe what this secret is used for
description = "Master password for the payments Aurora PostgreSQL cluster"
# Enforce string type for the password value
type = string
# Mark as sensitive so Terraform redacts it from plan and apply output
sensitive = true
# Validate minimum password complexity for production databases
validation {
condition = length(var.payments_db_password) >= 16
error_message = "Database password must be at least 16 characters for production."
}
}
# Read the database password from AWS Secrets Manager instead of tfvars
data "aws_secretsmanager_secret_version" "payments_db_password" {
# Reference the secret by its name in Secrets Manager
secret_id = "payments/prod/db-master-password"
}
# Read dynamic database credentials from HashiCorp Vault
data "vault_generic_secret" "payments_db_dynamic_creds" {
# Path in Vault where the database secrets engine generates credentials
path = "database/creds/payments-db-readwrite"
}
# Use the Vault-generated dynamic credentials for the RDS cluster
resource "aws_rds_cluster" "payments_db" {
# Unique cluster identifier for the payments database
cluster_identifier = "payments-db-prod"
# Use Aurora PostgreSQL for the payments workload
engine = "aurora-postgresql"
# Pin to a tested and approved engine version
engine_version = "15.4"
# Master username from Vault dynamic credentials
master_username = data.vault_generic_secret.payments_db_dynamic_creds.data["username"]
# Master password from Vault — automatically rotated and short-lived
master_password = data.vault_generic_secret.payments_db_dynamic_creds.data["password"]
# Place in private subnets only accessible within the VPC
db_subnet_group_name = aws_db_subnet_group.payments_private.name
# Storage encryption using a customer-managed KMS key
storage_encrypted = true
# Use a dedicated KMS key for the payments database encryption
kms_key_id = aws_kms_key.payments_db_encryption.arn
# Protect against accidental deletion
deletion_protection = true
# Tags for compliance and auditing
tags = {
Service = "payments-processing"
DataClass = "confidential"
EncryptionType = "CMK"
}
}
# Sensitive output that will be redacted in plan/apply console output
output "payments_db_connection_endpoint" {
# Describe what this output provides
description = "Connection endpoint for the payments database cluster"
# Output the cluster endpoint for application configuration
value = aws_rds_cluster.payments_db.endpoint
# Mark as sensitive to prevent exposure in logs and terminal output
sensitive = true
}
# Configure Vault provider to use AWS IAM authentication
provider "vault" {
# Vault server address — never hardcode in prod, use env var VAULT_ADDR
address = "https://vault.fintech-internal.com:8200"
# Use AWS IAM auth method for machine identity
auth_login {
path = "auth/aws/login"
method = "aws"
parameters = {
role = "terraform-payments-prod"
}
}
}Interview Tip
A junior engineer typically mentions the sensitive flag and stops there, not realizing it only masks output and does not encrypt state. Elevate your answer by explaining the full secret lifecycle: where secrets are stored (Vault, Secrets Manager), how they enter Terraform (data sources, environment variables), how they appear in output (sensitive flag), and critically, how they persist in state (plaintext, requiring encryption). Mention dynamic secrets from Vault as the gold standard because they automatically expire, limiting blast radius. The state file plaintext issue is the gotcha that separates engineers who have handled production secrets from those who have only read tutorials.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────┐
│ Secret Management Layers │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Layer 1: Secret Storage │ │
│ │ │ │
│ │ ┌──────────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ │ HashiCorp│ │ AWS │ │ AWS SSM │ │ │
│ │ │ Vault │ │ Secrets │ │ Parameter │ │ │
│ │ │ (dynamic)│ │ Manager │ │ Store │ │ │
│ │ └────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ │
│ └───────┼──────────────┼──────────────┼────────┘ │
│ └──────────────┼──────────────┘ │
│ │ │
│ ┌──────────────────────▼───────────────────────┐ │
│ │ Layer 2: Terraform Data Sources │ │
│ │ data.vault_generic_secret │ │
│ │ data.aws_secretsmanager_secret_version │ │
│ └──────────────────────┬───────────────────────┘ │
│ │ │
│ ┌──────────────────────▼───────────────────────┐ │
│ │ Layer 3: Sensitive Variable Flag │ │
│ │ variable { sensitive = true } │ │
│ │ output { sensitive = true } │ │
│ │ Plan output: (sensitive value) │ │
│ └──────────────────────┬───────────────────────┘ │
│ │ │
│ ┌──────────────────────▼───────────────────────┐ │
│ │ Layer 4: State File Protection │ │
│ │ ⚠ State stores values in PLAINTEXT │ │
│ │ → S3 encryption at rest (AES-256/KMS) │ │
│ │ → IAM policies restricting state access │ │
│ │ → Terraform Cloud state encryption │ │
│ └──────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘💬 Comments
Quick Answer
Count creates multiple resource instances indexed by number (0, 1, 2...), while for_each creates instances indexed by string keys from a map or set. for_each is preferred because removing an item from the middle does not force recreation of subsequent resources, unlike count where index shifts cause unnecessary destroys.
Detailed Answer
Both count and for_each are Terraform meta-arguments for creating multiple instances of a resource from a single block, but they differ fundamentally in how they identify and track instances. Understanding this difference prevents one of the most common Terraform disasters in production.
Think of count like numbered seats in a movie theater — seat 0, seat 1, seat 2. If the person in seat 1 leaves and everyone shifts down, the person who was in seat 2 is now in seat 1, and Terraform thinks they are a different person. Think of for_each like named reserved seats — 'Alice's seat', 'Bob's seat', 'Carol's seat'. If Bob leaves, Alice and Carol keep their seats unchanged.
With count, resources are identified by their numeric index: aws_iam_user.payments_engineers[0], aws_iam_user.payments_engineers[1], etc. When you define count = length(var.engineer_names) and the list is ["alice", "bob", "carol"], index 0 is Alice, 1 is Bob, 2 is Carol. If you remove Bob from the middle of the list, the new list becomes ["alice", "carol"]. Now index 1 maps to Carol instead of Bob. Terraform sees that index 1 changed from Bob to Carol and index 2 disappeared, so it plans to update index 1 (changing Bob's user to Carol's) and destroy index 2 (Carol's original user). In production, this means Carol's IAM user gets destroyed and recreated, losing all her attached policies, access keys, and MFA configuration.
With for_each, resources are identified by string keys: aws_iam_user.payments_engineers["alice"], aws_iam_user.payments_engineers["bob"], aws_iam_user.payments_engineers["carol"]. When you remove Bob, Terraform only destroys aws_iam_user.payments_engineers["bob"] — Alice and Carol are untouched because their keys have not changed. This is dramatically safer and more predictable.
for_each accepts either a map or a set of strings. When you pass a map, each.key gives you the map key and each.value gives you the map value, allowing you to associate configuration data with each instance. When you pass a set, each.key and each.value are both the set element. You cannot pass a list directly to for_each — you must convert it to a set with toset() because for_each requires unique keys.
There are still valid use cases for count. The most common is the conditional resource pattern: count = var.enable_monitoring ? 1 : 0 creates the resource only when the variable is true. This is clean, readable, and does not suffer from the index-shift problem because there is only one instance. count is also appropriate when creating a fixed number of identical resources where order does not matter and the count will never change — for example, three NAT gateways, one per availability zone, created in a predictable order.
A subtle gotcha with for_each is that the keys must be known at plan time. You cannot use for_each with a map whose keys come from a resource that has not been created yet — Terraform needs to know all the instance keys to build the dependency graph. If you hit this limitation, you may need to restructure your code or use a targeted apply to create the dependency first.
In modern Terraform, the best practice is to default to for_each for collections and reserve count for boolean conditionals. This prevents the index-shift problem and makes your code more self-documenting since each instance has a meaningful name instead of a number.
Code Example
# BAD: Using count — removing an engineer causes index shift chaos
# If we remove "bob" from this list, "carol" shifts from index 2 to 1
# Terraform will destroy carol's user and recreate it, losing all policies
variable "payment_engineers_list" {
# List of engineers who need access to the payments infrastructure
description = "Engineers on the payments team requiring IAM access"
# Use list type which maintains insertion order
type = list(string)
# Default list of current team members
default = ["alice.chen", "bob.kumar", "carol.martinez"]
}
# Dangerous: count-based IAM users indexed by position number
resource "aws_iam_user" "payments_engineers_bad" {
# Create one IAM user per engineer in the list
count = length(var.payment_engineers_list)
# Name is derived from the list element at this index position
name = var.payment_engineers_list[count.index]
# Path organizes users under the payments team in IAM
path = "/payments-team/"
# Tags for user management and auditing
tags = {
Team = "payments-backend"
ManagedBy = "terraform"
}
}
# GOOD: Using for_each — removing an engineer only affects that one user
# Bob can be removed without impacting Alice or Carol at all
variable "payment_engineers_map" {
# Map of engineers with their role for granular access control
description = "Payments team engineers mapped to their database access role"
# Use map type so each engineer has a stable string key
type = map(object({
role = string
db_access = string
}))
# Each key is the engineer's username — stable across additions/removals
default = {
"alice.chen" = {
role = "senior-engineer"
db_access = "readwrite"
}
"bob.kumar" = {
role = "engineer"
db_access = "readonly"
}
"carol.martinez" = {
role = "lead-engineer"
db_access = "admin"
}
}
}
# Safe: for_each-based IAM users indexed by engineer username
resource "aws_iam_user" "payments_engineers" {
# Iterate over the map — each engineer gets a stable keyed instance
for_each = var.payment_engineers_map
# each.key is the engineer's username (e.g., "alice.chen")
name = each.key
# Path organizes users under the payments team in IAM
path = "/payments-team/"
# Tags include role information from the map value
tags = {
Team = "payments-backend"
Role = each.value.role
DBAccess = each.value.db_access
ManagedBy = "terraform"
}
}
# Conditional resource using count — the one valid use case for count
resource "aws_cloudwatch_metric_alarm" "payments_db_cpu_alarm" {
# Create this alarm only if monitoring is enabled for this environment
count = var.enable_db_monitoring ? 1 : 0
# Name the alarm after the payments database and metric
alarm_name = "payments-db-prod-cpu-utilization-high"
# Monitor the CPUUtilization metric from the RDS namespace
namespace = "AWS/RDS"
metric_name = "CPUUtilization"
# Alert when average CPU exceeds 80% over 5 minutes
comparison_operator = "GreaterThanThreshold"
threshold = 80
evaluation_periods = 3
period = 300
statistic = "Average"
# Send alerts to the payments team's SNS topic
alarm_actions = [aws_sns_topic.payments_alerts.arn]
# Dimension to scope the alarm to the specific RDS cluster
dimensions = {
DBClusterIdentifier = aws_rds_cluster.payments_db.cluster_identifier
}
}Interview Tip
A junior engineer typically knows the basic syntax difference but cannot explain why for_each is safer. The interview-winning answer walks through the index-shift scenario step by step: start with three engineers using count, remove the middle one, and show how Terraform's plan incorrectly destroys and recreates the wrong resources. Then contrast with for_each where only the removed key is affected. Mention that count is still valid for the boolean conditional pattern (count = var.enabled ? 1 : 0). The for_each keys-must-be-known-at-plan-time limitation is an advanced gotcha that shows deep experience if you can explain when you hit it and how you worked around it.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────┐ │ count: Index-based identification (DANGEROUS for lists) │ │ │ │ Before removing Bob: After removing Bob: │ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │ │ [0] → alice.chen │ │ [0] → alice.chen │ │ │ │ [1] → bob.kumar │ │ [1] → carol.martinez│ │ │ │ [2] → carol.martinez│ │ [2] → (destroyed!) │ │ │ └─────────────────────┘ └─────────────────────┘ │ │ │ │ Plan: ~ update [1] bob→carol, - destroy [2] carol │ │ Result: Carol loses all IAM policies and MFA config! │ └───────────────────────────────────────────────────────────┘ ┌───────────────────────────────────────────────────────────┐ │ for_each: Key-based identification (SAFE) │ │ │ │ Before removing Bob: After removing Bob: │ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │ │ ["alice"] → user A │ │ ["alice"] → user A │ │ │ │ ["bob"] → user B │ │ ["bob"] → (gone) │ │ │ │ ["carol"] → user C │ │ ["carol"] → user C │ │ │ └─────────────────────┘ └─────────────────────┘ │ │ │ │ Plan: - destroy ["bob"] only │ │ Result: Alice and Carol completely untouched! │ └───────────────────────────────────────────────────────────┘ ┌───────────────────────────────────────────────────────────┐ │ count: Valid use case — boolean conditional │ │ │ │ var.enable_monitoring = true │ = false │ │ ┌────────────────────┐ │ ┌──────────────────┐ │ │ │ [0] → alarm exists │ │ │ (no instances) │ │ │ └────────────────────┘ │ └──────────────────┘ │ └───────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Terraform providers are plugins that translate HCL resource definitions into API calls for specific infrastructure platforms. They are distributed as separate binaries, downloaded during terraform init from registries, and communicate with Terraform core via a gRPC protocol. Each provider manages its own authentication, resource CRUD operations, and schema.
Detailed Answer
Terraform's provider plugin system is the architectural foundation that makes Terraform extensible to any infrastructure platform. Think of Terraform core as a universal remote control — it understands the concept of channels, volume, and power, but it needs a specific adapter (provider plugin) for each TV brand. The AWS provider is the adapter for AWS, the Azure provider for Azure, and the Kubernetes provider for Kubernetes. Without providers, Terraform core cannot manage any infrastructure.
Internally, providers are standalone Go binaries that communicate with Terraform core via gRPC (Google Remote Procedure Call) over a local socket. When you run terraform init, Terraform reads the required_providers block, downloads the specified provider binaries from the configured registry (default: registry.terraform.io), verifies their SHA256 checksums against signed hash files, and stores them in the .terraform/providers directory. The provider binary is platform-specific — there are separate builds for linux_amd64, darwin_arm64, and other OS/architecture combinations.
When terraform plan or apply runs, Terraform core starts each required provider as a child process. The provider plugin registers its resource types and data source types, along with their schemas — essentially telling Terraform core 'I can manage these resource types, and here are the attributes each one supports.' Terraform core then calls provider functions over gRPC for specific operations: ValidateResourceConfig (check if the HCL is valid), PlanResourceChange (compute what would change), ApplyResourceChange (make the actual API call), ReadResource (refresh current state), and ImportResourceState (handle imports).
Provider version constraints are crucial for stability. The required_providers block lets you pin versions using constraint syntax: version = "~> 5.0" means any 5.x version but not 6.0. Without version constraints, terraform init downloads the latest version, which might include breaking changes. The .terraform.lock.hcl file (dependency lock file) records the exact versions and checksums of providers used, ensuring consistent installations across team members and CI/CD environments. This file should be committed to version control.
Provider configuration blocks handle authentication and regional settings. The AWS provider, for example, accepts region, profile, assume_role, and other authentication parameters. You can configure multiple instances of the same provider using aliases — for example, one AWS provider for us-east-1 and another aliased provider for eu-west-1. Resources can then specify which provider alias to use with the provider meta-argument.
The provider ecosystem is vast. HashiCorp maintains official providers for major platforms (AWS, Azure, GCP, Kubernetes). Partner providers are maintained by third-party companies but listed on the registry. Community providers are maintained by individuals. You can also write custom providers using the Terraform Plugin SDK or the newer Terraform Plugin Framework, which is useful for internal platforms or APIs without public providers.
One production consideration is provider caching and air-gapped environments. In restricted networks, you cannot download providers from the public registry. Terraform supports filesystem mirrors, network mirrors, and the terraform providers mirror command to pre-download providers for offline use. Many enterprises run an internal Artifactory or Nexus instance as a provider mirror.
Code Example
# Required providers block specifying exact version constraints
terraform {
# Declare all providers this configuration depends on
required_providers {
# AWS provider for managing payments infrastructure
aws = {
# Official HashiCorp AWS provider from the Terraform registry
source = "hashicorp/aws"
# Pin to 5.x — allow minor and patch updates but not major version 6
version = "~> 5.40"
}
# Vault provider for dynamic secret retrieval
vault = {
# Official HashiCorp Vault provider
source = "hashicorp/vault"
# Pin to a specific minor version range for Vault integration stability
version = "~> 3.25"
}
# Datadog provider for monitoring and alerting
datadog = {
# Partner provider maintained by Datadog
source = "DataDog/datadog"
# Pin to 3.x for API compatibility with our Datadog org
version = "~> 3.38"
}
}
# Require Terraform 1.5+ for import blocks and other modern features
required_version = ">= 1.5.0"
}
# Primary AWS provider for us-east-1 where payments infrastructure lives
provider "aws" {
# Deploy payments resources in the primary region
region = "us-east-1"
# Use an IAM role for Terraform operations instead of static credentials
assume_role {
role_arn = "arn:aws:iam::role/terraform-payments-deployer"
session_name = "terraform-payments-prod"
external_id = "payments-tf-deploy-2024"
}
# Default tags applied to every resource this provider creates
default_tags {
tags = {
ManagedBy = "terraform"
Team = "payments-backend"
Environment = "production"
}
}
}
# Aliased AWS provider for disaster recovery region
provider "aws" {
# Alias allows referencing this as aws.disaster_recovery in resources
alias = "disaster_recovery"
# DR region for cross-region replication and failover
region = "us-west-2"
# Use the same role but in the DR region context
assume_role {
role_arn = "arn:aws:iam::role/terraform-payments-deployer"
session_name = "terraform-payments-dr"
external_id = "payments-tf-deploy-2024"
}
# Same default tags for consistent resource tagging in DR
default_tags {
tags = {
ManagedBy = "terraform"
Team = "payments-backend"
Environment = "production-dr"
}
}
}
# S3 bucket for payment receipt storage in the primary region
resource "aws_s3_bucket" "payment_receipts" {
# Bucket name follows the org-service-env-region convention
bucket = "fintech-payment-receipts-prod-us-east-1"
# Tags specific to this bucket's purpose
tags = {
Service = "payment-receipts"
DataClass = "financial-pii"
}
}
# S3 bucket replica in DR region using the aliased provider
resource "aws_s3_bucket" "payment_receipts_replica" {
# Explicitly use the disaster_recovery aliased provider
provider = aws.disaster_recovery
# Mirror bucket name with the DR region suffix
bucket = "fintech-payment-receipts-prod-us-west-2"
# Tags indicate this is a DR replica
tags = {
Service = "payment-receipts"
DataClass = "financial-pii"
ReplicaOf = "fintech-payment-receipts-prod-us-east-1"
}
}Interview Tip
A junior engineer typically says 'providers connect Terraform to cloud platforms' which is surface-level. Demonstrate deeper understanding by explaining the gRPC communication protocol between core and plugins, the provider lifecycle during init and plan/apply, and why the .terraform.lock.hcl file matters for team consistency. Discuss version constraint syntax and why pinning matters — a real scenario where a provider update broke your pipeline is compelling. Mentioning provider aliases for multi-region deployments shows practical experience. If you can explain how to handle air-gapped environments with provider mirrors, that signals enterprise-grade Terraform knowledge that most candidates lack.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────┐ │ terraform init │ │ │ │ ┌─────────────────┐ ┌──────────────────────┐ │ │ │ required_ │ │ Terraform Registry │ │ │ │ providers block │───→│ registry.terraform.io│ │ │ │ aws ~> 5.40 │ │ │ │ │ │ vault ~> 3.25 │ │ ┌──────────────────┐ │ │ │ └─────────────────┘ │ │ Provider Binary │ │ │ │ │ │ + SHA256 Hash │ │ │ │ │ │ + GPG Signature │ │ │ │ │ └────────┬─────────┘ │ │ │ └──────────┼───────────┘ │ │ │ │ │ ┌──────────▼───────────┐ │ │ │ .terraform/providers/│ │ │ │ hashicorp/aws/5.40.0 │ │ │ │ hashicorp/vault/3.25 │ │ │ └──────────────────────┘ │ └───────────────────────────────────────────────────────────┘ ┌───────────────────────────────────────────────────────────┐ │ terraform plan / apply │ │ │ │ ┌─────────────────┐ ┌─────────────────┐ │ │ │ Terraform Core │ gRPC │ Provider Plugin │ │ │ │ │◄───────►│ (child process) │ │ │ │ Reads HCL │ │ │ │ │ │ Builds Graph │ │ ValidateConfig │ │ │ │ Manages State │ │ PlanChange │ │ │ │ │ │ ApplyChange │ │ │ └────────┬────────┘ │ ReadResource │ │ │ │ │ ImportState │ │ │ │ └────────┬────────┘ │ │ │ │ │ │ │ ┌────────▼────────┐ │ │ │ │ Cloud Provider │ │ │ │ │ API (AWS/GCP/ │ │ │ │ │ Azure/etc.) │ │ │ │ └─────────────────┘ │ │ │ │ │ ┌────────▼────────┐ │ │ │ .terraform. │ │ │ │ lock.hcl │ │ │ │ (exact versions │ │ │ │ + checksums) │ │ │ └─────────────────┘ │ └───────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Create a single set of Terraform modules and root configurations, then use per-environment .tfvars files (dev.tfvars, qa.tfvars, uat.tfvars, prod.tfvars) to inject environment-specific values like instance sizes, replica counts, and CIDR blocks. The terraform plan -var-file flag selects the correct file, keeping code DRY while allowing each environment to diverge on sizing and configuration.
Detailed Answer
Using tfvars files for multi-environment management is like having a single restaurant menu (Terraform code) with portion size options (tfvars): the recipe stays the same whether you order a small (dev), medium (qa), large (uat), or extra-large (prod) — only the quantities change. This pattern eliminates code duplication while giving each environment its own tunable parameters.
The variable definition layer starts in variables.tf, where you declare every configurable parameter with a type constraint, description, and optionally a default value. Variables without defaults are mandatory and must be provided by the tfvars file. Use object types for related parameters: instead of separate variables for each RDS setting, create a variable of type object({ instance_class = string, allocated_storage = number, multi_az = bool, backup_retention_days = number }) called database_config. This groups related values and makes tfvars files self-documenting.
The tfvars files live in an environments/ directory: environments/dev.tfvars, environments/qa.tfvars, environments/uat.tfvars, environments/prod.tfvars. Each file sets the same variables with environment-appropriate values. Dev might use db.t3.medium with 20GB storage and single-AZ, while Prod uses db.r6g.2xlarge with 500GB and multi-AZ enabled. The key principle is that tfvars files contain only values, never logic. Conditional logic belongs in the Terraform code itself using ternary expressions or lookup maps.
The CI/CD pipeline selects the correct tfvars file based on the target environment. A typical command is terraform plan -var-file=environments/prod.tfvars -out=prod.tfplan. The pipeline determines the environment from the Git branch (feature/* uses dev.tfvars, release/* uses uat.tfvars, main uses prod.tfvars) or from a pipeline variable. This pattern integrates cleanly with the approval workflow: the plan output shows exactly what will change in the target environment before anyone approves.
For complex configurations, layer multiple tfvars files. Use a common.tfvars for values shared across all environments (organization name, DNS domain, tagging standards), then overlay environment-specific files: terraform plan -var-file=common.tfvars -var-file=environments/prod.tfvars. Later files override values from earlier ones, so environment-specific settings take precedence over common defaults.
The critical gotcha is tfvars file drift. Over time, someone adds a new variable to dev.tfvars but forgets to add it to prod.tfvars. Without a default value, the prod plan fails. Prevent this by treating tfvars files as a matrix: every variable declared without a default must appear in every tfvars file. Enforce this with a CI check that parses variables.tf for required variables and validates their presence in all tfvars files. Another gotcha is accidentally committing sensitive values (database passwords, API keys) in tfvars files. Sensitive values should come from environment variables (TF_VAR_*), HashiCorp Vault, or AWS Secrets Manager data sources — never from tfvars files in version control.
Code Example
# variables.tf — Declare all configurable parameters
# Environment identifier used in resource naming
variable "environment" {
description = "Target deployment environment"
type = string
# Validate against allowed environment names
validation {
condition = contains(["dev", "qa", "uat", "prod"], var.environment)
error_message = "Environment must be one of: dev, qa, uat, prod."
}
}
# VPC CIDR block — different per environment to allow peering
variable "vpc_cidr" {
description = "CIDR block for the payments VPC"
type = string
}
# Database configuration object — groups all RDS parameters
variable "database_config" {
description = "RDS cluster configuration for the payments database"
type = object({
instance_class = string
instance_count = number
allocated_storage = number
multi_az = bool
backup_retention_days = number
})
}
# EKS node group scaling parameters
variable "node_scaling" {
description = "Scaling configuration for EKS application node group"
type = object({
min_size = number
desired_size = number
max_size = number
instance_type = string
})
}
# environments/dev.tfvars — Development environment values
# Environment name used in all resource naming and tagging
# environment = "dev"
# Small VPC for development workloads (non-overlapping with other envs)
# vpc_cidr = "10.1.0.0/16"
# Minimal database for developer testing
# database_config = {
# instance_class = "db.t3.medium"
# instance_count = 1
# allocated_storage = 20
# multi_az = false
# backup_retention_days = 1
# }
# Small node group for dev workloads
# node_scaling = {
# min_size = 1
# desired_size = 2
# max_size = 4
# instance_type = "t3.large"
# }
# environments/prod.tfvars — Production environment values
# environment = "prod"
# Large VPC for production workloads with room for growth
# vpc_cidr = "10.4.0.0/16"
# Production-grade database with HA and compliance retention
# database_config = {
# instance_class = "db.r6g.2xlarge"
# instance_count = 3
# allocated_storage = 500
# multi_az = true
# backup_retention_days = 30
# }
# Production node group sized for payment processing SLA
# node_scaling = {
# min_size = 3
# desired_size = 6
# max_size = 15
# instance_type = "m5.2xlarge"
# }
# CI/CD pipeline usage — selecting the correct tfvars file
# terraform init -backend-config=backends/prod.hcl
# terraform plan -var-file=environments/prod.tfvars -out=prod.tfplan
# terraform apply prod.tfplanInterview Tip
A junior engineer typically answers by showing flat variable files with individual string values and does not address how to keep tfvars files in sync across environments. But for a senior role, the interviewer is actually looking for structured variable types (objects and maps) that group related parameters, layered tfvars files (common.tfvars overlaid with environment-specific files), and CI validation that every required variable appears in every tfvars file. Mention why sensitive values must never live in tfvars files — use TF_VAR_* environment variables or Vault data sources instead. Discuss the -var-file flag in CI/CD pipelines and how branch-based environment selection works.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐ │ tfvars-Based Environment Management │ ├───────────────────────────────────────────────────────────────┤ │ │ │ Single Codebase: │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ main.tf │ variables.tf │ outputs.tf │ modules/ │ │ │ │ (same code for all environments) │ │ │ └──────────────────────┬──────────────────────────────┘ │ │ │ │ │ ┌───────────┴───────────┐ │ │ │ terraform plan │ │ │ │ -var-file=??? │ │ │ └───────────┬───────────┘ │ │ │ │ │ ┌────────────┬───────┴──────┬──────────────┐ │ │ ↓ ↓ ↓ ↓ │ │ ┌──────────┐┌──────────┐┌──────────┐┌──────────────┐ │ │ │dev.tfvars││qa.tfvars ││uat.tfvars││prod.tfvars │ │ │ │ ││ ││ ││ │ │ │ │t3.large ││t3.xlarge ││m5.xlarge ││m5.2xlarge │ │ │ │1 replica ││2 replicas││2 replicas││3 replicas │ │ │ │20GB disk ││50GB disk ││100GB disk││500GB disk │ │ │ │no multi- ││no multi- ││multi-az ││multi-az │ │ │ │az ││az ││ ││30-day backup │ │ │ │1-day bkp ││3-day bkp ││7-day bkp ││ │ │ │ └──────────┘└──────────┘└──────────┘└──────────────┘ │ │ │ │ Layered Override: │ │ terraform plan \ │ │ -var-file=common.tfvars \ ← shared values │ │ -var-file=environments/prod.tfvars ← env overrides │ └───────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Terraform workspaces use a single configuration with separate state files selected by workspace name (terraform workspace select prod), while directory-based separation duplicates the configuration into per-environment directories (envs/dev/, envs/prod/) each with their own state. Workspaces are simpler but share all code paths; directories offer full isolation but require synchronization effort.
Detailed Answer
Choosing between workspaces and directory-based separation is like choosing between a multi-tenant apartment building (workspaces) and separate houses (directories). The apartment building shares plumbing and electrical systems — cheaper and easier to maintain, but a pipe burst affects everyone. Separate houses cost more to build and maintain, but a problem in one house never reaches another.
Terraform workspaces create named instances of state within the same backend configuration. When you run terraform workspace new qa, Terraform creates a new state file at env:/qa/terraform.tfstate in your backend (S3 key prefix changes to include the workspace name). The configuration stays identical — same main.tf, same modules — and you use terraform.workspace in conditionals or lookups to vary behavior. For example, locals { instance_type = terraform.workspace == "prod" ? "m5.2xlarge" : "t3.large" }. The advantage is zero code duplication: a module upgrade applies to all environments by running apply in each workspace sequentially.
Directory-based separation creates independent root configurations per environment: infrastructure/envs/dev/main.tf, infrastructure/envs/prod/main.tf. Each directory has its own backend configuration, its own provider setup, and its own terraform.tfvars. Shared logic lives in modules referenced by relative path or registry source. The directories might look identical initially, but they can diverge intentionally — Prod might have a WAF module that Dev does not, or Dev might test a newer provider version before promoting it.
The workspace approach has several tradeoffs. On the positive side: single source of truth for infrastructure code, atomic module upgrades, and less repository sprawl. On the negative side: a syntax error in main.tf breaks all environments simultaneously, there is no way to run different Terraform or provider versions per workspace, and terraform.workspace conditionals scattered through the code create hidden complexity. Most critically, a careless terraform apply in the wrong workspace can destroy production — there is no structural guardrail preventing this, only the workspace prompt.
Directory-based separation trades DRY for safety. Each environment is fully independent: you can upgrade Dev to Terraform 1.8 while Prod stays on 1.7, you can test a new module version in QA without touching UAT, and a broken configuration in one directory cannot affect another. The cost is synchronization: when you fix a bug in the Dev VPC module call, you must remember to propagate the fix to QA, UAT, and Prod directories. Tooling like Terragrunt mitigates this by generating directory-based configurations from a DRY template, giving you the isolation of directories with the maintainability of workspaces.
The production recommendation for most teams at Value Momentum's scale is a hybrid approach: use directory-based separation for major infrastructure boundaries (networking, EKS cluster, databases) and workspaces within each directory for environment selection only when the configurations are truly identical. Never use workspaces as a substitute for separate AWS accounts — they operate within a single provider configuration and do not provide IAM or network isolation.
Code Example
# Workspace-based approach — single configuration, multiple states
# main.tf — same code serves all environments
locals {
# Map workspace names to environment-specific configurations
env_config = {
dev = {
instance_type = "t3.large"
min_nodes = 1
max_nodes = 3
db_instance = "db.t3.medium"
multi_az = false
}
qa = {
instance_type = "t3.xlarge"
min_nodes = 2
max_nodes = 4
db_instance = "db.t3.large"
multi_az = false
}
prod = {
instance_type = "m5.2xlarge"
min_nodes = 3
max_nodes = 15
db_instance = "db.r6g.2xlarge"
multi_az = true
}
}
# Select the configuration for the current workspace
current = local.env_config[terraform.workspace]
}
# RDS cluster using workspace-driven configuration
resource "aws_rds_cluster" "payments_db" {
# Cluster name includes workspace for uniqueness
cluster_identifier = "payments-db-${terraform.workspace}"
# Engine and version are environment-agnostic
engine = "aurora-postgresql"
engine_version = "15.4"
# Instance class from the workspace-specific config map
# (applied to cluster instances, shown here for clarity)
# Multi-AZ driven by workspace config
# Note: Aurora handles AZ distribution via cluster instances
deletion_protection = terraform.workspace == "prod"
backup_retention_period = terraform.workspace == "prod" ? 30 : 7
}
# Workspace CLI commands
# terraform workspace new dev
# terraform workspace new qa
# terraform workspace new prod
# terraform workspace select prod
# terraform plan -out=prod.tfplan
# terraform apply prod.tfplan
# ─────────────────────────────────────────────────────
# Directory-based approach — separate configs per environment
# infrastructure/envs/dev/backend.tf
# terraform {
# backend "s3" {
# bucket = "valuemomentum-terraform-state"
# key = "dev/payments/terraform.tfstate"
# region = "us-east-1"
# dynamodb_table = "terraform-locks"
# encrypt = true
# }
# }
# infrastructure/envs/prod/backend.tf
# terraform {
# backend "s3" {
# bucket = "valuemomentum-terraform-state"
# key = "prod/payments/terraform.tfstate"
# region = "us-east-1"
# dynamodb_table = "terraform-locks"
# encrypt = true
# }
# }
# infrastructure/envs/prod/main.tf
# module "payments_vpc" {
# source = "../../modules/vpc"
# vpc_name = "payments-vpc-prod"
# vpc_cidr = "10.4.0.0/16"
# environment = "prod"
# }Interview Tip
A junior engineer typically picks one approach (usually workspaces because they are simpler) without articulating the tradeoffs. But for a senior role, the interviewer is actually looking for a nuanced comparison. Explain that workspaces share everything — Terraform version, provider versions, code paths — which means a broken main.tf breaks all environments simultaneously. Contrast this with directory-based separation where each environment can evolve independently. Mention Terragrunt as a hybrid solution that generates directory-based configurations from DRY templates. The critical insight is that workspaces do not provide IAM or network isolation — they are a state management convenience, not a security boundary. Share a real scenario where running apply in the wrong workspace caused an incident.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐ │ Workspaces vs Directory-Based Environment Separation │ ├───────────────────────────────────────────────────────────────┤ │ │ │ Workspace Approach: │ │ ┌─────────────────────────────────────────────┐ │ │ │ Single Configuration (main.tf) │ │ │ │ ┌──────────────────────────────────────┐ │ │ │ │ │ terraform.workspace → selects state │ │ │ │ │ └──────────────────────────────────────┘ │ │ │ │ │ │ │ │ │ │ │ ↓ ↓ ↓ │ │ │ │ ┌───────┐ ┌───────┐ ┌───────┐ │ │ │ │ │ dev │ │ qa │ │ prod │ states │ │ │ │ │ state │ │ state │ │ state │ │ │ │ │ └───────┘ └───────┘ └───────┘ │ │ │ └─────────────────────────────────────────────┘ │ │ Pros: DRY, single upgrade path │ │ Cons: shared code = shared failures, no version isolation │ │ │ │ Directory Approach: │ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ │ envs/dev/ │ │ envs/qa/ │ │ envs/prod/│ │ │ │ │ │ │ │ │ │ │ │ main.tf │ │ main.tf │ │ main.tf │ │ │ │ backend.tf│ │ backend.tf│ │ backend.tf│ │ │ │ dev.tfvars│ │ qa.tfvars │ │ prod.tfvar│ │ │ │ │ │ │ │ + WAF mod │ │ │ │ TF 1.7 │ │ TF 1.7 │ │ TF 1.7 │ │ │ │ state: dev│ │ state: qa │ │ state:prod│ │ │ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ │ │ │ │ │ │ └──────────────┴──────────────┘ │ │ │ │ │ ┌───────┴───────┐ │ │ │ Shared Modules│ │ │ │ modules/vpc │ │ │ │ modules/eks │ │ │ │ modules/rds │ │ │ └───────────────┘ │ │ Pros: full isolation, independent versions │ │ Cons: code duplication, sync overhead │ └───────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Use a single monorepo with environment-specific directories (envs/dev, envs/prod) and shared modules, combined with a trunk-based branching strategy where main is always deployable. Feature branches target dev first, promotion happens through directory-level changes, and protected branch rules prevent direct pushes to main without PR approval and passing CI checks.
Detailed Answer
Choosing between a monorepo and multi-repo for Terraform is like choosing between a single warehouse with organized aisles (monorepo) versus separate warehouses per product line (multi-repo). The single warehouse is easier to navigate and keeps inventory consistent, but a forklift accident in one aisle can block the whole building. Separate warehouses provide isolation but make it harder to share parts and coordinate shipments.
The monorepo approach stores all Terraform code in a single repository: shared modules in modules/, environment configurations in envs/dev/, envs/qa/, envs/prod/, and CI/CD pipeline definitions alongside the code. The primary advantage is atomic changes: when you update a shared module and its callers, everything is in one PR, reviewable together. Cross-cutting concerns like provider version upgrades, backend configuration changes, or tagging policy updates are a single commit. Module development and consumption happen in the same repository, eliminating the version publishing ceremony.
The multi-repo approach creates separate repositories per environment or per infrastructure domain: infra-networking, infra-eks, infra-databases. Each repository has its own CI/CD pipeline, its own access controls, and its own release cadence. The advantage is strict blast radius isolation: a broken CI pipeline in the networking repo cannot block database deployments. Access control is more granular — you can give database administrators write access to infra-databases without exposing networking code.
For most teams at the scale of 4 environments (Dev, QA, UAT, Prod), the monorepo with environment directories is the pragmatic choice. The branching strategy that works best is trunk-based development with short-lived feature branches. Main (or trunk) is always deployable — it represents the current desired state of all environments. Engineers create feature branches from main, make changes in the relevant environment directory, open a PR, and the CI pipeline runs terraform plan against the affected environments.
The promotion flow works through directory-level changes, not branch-based promotion. An engineer develops a new VPC peering configuration in envs/dev/peering.tf, merges to main, and the dev pipeline applies it. After validation in dev, they create a new PR that adds the same configuration to envs/qa/peering.tf (possibly with different tfvars). This continues through UAT and Prod. Each environment change is a separate PR with its own review and approval cycle.
The critical branching anti-pattern to avoid is environment branches (dev branch, qa branch, prod branch) where you promote by merging dev into qa into prod. This creates merge conflicts, diverging code paths, and the nightmare scenario where a hotfix to prod must be cherry-picked back through all environment branches. Trunk-based development with directory separation eliminates this entirely.
For the multi-repo approach, use Git tags or releases to version shared modules. Consumer repositories reference modules via Git source URLs with pinned tags: source = "git::https://github.com/org/tf-modules.git//vpc?ref=v2.2.0". Promotion happens by bumping the version tag in each environment's module source, creating a clear audit trail of what version each environment runs.
Code Example
# Monorepo directory structure for Value Momentum
# infrastructure/
# ├── modules/ # Shared reusable modules
# │ ├── vpc/
# │ │ ├── main.tf
# │ │ ├── variables.tf
# │ │ └── outputs.tf
# │ ├── eks-cluster/
# │ ├── rds-aurora/
# │ └── security-baseline/
# ├── envs/ # Environment-specific roots
# │ ├── dev/
# │ │ ├── main.tf # Calls shared modules
# │ │ ├── backend.tf # Dev state backend config
# │ │ └── terraform.tfvars # Dev-specific values
# │ ├── qa/
# │ ├── uat/
# │ └── prod/
# │ ├── main.tf # Same modules, prod values
# │ ├── backend.tf # Prod state backend config
# │ └── terraform.tfvars # Prod-specific values
# ├── .github/
# │ └── workflows/
# │ └── terraform.yml # CI/CD pipeline
# └── CODEOWNERS # Require platform team review
# CODEOWNERS — enforce review policies per directory
# Require platform-admins approval for production changes
# /infrastructure/envs/prod/ @valuemomentum/platform-admins
# Require networking team approval for VPC changes
# /infrastructure/modules/vpc/ @valuemomentum/networking-team
# Any engineer can modify dev without special approval
# /infrastructure/envs/dev/ @valuemomentum/developers
# CI/CD pipeline — detect which environments changed
# .github/workflows/terraform.yml
# name: Terraform Multi-Environment Pipeline
# on:
# pull_request:
# paths: ['infrastructure/**']
# jobs:
# detect-changes:
# runs-on: ubuntu-latest
# outputs:
# dev_changed: ${{ steps.changes.outputs.dev }}
# prod_changed: ${{ steps.changes.outputs.prod }}
# steps:
# - uses: dorny/paths-filter@v3
# id: changes
# with:
# filters: |
# dev:
# - 'infrastructure/envs/dev/**'
# - 'infrastructure/modules/**'
# prod:
# - 'infrastructure/envs/prod/**'
# - 'infrastructure/modules/**'
#
# plan-dev:
# needs: detect-changes
# if: needs.detect-changes.outputs.dev_changed == 'true'
# runs-on: ubuntu-latest
# steps:
# - run: cd infrastructure/envs/dev && terraform plan
#
# plan-prod:
# needs: detect-changes
# if: needs.detect-changes.outputs.prod_changed == 'true'
# runs-on: ubuntu-latest
# environment: production-review
# steps:
# - run: cd infrastructure/envs/prod && terraform plan
# Module reference from environment directory
# infrastructure/envs/prod/main.tf
module "payments_vpc" {
# Relative path to shared module within the monorepo
source = "../../modules/vpc"
# Production VPC configuration
vpc_name = "payments-vpc-prod"
vpc_cidr = "10.4.0.0/16"
environment = "prod"
# Enable 3 NAT gateways for production HA
single_nat_gateway = false
}
module "payments_eks" {
# Relative path to shared EKS module
source = "../../modules/eks-cluster"
# Production cluster configuration
cluster_name = "payments-eks-prod"
vpc_id = module.payments_vpc.vpc_id
subnet_ids = module.payments_vpc.private_subnet_ids
# Production Kubernetes version
k8s_version = "1.29"
}Interview Tip
A junior engineer typically answers 'we use separate branches for each environment' — dev branch, qa branch, prod branch — without recognizing the merge hell this creates. But for a senior role, the interviewer is actually looking for trunk-based development with directory-based environment separation. Explain why environment branches are an anti-pattern: merge conflicts, diverging code, and cherry-pick nightmares for hotfixes. Describe the monorepo structure with CODEOWNERS for per-directory review policies, path-based CI triggers that only plan/apply changed environments, and the promotion workflow where each environment change is a separate PR rather than a branch merge. Mention that shared module changes trigger plans for all environments that consume them.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐ │ Monorepo Trunk-Based Branching Strategy │ ├───────────────────────────────────────────────────────────────┤ │ │ │ main (always deployable) │ │ ─────────●─────────●─────────●─────────●──────→ │ │ │ │ │ │ │ │ │ │ │ └── PR: add WAF to │ │ │ │ │ envs/prod/ │ │ │ │ │ │ │ │ │ └── PR: add peering to │ │ │ │ envs/uat/ │ │ │ │ │ │ │ └── PR: add peering to envs/qa/ │ │ │ │ │ └── PR: add peering to envs/dev/ │ │ (feature/vpc-peering branch) │ │ │ │ Promotion Flow (directory-based, NOT branch-based): │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ 1. PR: modify envs/dev/peering.tf → merge → apply │ │ │ │ 2. Validate in dev │ │ │ │ 3. PR: modify envs/qa/peering.tf → merge → apply │ │ │ │ 4. Validate in qa │ │ │ │ 5. PR: modify envs/uat/peering.tf → merge → apply │ │ │ │ 6. Validate in uat │ │ │ │ 7. PR: modify envs/prod/peering.tf → approve → apply│ │ │ └──────────────────────────────────────────────────────┘ │ │ │ │ Anti-Pattern (environment branches): │ │ dev ──────●──────●──────→ │ │ ╲ merge │ │ qa ────────────────●──────→ ← merge conflicts │ │ ╲ merge │ │ prod ────────────────●──────→ ← cherry-pick hell │ │ │ │ CI Trigger Matrix: │ │ ┌──────────────────┬────────────────────────┐ │ │ │ Path Changed │ Environments Planned │ │ │ ├──────────────────┼────────────────────────┤ │ │ │ envs/dev/** │ dev only │ │ │ │ envs/prod/** │ prod only │ │ │ │ modules/** │ ALL environments │ │ │ └──────────────────┴────────────────────────┘ │ └───────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Implement a naming convention module that generates consistent resource names using a pattern like {project}-{component}-{environment}-{region-short}. Pass environment as a variable, embed it in every resource name and tag, and use validation rules to enforce the convention. This prevents name collisions across environments sharing the same AWS account and makes resources identifiable in billing reports and the AWS console.
Detailed Answer
Naming conventions in Terraform are like street addresses in a city: without a consistent scheme, you end up with two buildings called 'Main Office' and no one knows which is production and which is staging until something breaks. A well-designed naming convention makes every resource self-describing — you should be able to look at a resource name in a CloudWatch alarm or a billing report and immediately know its environment, project, component, and owner.
The naming pattern should follow a hierarchical structure: {project}-{component}-{environment}-{region-short}. For example: payments-api-alb-prod-use1 (project=payments, component=api-alb, environment=prod, region=us-east-1 abbreviated). This pattern works across most AWS resource types, though some have character limits (S3 bucket names max at 63 characters, IAM role names at 64). Build a naming module that accepts these components and outputs formatted names, handling truncation and character restrictions per resource type.
The naming module centralizes convention enforcement. Every resource in your Terraform code calls this module to generate its name rather than constructing names inline. This ensures consistency: if the team decides to change the naming format (adding a cost center code, for example), you update one module and every resource follows suit. The module also handles AWS-specific constraints: S3 buckets cannot have underscores, security group names cannot start with sg-, and some resources require globally unique names while others only need account-level uniqueness.
Environment embedding is the critical collision-prevention mechanism. When Dev and QA share an AWS account (common in early-stage projects), every resource must include the environment in its name. Without this, a terraform apply in dev that creates a security group called payments-api-sg will conflict with the same apply in qa trying to create the same name. The conflict can manifest as Terraform errors (resource already exists) or worse — Terraform adopting the existing resource into its state, giving one environment control over another's security group.
Tag standardization complements naming. While names are visible in the console and logs, tags power cost allocation, automation, and compliance. Define a standard tag set: Environment, Project, Component, ManagedBy (always 'terraform'), Team, CostCenter. Use the AWS provider's default_tags block to automatically apply these to every resource without repeating them in each resource block. This guarantees no resource escapes tagging, even if an engineer forgets to add tags to a new resource.
The gotcha most teams hit is naming convention drift over time. Early resources use one pattern, new resources use an updated pattern, and the codebase becomes inconsistent. Prevent this by adding tflint rules or OPA policies that validate resource names against a regex pattern. Run these checks in CI so non-conforming names fail the pipeline before reaching any environment. Another gotcha is renaming existing resources: changing a resource name in Terraform causes a destroy-and-recreate cycle unless you use a moved block or terraform state mv to update the state mapping.
Code Example
# modules/naming/main.tf — Centralized naming convention module
# Input variables for name construction
variable "project" {
description = "Project identifier (e.g., payments, user-auth)"
type = string
# Enforce lowercase alphanumeric with hyphens only
validation {
condition = can(regex("^[a-z][a-z0-9-]{1,20}$", var.project))
error_message = "Project name must be lowercase alphanumeric with hyphens, 2-21 chars."
}
}
variable "component" {
description = "Component identifier (e.g., api-alb, eks-cluster, rds-primary)"
type = string
}
variable "environment" {
description = "Environment identifier (dev, qa, uat, prod)"
type = string
validation {
condition = contains(["dev", "qa", "uat", "prod"], var.environment)
error_message = "Environment must be one of: dev, qa, uat, prod."
}
}
variable "region" {
description = "AWS region for the short code suffix"
type = string
default = "us-east-1"
}
# Region short code mapping for compact names
locals {
# Map full region names to 4-character abbreviations
region_short = {
"us-east-1" = "use1"
"us-east-2" = "use2"
"us-west-2" = "usw2"
"eu-west-1" = "euw1"
}
# Standard name pattern: project-component-env-region
standard_name = "${var.project}-${var.component}-${var.environment}-${local.region_short[var.region]}"
# S3-safe name (no underscores, max 63 chars)
s3_name = substr(replace(local.standard_name, "_", "-"), 0, 63)
# Standard tag set applied to all resources
standard_tags = {
Project = var.project
Component = var.component
Environment = var.environment
ManagedBy = "terraform"
NamingVersion = "v2"
}
}
# Output the generated names and tags
output "standard_name" {
description = "Standard resource name"
value = local.standard_name
}
output "s3_name" {
description = "S3-safe resource name (no underscores, max 63 chars)"
value = local.s3_name
}
output "tags" {
description = "Standard tag set for all resources"
value = local.standard_tags
}
# Usage in root configuration — every resource uses the naming module
module "naming_vpc" {
# Reference the naming module
source = "./modules/naming"
project = "payments"
component = "vpc"
environment = var.environment
}
resource "aws_vpc" "payments_network" {
# Use the naming module output for the VPC name tag
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
tags = merge(module.naming_vpc.tags, {
# Name tag uses the standardized naming output
Name = module.naming_vpc.standard_name
})
}
module "naming_alb" {
source = "./modules/naming"
project = "payments"
component = "api-alb"
environment = var.environment
}
resource "aws_lb" "payments_api" {
# ALB name using the naming convention (max 32 chars for ALB)
name = substr(module.naming_alb.standard_name, 0, 32)
internal = false
load_balancer_type = "application"
subnets = var.public_subnet_ids
tags = module.naming_alb.tags
}Interview Tip
A junior engineer typically hardcodes resource names like 'my-vpc' or 'main-alb' without environment identifiers, leading to collisions when multiple environments share an account. But for a senior role, the interviewer is actually looking for a systematic naming strategy: a centralized naming module that generates consistent names from project, component, environment, and region inputs. Explain how the module handles AWS-specific constraints (S3 bucket character limits, ALB name length limits). Discuss how default_tags in the AWS provider guarantees tag coverage without per-resource repetition. Mention the rename gotcha — changing a resource name causes destroy/recreate unless you use moved blocks — and how tflint or OPA policies in CI enforce naming compliance before resources are created.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐
│ Terraform Naming Convention Architecture │
├───────────────────────────────────────────────────────────────┤
│ │
│ Naming Pattern: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ {project}-{component}-{environment}-{region-short} │ │
│ │ │ │
│ │ payments-api-alb-prod-use1 │ │
│ │ payments-vpc-dev-use1 │ │
│ │ user-auth-rds-qa-use1 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Naming Module Flow: │
│ ┌──────────────┐ │
│ │ Inputs: │ │
│ │ project │ ┌──────────────────┐ │
│ │ component │───→│ Naming Module │ │
│ │ environment │ │ │ │
│ │ region │ │ Validates input │ │
│ └──────────────┘ │ Applies rules │ │
│ │ Handles limits │ │
│ └────────┬─────────┘ │
│ │ │
│ ┌─────────────────┼─────────────────┐ │
│ ↓ ↓ ↓ │
│ ┌────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │standard_name│ │s3_name │ │standard_tags │ │
│ │(general) │ │(63 char max) │ │(Project, Env │ │
│ │ │ │(no _) │ │ Component) │ │
│ └────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ Collision Prevention: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Same account, different environments: │ │
│ │ │ │
│ │ payments-api-sg-dev-use1 ← unique, no collision │ │
│ │ payments-api-sg-qa-use1 ← unique, no collision │ │
│ │ payments-api-sg-prod-use1 ← unique, no collision │ │
│ │ │ │
│ │ Without env in name: │ │
│ │ payments-api-sg ← COLLISION across environments │ │
│ └──────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘💬 Comments
Quick Answer
Terraform Enterprise adds remote state management with RBAC, Sentinel policy-as-code for compliance enforcement, private module registries, team-based workspace access, and audit logging. Organizations need TFE when they require governance, collaboration at scale, and regulatory compliance that open-source cannot provide.
Detailed Answer
Think of open-source Terraform as a skilled carpenter with excellent tools who works alone from blueprints. Terraform Enterprise is a construction firm — it has the same carpenter, but adds project managers (RBAC), building inspectors (Sentinel policies), a parts catalog (private registry), apprentice supervision (workspace permissions), and a complete paper trail of every nail hammered (audit logs). A solo developer building a shed does not need a construction firm. A bank building a skyscraper absolutely does.
Open-source Terraform provides the core functionality: HCL language for defining infrastructure, a provider ecosystem for interacting with cloud APIs, state management, plan and apply workflows, and module reuse. When a single engineer or small team manages infrastructure, open-source Terraform with a remote state backend (S3 + DynamoDB) works well. The limitations emerge at scale: who can run terraform apply against production? How do you enforce that all S3 buckets have encryption enabled? How do you share vetted modules across 20 teams without everyone copy-pasting and diverging? How do you prove to auditors that every infrastructure change was reviewed, approved, and logged? Open-source Terraform has no answers to these questions — it is a tool, not a platform.
Terraform Enterprise addresses these gaps through several key features. Workspaces provide isolated environments with their own state, variables, and permissions — the payments-api infrastructure can be in one workspace with access limited to the payments team, while the settlements-db workspace is restricted to the database team. Sentinel policies run before every apply and enforce organizational rules as code — 'all RDS instances must have encryption at rest,' 'no IAM policies can use wildcard actions,' 'all resources must have cost-center and owner tags.' These policies are version-controlled, peer-reviewed, and provide automated compliance enforcement that auditors love. The private module registry lets the platform team publish vetted, hardened modules (like an approved EKS cluster configuration) that application teams consume — ensuring consistency without restricting autonomy.
Remote execution in TFE solves the 'works on my machine' problem. Instead of engineers running terraform apply from their laptops (with their personal AWS credentials and whatever Terraform version they have installed), all plans and applies execute in TFE's managed runners with consistent Terraform versions, standardized provider credentials (injected via workspace variables), and no local state. This eliminates credential sprawl — engineers never need direct AWS access for infrastructure changes, because TFE holds the credentials and applies changes on their behalf. For banking, this is a massive security improvement: credentials are centralized, rotated, and never touch developer machines.
In production at a bank, TFE becomes the control plane for all infrastructure changes. The typical workflow is: engineer creates a branch, makes infrastructure changes, opens a PR. TFE runs a speculative plan on the PR (visible as a GitHub check), showing exactly what will change. Team members review the plan diff alongside the code diff. After PR approval and merge, TFE runs the real plan and waits for workspace-level approval (configurable — some workspaces auto-apply, production workspaces require manual confirmation from an authorized approver). Sentinel policies gate the apply, and the entire execution is logged with timestamps, user identity, plan output, and apply results. These logs are retained for compliance and can be exported to SIEM systems for security monitoring.
The gotcha is cost and operational overhead. TFE is expensive — it requires either a self-hosted installation (on-premise or in your cloud account) or a Terraform Cloud Business subscription. Self-hosted TFE needs its own infrastructure (compute, database, object storage), monitoring, backup, and upgrades. Many organizations start with Terraform Cloud (the SaaS version) for its lower operational burden and migrate to self-hosted TFE only when data residency requirements or network isolation mandates require it. Another common mistake is over-governing — applying strict Sentinel policies and manual approval gates to every workspace, including development and sandbox environments, which slows down experimentation. Use tiered governance: strict policies and approvals for production, advisory-only policies for staging, and minimal controls for development.
Code Example
# Terraform Enterprise workspace configuration via Terraform
# (yes, you manage TFE with Terraform itself)
resource "tfe_organization" "bank" {
name = "bank-platform"
email = "[email protected]"
}
# Private module registry - vetted EKS module
resource "tfe_registry_module" "eks_cluster" {
organization = tfe_organization.bank.name
vcs_repo {
display_identifier = "bank/terraform-aws-eks-cluster"
identifier = "bank/terraform-aws-eks-cluster"
oauth_token_id = var.github_oauth_token_id
}
}
# Production workspace with approval gate
resource "tfe_workspace" "payments_infra_prod" {
name = "payments-infra-production"
organization = tfe_organization.bank.name
terraform_version = "1.7.0" # Pinned version
auto_apply = false # Require manual approval
queue_all_runs = true
working_directory = "environments/production"
vcs_repo {
identifier = "bank/payments-infrastructure"
branch = "main"
oauth_token_id = var.github_oauth_token_id
}
}
# RBAC - only senior engineers can approve production applies
resource "tfe_team" "payments_admins" {
name = "payments-infra-admins"
organization = tfe_organization.bank.name
}
resource "tfe_team_access" "payments_prod" {
access = "write" # Can queue plans and approve applies
team_id = tfe_team.payments_admins.id
workspace_id = tfe_workspace.payments_infra_prod.id
}
resource "tfe_team_access" "payments_dev" {
access = "plan" # Can only view plans, not approve
team_id = tfe_team.payments_developers.id
workspace_id = tfe_workspace.payments_infra_prod.id
}
---
# Sentinel policy set attached to production workspaces
resource "tfe_policy_set" "pci_dss_compliance" {
name = "pci-dss-compliance"
organization = tfe_organization.bank.name
kind = "sentinel"
enforcement_mode = "hard-mandatory" # Cannot override
vcs_repo {
identifier = "bank/sentinel-policies"
branch = "main"
oauth_token_id = var.github_oauth_token_id
}
workspace_ids = [
tfe_workspace.payments_infra_prod.id,
tfe_workspace.settlements_infra_prod.id,
]
}
---
# Comparing open-source vs TFE workflow
# Open-source: engineer runs locally
# $ terraform init # Downloads providers to laptop
# $ terraform plan # Uses personal AWS credentials
# $ terraform apply # No approval gate, no audit log
# $ terraform state pull # State in S3, no RBAC on who reads it
# TFE: governed workflow
# 1. Engineer pushes to branch → TFE speculative plan on PR
# 2. Peer reviews plan output in GitHub PR check
# 3. Merge to main → TFE queues real plan
# 4. Sentinel policies evaluate → hard-mandatory must pass
# 5. Authorized team member approves apply
# 6. TFE applies using centralized credentials
# 7. Full audit log: who, when, what changed, plan outputInterview Tip
A junior engineer typically says 'Terraform Enterprise is just Terraform with a UI' without understanding the governance and compliance capabilities that justify its cost. Show that you understand the specific pain points TFE solves: credential centralization (no AWS keys on laptops), Sentinel policy-as-code (automated compliance), private module registry (consistent vetted modules), and workspace RBAC (who can apply to production). In banking, emphasize the audit trail — every plan and apply is logged with user identity and timestamps, which is exactly what SOX and PCI-DSS auditors want to see. Mention tiered governance to show you balance security with developer velocity.
◈ Architecture Diagram
┌─────────────────────────────────────────────────────────────────┐ │ Open-Source Terraform vs Terraform Enterprise │ │ │ │ ┌─────────────────────────┐ ┌──────────────────────────────┐ │ │ │ Open-Source Terraform │ │ Terraform Enterprise │ │ │ │ │ │ │ │ │ │ ┌───────────────────┐ │ │ ┌────────────────────────┐ │ │ │ │ │ Local execution │ │ │ │ Remote execution │ │ │ │ │ │ Personal creds │ │ │ │ Centralized creds │ │ │ │ │ │ No approval gate │ │ │ │ Workspace approval │ │ │ │ │ │ S3 state (no RBAC)│ │ │ │ RBAC on state │ │ │ │ │ │ No policy engine │ │ │ │ Sentinel policies │ │ │ │ │ │ Copy-paste modules│ │ │ │ Private registry │ │ │ │ │ │ No audit trail │ │ │ │ Full audit logging │ │ │ │ │ └───────────────────┘ │ │ └────────────────────────┘ │ │ │ │ │ │ │ │ │ │ Good for: │ │ Good for: │ │ │ │ - Solo/small teams │ │ - Enterprise teams │ │ │ │ - Non-regulated envs │ │ - Regulated industries │ │ │ │ - Experimentation │ │ - Multi-team governance │ │ │ └─────────────────────────┘ └──────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────────────────────┐ │ │ │ TFE Workflow: PR → Speculative Plan → Sentinel Check │ │ │ │ → Merge → Real Plan → Approval → Apply → Audit Log │ │ │ └───────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Use Ansible Automation Platform (AAP) with separate execution environments for Windows (WinRM) and Linux (SSH), dynamic inventories from AWS and VMware, and standardized job templates with surveys for self-service provisioning. Playbooks handle OS hardening, agent installation, domain joining, and compliance baseline configuration.
Detailed Answer
Think of Ansible Automation Platform as a universal remote control for every server in your bank — whether it is a Red Hat Linux box in AWS, a Windows Server in your on-premise VMware cluster, or a SUSE machine in Azure. Without it, every team has their own scripts, their own credentials, and their own way of setting up servers. With AAP, there is one platform that speaks every server's language, follows the same playbooks, and logs every action for compliance.
Ansible Automation Platform (formerly Ansible Tower/AWX) provides the enterprise features that raw Ansible lacks: a web UI and API for managing automation, RBAC for controlling who can run what against which servers, credential management that securely stores SSH keys, WinRM credentials, and cloud API tokens, job scheduling for recurring tasks, and a comprehensive audit trail of every playbook execution. For a bank operating across hybrid cloud (AWS EC2, on-premise VMware, Azure VMs), AAP serves as the single automation control plane. Execution environments — containerized Ansible runtimes with pre-installed collections and dependencies — ensure consistent behavior regardless of where the playbook runs.
Windows and Linux provisioning differ at the connection layer but share the same automation framework. Linux servers use SSH with key-based authentication. Windows servers use WinRM (Windows Remote Management) with certificate-based or Kerberos authentication. AAP handles this transparently through separate credential types and execution environments. A Windows execution environment includes the ansible.windows and community.windows collections, while a Linux execution environment includes ansible.posix and community.general. The key insight is that the playbook structure is identical — the difference is in the modules used (win_feature vs yum, win_service vs systemd, win_copy vs copy).
A standardized provisioning workflow in AAP uses job templates with surveys. The survey presents a form to the requester: server name, operating system (RHEL 9, Windows Server 2022, Ubuntu 22.04), environment (dev, staging, production), team, application role, and cloud target (AWS, VMware, Azure). The job template triggers a workflow that provisions the VM (calling Terraform or the cloud API), waits for the server to become reachable, then runs the appropriate provisioning playbook based on the OS. The Linux playbook applies CIS benchmark hardening (firewall rules, password policies, auditd configuration, SSH hardening), installs monitoring agents (Datadog, Splunk forwarder), configures time synchronization, joins the server to FreeIPA for centralized authentication, and registers the server in the CMDB. The Windows playbook applies STIG hardening, installs monitoring agents, joins the Active Directory domain, configures Windows Firewall rules, enables PowerShell logging for audit compliance, and registers in SCCM.
In production at a bank, AAP integrates with the ITSM workflow. A developer requests a server through ServiceNow, which triggers the AAP API via webhook. AAP provisions and configures the server, then updates the ServiceNow ticket with the server details, IP address, and compliance scan results. The entire process — from ticket creation to a fully hardened, monitored, domain-joined server — takes 15-20 minutes instead of the 2-3 days it took with manual processes. For compliance, every AAP job execution is logged with the user who triggered it, the playbook that ran, the inventory targets, and the full output. These logs feed into Splunk for SIEM integration and are available for SOX and PCI-DSS audit reviews.
The biggest gotcha is credential management across hybrid cloud. AAP's credential types handle this — you create separate credentials for AWS (access key + secret key or IAM role assumption), VMware (vCenter username/password), Azure (service principal), Linux SSH (private key), and Windows WinRM (domain admin or local admin). These credentials are encrypted at rest in AAP's database and injected into playbook executions as environment variables — operators can use credentials without seeing them. Another gotcha is execution environment bloat — do not create one massive execution environment with every collection installed. Build purpose-specific environments (windows-provisioning, linux-hardening, cloud-networking) that are small, fast to pull, and easy to maintain. Finally, test playbooks with Molecule against both fresh and partially-configured servers to ensure idempotency — a playbook should produce the same result whether it runs on a brand-new server or one that was partially provisioned before a failure.
Code Example
# Ansible Automation Platform - Workflow Job Template
# Provisioning workflow: VM creation → OS hardening → Agent installation
#
# AAP Workflow (configured in UI/API):
# ┌──────────┐ ┌──────────────┐ ┌────────────────┐
# │ Provision │───→│ OS Hardening │───→│ Agent Install │
# │ VM (TF) │ │ CIS/STIG │ │ Monitor+SIEM │
# └──────────┘ └──────────────┘ └────────────────┘
# Linux provisioning playbook
# playbooks/provision-linux.yml
---
- name: Provision and harden Linux server
hosts: "{{ target_hosts }}"
become: true
vars:
compliance_standard: "cis-level2"
monitoring_agent: "datadog"
siem_agent: "splunk-forwarder"
roles:
- role: cis_hardening
vars:
cis_level: 2
cis_firewall_enable: true
cis_ssh_max_auth_tries: 3
cis_password_min_length: 14
- role: monitoring_agent
vars:
datadog_api_key: "{{ datadog_api_key }}"
datadog_tags:
- "env:{{ environment }}"
- "team:{{ team }}"
- "role:{{ server_role }}"
- role: splunk_forwarder
vars:
splunk_indexer: "splunk-indexer.bank.internal:9997"
splunk_index: "banking-infrastructure"
- role: freeipa_client
vars:
ipa_server: "ipa.bank.internal"
ipa_domain: "bank.internal"
---
# Windows provisioning playbook
# playbooks/provision-windows.yml
- name: Provision and harden Windows server
hosts: "{{ target_hosts }}"
vars:
ansible_connection: winrm
ansible_winrm_transport: kerberos
ansible_winrm_server_cert_validation: validate
tasks:
- name: Join Active Directory domain
microsoft.ad.membership:
dns_domain_name: bank.internal
domain_admin_user: "{{ ad_join_user }}"
domain_admin_password: "{{ ad_join_password }}"
domain_ou_path: "OU=Servers,OU=Banking,DC=bank,DC=internal"
state: domain
register: domain_join
- name: Reboot after domain join
ansible.windows.win_reboot:
when: domain_join.reboot_required
- name: Install required Windows features
ansible.windows.win_feature:
name:
- NET-Framework-45-Core
- Web-Server
state: present
- name: Apply Windows STIG hardening
ansible.windows.win_dsc:
resource_name: SecurityPolicy
name: AuditPolicy
AuditLogonEvents: Success,Failure
AuditAccountManagement: Success,Failure
- name: Enable PowerShell script block logging (compliance)
ansible.windows.win_regedit:
path: HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging
name: EnableScriptBlockLogging
data: 1
type: dword
- name: Install Datadog agent on Windows
ansible.windows.win_package:
path: https://s3.amazonaws.com/ddagent-windows-stable/datadog-agent-7-latest.amd64.msi
arguments: >-
APIKEY={{ datadog_api_key }}
TAGS="env:{{ environment }},team:{{ team }}"
state: present
- name: Install Splunk Universal Forwarder
ansible.windows.win_package:
path: "{{ splunk_forwarder_msi_url }}"
arguments: >-
RECEIVING_INDEXER="splunk-indexer.bank.internal:9997"
AGREETOLICENSE=yes
state: present
---
# Dynamic inventory for hybrid cloud
# inventory/hybrid_cloud.yml
---
plugin: constructed
strict: false
groups:
linux_servers: ansible_os_family == 'RedHat' or ansible_os_family == 'Debian'
windows_servers: ansible_os_family == 'Windows'
production: tags.Environment == 'production'
payments_team: tags.Team == 'payments'
# AWS dynamic inventory (separate file)
# inventory/aws_ec2.yml
plugin: amazon.aws.aws_ec2
regions: [us-east-1, us-west-2]
filters:
tag:ManagedBy: ansible
# VMware dynamic inventory (separate file)
# inventory/vmware.yml
plugin: community.vmware.vmware_vm_inventory
hostname: vcenter.bank.internal
validate_certs: true
with_tags: true
filters:
- config.template == false # Exclude templatesInterview Tip
A junior engineer typically focuses only on Linux and mentions Ansible ad-hoc commands without addressing enterprise concerns like RBAC, credential management, and audit trails. Show breadth by explaining how AAP handles both Windows (WinRM/Kerberos) and Linux (SSH) with the same automation framework but different execution environments and module sets. Discuss the self-service model — ServiceNow triggers AAP via API, and a fully hardened server is ready in 15 minutes instead of days. In banking, emphasize the compliance aspects: every job execution is logged for SOX/PCI-DSS audits, credentials are encrypted and never visible to operators, and CIS/STIG hardening is applied automatically as part of the provisioning workflow.
◈ Architecture Diagram
┌─────────────────────────────────────────────────────────────────┐ │ Ansible Automation Platform: Hybrid Cloud Provisioning │ │ │ │ ┌──────────────┐ │ │ │ ServiceNow │──── Webhook ───→ ┌──────────────────────┐ │ │ │ Request │ │ Ansible Automation │ │ │ └──────────────┘ │ Platform (AAP) │ │ │ │ │ │ │ │ ┌─────────────────┐ │ │ │ │ │ Job Template │ │ │ │ │ │ + Survey Form │ │ │ │ │ │ (OS, Env, Team) │ │ │ │ │ └────────┬────────┘ │ │ │ │ │ │ │ │ └───────────┼───────────┘ │ │ │ │ │ ┌─────────────────────┼──────────────┐ │ │ │ │ │ │ │ ┌─────▼──────┐ ┌──────▼───────┐ │ │ │ │ Linux │ │ Windows │ │ │ │ │ (SSH) │ │ (WinRM) │ │ │ │ │ │ │ │ │ │ │ │ CIS Harden │ │ STIG Harden │ │ │ │ │ FreeIPA │ │ AD Join │ │ │ │ │ Datadog │ │ Datadog │ │ │ │ │ Splunk Fwd │ │ Splunk Fwd │ │ │ │ └─────┬──────┘ └──────┬───────┘ │ │ │ │ │ │ │ │ ┌───────────────────────▼─────────────────────▼────────────┐ │ │ │ │ Targets: │ │ │ │ │ AWS EC2 │ VMware vSphere │ Azure VMs │ On-Prem Bare Metal│ │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ │ │ ┌───────────────────────────────────────────────────────────┐│ │ │ │ Audit: All executions logged → Splunk SIEM → SOX/PCI-DSS ││ │ │ └───────────────────────────────────────────────────────────┘│ │ └─────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Per-environment workspaces (dev/staging/prod) share code but risk configuration drift; per-component workspaces separate blast radius but increase complexity; directory-based separation provides maximum isolation but duplicates code. Most mature organizations use a hybrid: directories for environments with workspaces for regional variations.
Detailed Answer
Strategy 1: Per-Environment Workspaces (terraform workspace) Use terraform workspace new dev/staging/prod with environment-specific .tfvars files. Same code, same state backend, different workspace-isolated state files. - Pros: DRY code, easy to promote changes through environments - Cons: All environments share the same backend permissions, accidental terraform destroy in wrong workspace is catastrophic, can't have different module versions per environment - Anti-pattern: Using terraform.workspace in conditionals creates implicit differences between environments that are hard to track
Strategy 2: Per-Component Workspaces Each infrastructure component (networking, compute, data) gets its own workspace. Promotes independent lifecycle management. - Pros: Small blast radius, fast plan/apply, team ownership boundaries - Cons: Cross-workspace dependencies require data sources or remote state, ordering of applies matters
Strategy 3: Directory-Based Separation Separate directories: environments/dev/, environments/staging/, environments/prod/, each with their own backend config and state. - Pros: Maximum isolation, different module versions per environment, independent credentials - Cons: Code duplication, drift between environments, harder to promote changes
Use Terragrunt with directory-based environments that reference shared modules with pinned versions. Each environment directory contains a terragrunt.hcl that specifies the module source version. Promotions happen by updating the version pin (e.g., dev uses v2.1.0, prod uses v2.0.3).
Use workspaces mapped to VCS branches or directories. Tag-based workspace organization. Run triggers for dependencies between workspaces. Variable sets for shared configuration across workspace groups.
Code Example
# Anti-pattern: workspace conditionals
# DON'T do this - hidden environment differences
resource "aws_instance" "web" {
instance_type = terraform.workspace == "prod" ? "m5.xlarge" : "t3.micro"
# This creates invisible drift between environments
}
# Better: Directory-based with Terragrunt
# environments/
# dev/
# networking/terragrunt.hcl
# compute/terragrunt.hcl
# staging/
# networking/terragrunt.hcl
# compute/terragrunt.hcl
# prod/
# networking/terragrunt.hcl
# compute/terragrunt.hcl
# environments/prod/networking/terragrunt.hcl
terraform {
source = "git::https://github.com/company/modules.git//networking?ref=v2.0.3"
}
include "root" {
path = find_in_parent_folders()
}
include "env" {
path = find_in_parent_folders("env.hcl")
}
# environments/prod/env.hcl
locals {
environment = "prod"
account_id = "111111111111"
region = "us-east-1"
}
# root terragrunt.hcl (generates backend config)
remote_state {
backend = "s3"
generate {
path = "backend.tf"
if_exists = "overwrite"
}
config = {
bucket = "tfstate-${local.account_id}"
key = "${path_relative_to_include()}/terraform.tfstate"
region = local.region
encrypt = true
dynamodb_table = "terraform-locks"
}
}
# Deploy all components in prod
cd environments/prod && terragrunt run-all applyInterview Tip
This is a foundational architecture question. The strongest answer acknowledges that no single strategy is perfect and explains the tradeoffs. Mention the workspace conditional anti-pattern (using terraform.workspace in resource configs) - it's a red flag that shows you've seen real-world issues. Recommend the Terragrunt hybrid approach for organizations past the startup phase.
💬 Comments
Quick Answer
Infracost analyzes Terraform plan JSON to estimate monthly costs. Integrate in CI/CD by running 'infracost breakdown' on plan output, use 'infracost diff' for PR comments showing cost changes, and implement policy checks with 'infracost output --format json' piped to threshold validation scripts.
Detailed Answer
Infracost parses Terraform plan JSON files and matches resources to its pricing database (covering AWS, Azure, GCP). It calculates estimated monthly costs based on resource types, sizes, regions, and usage patterns. It supports both flat estimation (what will this cost?) and diff estimation (how much more/less will this change cost?).
1. Run terraform plan -out=plan.tfplan in the PR pipeline 2. Run infracost breakdown --path plan.tfplan for full cost breakdown 3. Run infracost diff --path plan.tfplan --compare-to infracost-base.json to show cost delta 4. Post results as PR comment with infracost comment github 5. Implement guardrails in the pipeline script
- Soft guardrail: Comment on PR with cost impact, require manual approval for changes > $500/month - Hard guardrail: Block merge if estimated monthly cost increase > $5000 without VP approval tag - Per-resource guardrails: Alert if any single resource costs > $1000/month (catches accidental large instance types)
Infracost supports usage files for resources with variable costs (data transfer, API calls, storage growth). Create infracost-usage.yml with expected usage patterns for more accurate estimates.
Export Infracost data to your FinOps platform. Track cost trends over time per team/project. Compare Infracost estimates against actual AWS Cost Explorer data to calibrate accuracy. Typical accuracy is within 15-20% for compute-heavy workloads.
Code Example
# GitHub Actions workflow with Infracost
name: Terraform Cost Check
on: [pull_request]
jobs:
infracost:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: infracost/actions/setup@v3
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
# Generate baseline cost from main branch
- name: Generate Infracost baseline
run: |
git checkout main
terraform init
infracost breakdown --path . --format json --out-file /tmp/infracost-base.json
git checkout ${{ github.head_ref }}
# Generate cost diff for PR
- name: Generate Infracost diff
run: |
terraform init
infracost diff --path . \
--compare-to /tmp/infracost-base.json \
--format json \
--out-file /tmp/infracost-diff.json
# Post PR comment
- name: Post Infracost comment
run: |
infracost comment github \
--path /tmp/infracost-diff.json \
--repo ${{ github.repository }} \
--pull-request ${{ github.event.pull_request.number }} \
--github-token ${{ secrets.GITHUB_TOKEN }} \
--behavior update
# Cost guardrail - block if too expensive
- name: Check cost threshold
run: |
DIFF=$(jq '.diffTotalMonthlyCost | tonumber' /tmp/infracost-diff.json)
if (( $(echo "$DIFF > 5000" | bc -l) )); then
echo "::error::Cost increase of \$$DIFF/month exceeds \$5000 threshold"
exit 1
fi
# infracost-usage.yml for usage-based estimation
version: 0.1
resource_usage:
aws_s3_bucket.data:
standard:
storage_gb: 5000
monthly_tier_1_requests: 1000000 # PUT/POST/LIST
monthly_tier_2_requests: 10000000 # GET/SELECT
glacier:
storage_gb: 50000
aws_lambda_function.processor:
monthly_requests: 5000000
request_duration_ms: 200Interview Tip
Cost management in IaC is increasingly important in interviews, especially at cost-conscious companies. Know the difference between breakdown (absolute cost) and diff (cost change). Mention usage-based estimation for resources with variable costs (Lambda, S3, data transfer). The guardrail pattern (soft vs hard thresholds) shows organizational maturity.
💬 Comments
Quick Answer
Use 'moved' blocks to declaratively specify resource address changes (rename, move between modules, change count to for_each). Terraform plans the move as an in-place update with no infrastructure changes. This replaces the imperative 'terraform state mv' approach.
Detailed Answer
When you rename a resource, change from count to for_each, or restructure modules, Terraform sees the old address as a destroy and the new address as a create. This causes unnecessary destruction of real infrastructure.
The moved block declaratively tells Terraform that a resource has changed address. During plan, Terraform shows the move as a no-op (no infrastructure changes). This is safer than terraform state mv because: 1. It's version-controlled (code review the move) 2. It works in CI/CD (no manual state manipulation) 3. It's idempotent (safe to apply multiple times) 4. Team members get the move automatically when they pull code
1. Simple rename: Resource aws_instance.web -> aws_instance.application 2. Move into module: aws_vpc.main -> module.networking.aws_vpc.main 3. Count to for_each: aws_subnet.private[0] -> aws_subnet.private["us-east-1a"] 4. Module rename: module.old_name -> module.new_name 5. Split module: Move resources from one module into two separate modules
- Keep moved blocks for at least 2 release cycles so all state files get updated - Add comments explaining why the move happened - Run terraform plan in all environments after adding moved blocks to verify no unintended changes - For complex refactoring, do moves in stages (don't rename AND restructure in the same PR)
Moved blocks can't change resource types (e.g., aws_instance to aws_launch_template). For type changes, you must import the new resource and remove the old one.
Code Example
# Scenario 1: Simple resource rename
moved {
from = aws_instance.web
to = aws_instance.application_server
}
resource "aws_instance" "application_server" {
# Same configuration as before
ami = "ami-0123456789"
instance_type = "m5.xlarge"
}
# Scenario 2: Move resource into a module
moved {
from = aws_vpc.main
to = module.networking.aws_vpc.main
}
module "networking" {
source = "./modules/networking"
}
# Scenario 3: Convert count to for_each
moved {
from = aws_subnet.private[0]
to = aws_subnet.private["us-east-1a"]
}
moved {
from = aws_subnet.private[1]
to = aws_subnet.private["us-east-1b"]
}
moved {
from = aws_subnet.private[2]
to = aws_subnet.private["us-east-1c"]
}
resource "aws_subnet" "private" {
for_each = toset(["us-east-1a", "us-east-1b", "us-east-1c"])
vpc_id = aws_vpc.main.id
availability_zone = each.value
cidr_block = cidrsubnet(var.vpc_cidr, 4, index(["us-east-1a", "us-east-1b", "us-east-1c"], each.value))
}
# Scenario 4: Module rename
moved {
from = module.old_infra
to = module.core_infrastructure
}
# Verify no infrastructure changes
terraform plan
# Should show:
# # aws_instance.web has moved to aws_instance.application_server
# # (no changes, resource address updated)Interview Tip
Moved blocks are a relatively modern Terraform feature that many candidates don't know about. Demonstrating knowledge of this shows you do real-world Terraform maintenance, not just greenfield development. The key selling point over 'terraform state mv' is that moved blocks are version-controlled, reviewable, and work in CI/CD without manual state manipulation.
💬 Comments
Quick Answer
Terraform detects the drift during the refresh phase of the next plan because the real-world state no longer matches what's recorded in the state file, and it will propose changing the rule back to match your .tf configuration. To fix it without destroying anything, either update your Terraform code to match the intentional manual change, or run terraform apply to silently revert the console edit back to the declared configuration — both are non-destructive since security group rule updates are in-place.
Detailed Answer
Think of Terraform's state file like a librarian's card catalog. The catalog says exactly which books are on which shelf. If someone walks in and rearranges a shelf without telling the librarian, the catalog is now wrong — not the shelf. The librarian's job on the next inventory check is to notice the mismatch and either update the catalog or put the books back where the catalog says they belong.
Terraform was designed around the idea that the .tf files are the single source of truth, and the state file is a cached snapshot of what Terraform believes exists in the real world. A manual console change never updates that cached snapshot, so the two diverge. This is intentional: Terraform does not constantly poll AWS in real time, because that would be slow and expensive at scale, so it only reconciles differences when you explicitly run a plan or apply.
Internally, terraform plan first performs a refresh: it calls the AWS API for every resource tracked in state (in this case, describe-security-groups) and compares the live attributes against what's stored in state. When it finds the ingress or egress rule block doesn't match, it marks that resource as having a diff. Because security group rules are updated in place via the AWS API (authorize/revoke operations), Terraform generates an update-in-place plan, not a destroy-and-recreate plan — so running apply is safe and reversible, it will not tear down the security group or the resources attached to it.
At production scale, this scenario is exactly why teams enable drift detection dashboards (terraform plan run on a schedule, or tools like driftctl) and lock down console write access via IAM policies or SCPs, so that engineers cannot fat-finger a manual change during an incident and forget to backport it into code. Some teams pipe scheduled drift-detection plan output into Slack so nobody discovers a silent revert three weeks later during an unrelated deploy.
The non-obvious gotcha: if the manual change was actually the correct fix (say, the developer opened port 443 during a live incident to unblock a partner integration), running terraform apply without updating the .tf file first will silently revert that fix and could reopen the incident. Always diff the plan output carefully before applying — a clean 'this brings your infrastructure in line with configuration' plan can mean 'this undoes an emergency fix' just as easily as 'this reverts an accidental change.'
Code Example
# Step 1: See what Terraform thinks changed vs the manual console edit
terraform plan -target=aws_security_group_rule.app_https
# Step 2a: If the manual change was WRONG, apply to revert it back to code
terraform apply -target=aws_security_group_rule.app_https
# Step 2b: If the manual change was CORRECT, update the .tf file to match reality first
resource "aws_security_group_rule" "app_https" {
type = "ingress"
from_port = 443 # matches the port the developer opened manually
to_port = 443
protocol = "tcp"
cidr_blocks = ["203.0.113.0/24"] # partner CIDR added during the incident
security_group_id = aws_security_group.payments_api.id
}
# Step 3: Re-run plan to confirm zero diff after aligning code with reality
terraform plan -target=aws_security_group_rule.app_httpsInterview Tip
A junior engineer typically answers 'Terraform will show a diff and fix it on apply,' which is true but incomplete. For a senior or architect role, the interviewer is actually looking for you to recognize the blast-radius question underneath: does Terraform know whether the manual change was a mistake or an emergency fix? A mature answer talks about drift-detection pipelines that alert humans before any apply runs unattended, IAM guardrails that prevent console writes to Terraform-managed resources in the first place, and the operational discipline of always reading plan output as a diff to be reviewed, not a black box to be trusted. Mentioning `terraform plan -target` for a scoped check, and `-refresh-only` mode for detecting drift without generating a destructive plan, signals real production experience.
◈ Architecture Diagram
┌─────────┐ describe-sg ┌──────────┐
│ AWS │◄───────────────│ terraform│
│ (real) │ │ plan │
└────┬─────┘ └────┬─────┘
│ live rule: port 8443 │ state rule: port 443
└────────────┬───────────────┘
▼
DRIFT DETECTED
┌───────┴───────┐
▼ ▼
apply (revert) update .tf (adopt)💬 Comments
Quick Answer
Remote state (e.g. S3 + DynamoDB) shares state across a team and locks it to prevent concurrent applies from corrupting it.
Detailed Answer
Local state doesn't work for teams — two applies race. A remote backend stores state centrally and, with a lock (DynamoDB for S3, or native locking in Terraform Cloud/GCS), serializes operations so only one apply mutates state at a time. It also enables encryption and versioning.
Code Example
terraform {
backend "s3" {
bucket = "tf-state"
key = "prod/terraform.tfstate"
dynamodb_table = "tf-locks"
}
}Interview Tip
Name the S3 + DynamoDB lock pattern.
💬 Comments
Quick Answer
Modules are reusable, parameterized groups of resources — the unit of composition and reuse.
Detailed Answer
A module bundles related resources with input variables and outputs, so you define (say) a VPC once and instantiate it per environment. Modules encapsulate complexity, enforce standards, and enable DRY infrastructure. Pin module versions from a registry for stability.
Interview Tip
Pin module versions; expose a small, clear input surface.
💬 Comments
Quick Answer
Use terraform import <resource_address> <resource_id> to bring an existing real resource under Terraform management by writing it into state; then add matching configuration so future plans do not try to recreate it.
Detailed Answer
Import only updates state — it does not generate config, so you must write the resource block (or use Terraform 1.5+ import blocks / -generate-config-out) to match reality, then run plan until it shows no changes. This is how you adopt manually-created infrastructure into IaC without downtime.
Code Example
terraform import aws_s3_bucket.logs my-existing-bucket # then write the aws_s3_bucket.logs block and plan until no changes
Interview Tip
Stress that import updates state but not config — you must write the block and plan to zero-diff, or mention 1.5+ import blocks.
💬 Comments
Quick Answer
Another operation holds the state lock (a running apply, or a crashed run that left a stale lock). Wait for the other run, or if it is genuinely stale, release it with terraform force-unlock <lock-id> after confirming no apply is in progress.
Detailed Answer
Remote backends (S3+DynamoDB, Terraform Cloud, GCS) lock state to prevent concurrent applies corrupting it. The error prints the lock ID, who holds it, and when. First confirm no real apply is running (CI job, teammate). Only then terraform force-unlock <lock-id>. Prevent it by serializing applies in CI and never Ctrl-C-ing an apply mid-run.
Code Example
terraform force-unlock <lock-id> # only after confirming no apply is running
Interview Tip
Stress confirming no apply is actually running before force-unlock — forcing during a live apply corrupts state.
💬 Comments
Quick Answer
Backend init failure means the state backend (S3/GCS/Azure) is unreachable or credentials/permissions are wrong — run terraform init after fixing access. Module not found means a bad module source or version — fix the source path/registry ref and re-run terraform init.
Detailed Answer
Backend errors: check the bucket/container exists, the credentials and IAM permissions are correct, and network access to the backend — then terraform init. Module errors: verify the module source (local path, Git URL, or registry address) and the pinned version, ensure you can reach the registry/Git, and run terraform init to download it. Both are resolved at init time, not plan/apply.
Code Example
terraform init -reconfigure terraform init -upgrade # refresh module/provider versions
Interview Tip
Both are init-time problems — mention terraform init (with -reconfigure/-upgrade) as the resolution step.
💬 Comments