9 Terraform architect questions with detailed answers, HCL examples, and interview tips.
Quick Answer
At scale, Terraform state must be stored in remote backends like S3 with DynamoDB locking or Terraform Cloud, split into small blast-radius units by domain or environment, and isolated via workspaces or directory structure. State locking prevents concurrent applies from corrupting state, and state splitting ensures a single terraform apply cannot accidentally destroy unrelated infrastructure.
Detailed Answer
Think of a hospital records system. If every department writes to one giant patient file simultaneously, records get corrupted and the wrong medication gets administered. Splitting records by department, locking each file during edits, and storing everything in a central secure archive prevents these disasters. Terraform state management works the same way — it is the record of what infrastructure exists, and mismanaging it causes outages.
Terraform state is a JSON file that maps every resource in your configuration to a real cloud object. When terraform plan runs, it reads the state to determine what exists, compares it to the desired configuration, and calculates the diff. If two engineers run terraform apply simultaneously against the same state, one overwrites the other's changes, causing state corruption where Terraform's view of the world no longer matches reality. Remote backends solve storage and collaboration: S3 stores the state file durably, DynamoDB provides a lock table so only one operation can modify state at a time, and versioning on the S3 bucket enables recovery from bad applies.
Internally, when terraform apply starts, it sends a Lock request to the backend. For S3+DynamoDB, this writes a lock record to the DynamoDB table with a unique ID, the user's identity, and a timestamp. If another process already holds the lock, Terraform exits with an error. After the apply completes, Terraform writes the updated state to S3 and releases the lock. If a process crashes mid-apply, the lock remains until it expires or is manually force-unlocked with terraform force-unlock. Terraform Cloud handles locking internally and adds run queues so multiple plans can exist but only one apply executes at a time per workspace.
At production scale, the critical architectural decision is state splitting. A monolithic state file containing the VPC, databases, Kubernetes clusters, DNS records, and application services means a single terraform apply can accidentally destroy the database while updating a DNS record. The recommended pattern is splitting state by blast radius: network foundations in one state, data layer in another, compute in another, and application configurations in their own states. Each state has its own backend configuration and can use terraform_remote_state data sources or outputs stored in SSM Parameter Store to share values. Workspaces can further separate environments (dev, staging, production) within the same configuration, but they should not be used as a substitute for proper state splitting — all workspaces in a configuration share the same codebase, backend, and permissions.
The non-obvious gotcha is that terraform_remote_state creates a hard coupling between states, and if the upstream state is corrupted or the output names change, downstream plans break. Many mature teams replace terraform_remote_state with data sources that look up infrastructure by tags or names, or they store shared values in AWS SSM Parameter Store or HashiCorp Consul, which decouples state files completely. Another trap is that S3 bucket versioning does not protect against state file deletion — teams must also enable MFA Delete or use S3 Object Lock for regulatory environments.
Code Example
# backend.tf — Remote backend configuration for the payments data layer
terraform {
# Use S3 as the remote state storage backend
backend "s3" {
# S3 bucket dedicated to Terraform state files
bucket = "company-terraform-state-prod"
# State file path scoped to team and layer
key = "payments/data-layer/terraform.tfstate"
# AWS region for the state bucket
region = "us-east-1"
# DynamoDB table for state locking and consistency checking
dynamodb_table = "terraform-state-locks"
# Enable server-side encryption for state at rest
encrypt = true
# Use a specific KMS key for encryption
kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/payments-tf-state-key"
}
}
# Reference outputs from the network layer state without tight coupling
# Using SSM Parameter Store instead of terraform_remote_state
data "aws_ssm_parameter" "vpc_id" {
# Parameter path set by the network layer's terraform apply
name = "/infrastructure/network/vpc-id"
}
data "aws_ssm_parameter" "private_subnet_ids" {
# Comma-separated subnet IDs stored by the network team
name = "/infrastructure/network/private-subnet-ids"
}
# Use the decoupled values in resource configuration
resource "aws_db_subnet_group" "payments" {
# Subnet group name for the payments database
name = "payments-db-subnets"
# Split the comma-separated parameter value into a list
subnet_ids = split(",", data.aws_ssm_parameter.private_subnet_ids.value)
# Tag for operational identification
tags = {
Team = "payments"
Layer = "data"
}
}
# DynamoDB lock table must exist before backend configuration
# This is typically created by a bootstrap state or manually
# aws dynamodb create-table \
# --table-name terraform-state-locks \
# --attribute-definitions AttributeName=LockID,AttributeType=S \
# --key-schema AttributeName=LockID,KeyType=HASH \
# --billing-mode PAY_PER_REQUESTInterview Tip
A junior engineer typically answers that Terraform state should be stored in S3, but for a senior/architect role, the interviewer is actually looking for blast-radius management and operational recovery patterns. Explain why state splitting by domain or layer prevents a single apply from destroying unrelated infrastructure, how DynamoDB locking prevents concurrent apply corruption, why terraform_remote_state creates brittle coupling that SSM parameters or data sources can avoid, and what happens when a lock is orphaned after a crash. A mature answer also covers S3 versioning for state recovery, the workspace-versus-directory debate, and why MFA Delete or Object Lock may be necessary for compliance.
◈ Architecture Diagram
┌──────────┐
│ tf apply │
└────┬─────┘
│
┌────┴─────┐
│ Lock │
│ DynamoDB │
└────┬─────┘
│
┌────┴─────┐
│ Read │
│ S3 State │
└────┬─────┘
│
┌────┴─────┐
│ Apply │
│ Changes │
└────┬─────┘
│
┌────┴─────┐
│ Write │
│ S3 State │
└────┬─────┘
│
┌────┴─────┐
│ Unlock │
└──────────┘💬 Comments
Quick Answer
Composable modules follow a thin-wrapper pattern with clear input/output contracts, use variable validation blocks for early error detection, semantic versioning via Git tags for safe upgrades, and publish to a private registry for organizational reuse. Module composition uses outputs and data sources rather than nested module trees that create opaque dependency chains.
Detailed Answer
Think of building a house with prefabricated components. A good prefab wall panel has standard dimensions (clear interface), quality-tested materials (validation), a version number stamped on it (semantic versioning), and is available from a catalog (registry). A bad panel is custom-cut for one house, undocumented, and stored in someone's garage. Terraform module design follows the same principles.
A well-designed Terraform module encapsulates a single infrastructure concern with a clear input/output contract. The module should do one thing well — create an RDS instance with standard security settings, or provision a VPC with consistent CIDR allocation — rather than trying to create an entire environment. Variable validation blocks catch configuration errors at plan time rather than during apply or, worse, at runtime when a database accepts an invalid parameter and fails to start. Validation expressions use conditions and error messages to enforce naming patterns, CIDR ranges, instance size constraints, and environment-specific rules before any API call is made.
Internally, Terraform resolves module sources during terraform init. A module sourced from a Git repository with a version tag (git::https://github.com/company/terraform-aws-rds.git?ref=v2.3.1) is downloaded and cached in .terraform/modules. The version pin ensures that a new commit to the module repository does not unexpectedly change infrastructure across all consumers. When published to a private registry (Terraform Cloud, Artifactory, or a self-hosted registry), modules appear in a searchable catalog with documentation generated from variables.tf, outputs.tf, and README.md. The registry enforces semantic versioning, making it safe to specify version constraints like ~> 2.3 (any 2.x from 2.3 upward) in consumer configurations.
At production scale, module composition patterns matter as much as individual module quality. The recommended pattern is flat composition: a root configuration references multiple modules at the same level, passing outputs from one module as inputs to another, rather than nesting modules three or four levels deep. Deep nesting creates opaque dependency chains where a change in a leaf module requires understanding the full tree to predict impact. Root modules should be environment-specific (payments-prod, payments-staging) and pin module versions independently per environment so that staging can test a new module version before production adopts it. Teams should run terraform validate and tflint in CI for every module change, and use automated tests with terratest or terraform test to verify module behavior.
The non-obvious gotcha is that module versioning only works if teams actually bump versions. A common failure pattern is pinning to a Git branch (ref=main) instead of a tag, which means terraform init on different days pulls different code. Another trap is overusing count or for_each in modules to make them do too many things — a module that creates either an RDS instance or an Aurora cluster based on a boolean variable becomes untestable and produces confusing plans. Architects should split divergent resources into separate modules rather than adding conditional logic that makes the module's behavior unpredictable.
Code Example
# modules/rds-instance/variables.tf — Module input contract with validation
variable "instance_name" {
# Human-readable name for the database instance
type = string
description = "Name of the RDS instance, must follow naming convention"
validation {
# Enforce the team naming convention: team-service-env
condition = can(regex("^[a-z]+-[a-z]+-(?:dev|staging|prod)$", var.instance_name))
error_message = "Instance name must match pattern: team-service-env (e.g., payments-orders-prod)."
}
}
variable "instance_class" {
# RDS instance type for compute sizing
type = string
description = "RDS instance class, restricted to approved sizes"
validation {
# Only allow instance classes approved by the platform team
condition = contains(["db.t3.medium", "db.r6g.large", "db.r6g.xlarge", "db.r6g.2xlarge"], var.instance_class)
error_message = "Instance class must be one of the platform-approved sizes."
}
}
variable "allocated_storage_gb" {
# Storage size in gigabytes
type = number
description = "Allocated storage in GB, minimum 20, maximum 5000"
validation {
# Enforce storage boundaries to prevent cost overruns
condition = var.allocated_storage_gb >= 20 && var.allocated_storage_gb <= 5000
error_message = "Storage must be between 20 and 5000 GB."
}
}
# Root configuration consuming the module with version pin
# payments-prod/main.tf
module "orders_database" {
# Source from private registry with semantic version constraint
source = "app.terraform.io/company/rds-instance/aws"
version = "~> 2.3" # Accept any 2.x >= 2.3, reject 3.x
# Pass validated inputs to the module
instance_name = "payments-orders-prod"
instance_class = "db.r6g.large"
allocated_storage_gb = 500
# Pass outputs from the network module as inputs
subnet_group_name = module.vpc.database_subnet_group_name
security_group_ids = [module.vpc.database_security_group_id]
}
# Output the database endpoint for consumption by other configurations
output "orders_db_endpoint" {
# Expose the RDS endpoint for application configuration
value = module.orders_database.endpoint
description = "Connection endpoint for the orders database"
}Interview Tip
A junior engineer typically answers that modules let you reuse Terraform code, but for a senior/architect role, the interviewer is actually looking for design discipline and organizational scale patterns. Explain why modules should do one thing well with clear input validation, how semantic versioning via Git tags or a registry prevents unintended changes, why flat composition is preferable to deep module nesting, and how to test modules in CI. A mature answer also covers the anti-pattern of pinning to a branch instead of a tag, the problem with conditional modules that try to handle too many resource types, and the strategy of environment-specific root modules that pin module versions independently.
◈ Architecture Diagram
┌──────────┐
│ Registry │
│ v2.3.1 │
└────┬─────┘
│
┌────┴─────┐
│ Root Cfg │
│ prod │
└──┬───┬───┘
│ │
↓ ↓
┌─────┐┌─────┐
│ VPC ││ RDS │
│ mod ││ mod │
└──┬──┘└──┬──┘
│ │
↓ ↓
┌──────────┐
│ outputs │
└──────────┘💬 Comments
Quick Answer
Terraform Cloud enforces governance through Sentinel policies that evaluate plans as code before apply, cost estimation that flags unexpected spend, run tasks that integrate external checks like security scanners, and VCS workflows that trigger plans on pull requests. This shifts enforcement left into the plan phase so teams get fast feedback without needing manual approval for every change.
Detailed Answer
Think of a highway with automated safety systems. Speed cameras (Sentinel policies) automatically flag violations, fuel cost displays (cost estimation) warn drivers before they commit to a route, roadside inspection stations (run tasks) check specific safety requirements, and GPS-guided lanes (VCS workflows) route each vehicle through the correct path. The highway keeps moving because enforcement is automated, not manual.
Terraform Cloud Enterprise provides a collaborative platform where infrastructure changes follow a standardized workflow: code is committed to VCS, a plan is triggered, governance checks run, and apply executes only after all checks pass. Sentinel is HashiCorp's policy-as-code framework that evaluates Terraform plans, state, and configuration using a policy language. Policies can enforce rules like requiring encryption on all S3 buckets, restricting instance types to cost-approved sizes, mandating specific tags on every resource, or preventing deletion of production databases. Policies are organized into policy sets that are applied to specific workspaces or all workspaces in an organization.
Internally, the run pipeline processes stages in order: VCS trigger, terraform plan, cost estimation, Sentinel policy check, run tasks, and terraform apply. Cost estimation parses the plan output and calculates the monthly cost delta using HashiCorp's pricing database, surfacing changes like adding a db.r6g.2xlarge that increases monthly spend by $1,200. Run tasks are webhook-based integrations that send the plan JSON to external systems — security scanners like Snyk or Prisma Cloud, compliance checkers, or custom approval systems — and wait for a pass/fail response. Each run task can be advisory (warning only) or mandatory (blocking apply). The entire pipeline runs automatically on pull request creation, giving developers feedback in minutes rather than waiting for a manual review.
At production scale, governance design requires balancing safety with velocity. Hard-mandatory Sentinel policies should cover non-negotiable rules like encryption and tagging. Soft-mandatory policies allow overrides with justification for edge cases like temporary large instances for data migration. Advisory policies educate teams about best practices without blocking. Cost estimation thresholds can be set to require manager approval for changes exceeding a dollar amount. VCS workflows should use speculative plans on pull requests (plan only, no apply) so developers see the impact before merging, and auto-apply on the main branch for environments like dev where speed matters more than manual gates.
The non-obvious gotcha is that Sentinel policies execute after the plan phase, so they cannot prevent Terraform from planning invalid configurations — they can only block the apply. If a Sentinel policy references a resource attribute that does not exist in the plan (because the resource was removed), the policy can fail with a confusing error rather than a clean policy violation. Teams should test Sentinel policies against mock plan data in CI using the Sentinel CLI before deploying them to Terraform Cloud. Another trap is over-engineering run tasks: each run task adds latency to the pipeline, and if the external system is slow or unreliable, it blocks every infrastructure change across the organization.
Code Example
# Sentinel policy: require encryption on all S3 buckets
# policies/s3-encryption-required.sentinel
import "tfplan/v2" as tfplan
# Find all S3 bucket resources being created or updated
s3_buckets = filter tfplan.resource_changes as _, rc {
# Match only aws_s3_bucket resources with create or update actions
rc.type is "aws_s3_bucket" and
rc.mode is "managed" and
(rc.change.actions contains "create" or rc.change.actions contains "update")
}
# Check that every bucket has a server-side encryption configuration
encryption_check = rule {
all s3_buckets as _, bucket {
# Verify the bucket_encryption block is not null after apply
bucket.change.after.server_side_encryption_configuration is not null
}
}
# Main rule that must pass for the apply to proceed
main = rule {
encryption_check
}
# sentinel.hcl — Policy set configuration
# policy "s3-encryption-required" {
# source = "./policies/s3-encryption-required.sentinel"
# enforcement_level = "hard-mandatory" # Cannot be overridden
# }
# terraform-cloud workspace configuration via CLI
# Create a workspace connected to VCS with auto-apply disabled for production
# terraform login
# terraform workspace new payments-prod -organization=company
# .terraform-cloud.auto.tfvars — Workspace variable defaults
# These are set in the Terraform Cloud UI or API for the workspace
# environment = "prod"
# team = "payments"
# cost_threshold = 500
# Run task configuration via API (register a security scanner)
# curl -s -X POST \
# -H "Authorization: Bearer $TFC_TOKEN" \
# -H "Content-Type: application/vnd.api+json" \
# https://app.terraform.io/api/v2/organizations/company/tasks \
# -d '{"data":{"type":"tasks","attributes":{"name":"snyk-iac-scan","url":"https://hooks.snyk.io/terraform-cloud","category":"task","hmac-key":"secret-key"}}}'Interview Tip
A junior engineer typically answers that Terraform Cloud stores state remotely, but for a senior/architect role, the interviewer is actually looking for governance architecture at organizational scale. Explain how Sentinel policies evaluate plans as code with hard-mandatory versus soft-mandatory enforcement levels, how cost estimation surfaces spend impact before apply, how run tasks integrate external security and compliance tools, and why speculative plans on pull requests shift feedback left. A mature answer also covers the Sentinel testing workflow using mock data, the latency impact of chaining multiple run tasks, and the strategy of separating advisory policies for education from mandatory policies for enforcement.
◈ Architecture Diagram
┌──────────┐
│ VCS Push │
└────┬─────┘
↓
┌──────────┐
│ tf plan │
└────┬─────┘
↓
┌──────────┐
│ Cost Est │
└────┬─────┘
↓
┌──────────┐
│ Sentinel │
└────┬─────┘
↓
┌──────────┐
│ Run Task │
└────┬─────┘
↓
┌──────────┐
│ tf apply │
└──────────┘💬 Comments
Quick Answer
Terraform plan -refresh-only detects drift by comparing actual cloud state against the stored state file without proposing configuration changes. Import blocks bring unmanaged resources under Terraform control declaratively. Moved blocks refactor resource addresses in state without destroying and recreating infrastructure. Together they let architects reconcile drift, adopt existing resources, and restructure code safely.
Detailed Answer
Think of a warehouse inventory system. Drift detection is like a stock audit — you compare what the computer says is on the shelf to what is actually there. Import is like scanning a product that was placed on the shelf without being logged into the system. Move is like changing the shelf label without physically moving the product. All three keep the inventory accurate without throwing anything away.
Infrastructure drift occurs when cloud resources are modified outside Terraform — through the console, CLI, another IaC tool, or automated processes like auto-scaling. terraform plan -refresh-only reads the current state of every managed resource from the cloud provider APIs and compares it to the stored state file. It shows what has changed in the real world without proposing any configuration-level changes. This is distinct from a regular terraform plan, which both refreshes state and compares it to the desired configuration. Running refresh-only plans on a schedule helps teams detect unauthorized changes before they cause incidents.
Internally, refresh-only mode calls the same provider Read functions that a normal plan uses, but it stops after updating the in-memory state representation. It shows a diff between the previously stored state and the freshly read state, highlighting attributes that changed externally. If the operator approves the refresh with terraform apply -refresh-only, the state file is updated to match reality without making any infrastructure changes. Import blocks, introduced in Terraform 1.5, allow declarative imports in configuration files rather than the imperative terraform import CLI command. A resource block with an import block specifies the cloud resource ID, and terraform plan generates the configuration needed to manage it. Moved blocks tell Terraform that a resource has been renamed or restructured in the configuration — for example, moving from a flat resource to a module or changing a resource's for_each key — so it updates the state address rather than planning a destroy and create.
At production scale, drift detection should be automated. Teams run terraform plan -refresh-only in CI on a daily schedule and alert on any detected drift. The plan output is stored as an artifact for audit trail. Import blocks are essential during brownfield adoption — when a company has existing infrastructure created manually or by CloudFormation and wants to manage it with Terraform. Without import blocks, the alternative is terraform import commands that must be run manually for each resource, which is error-prone and not version-controlled. Moved blocks are critical during refactoring: when a team restructures modules, renames resources for clarity, or converts single resources to for_each collections, moved blocks prevent Terraform from destroying the production database and recreating it.
The non-obvious gotcha with refresh-only is that it only detects drift in resources Terraform already manages — it cannot find resources created outside Terraform. Teams need cloud-native tools like AWS Config or Azure Policy for complete drift coverage. With import blocks, the generated configuration may not match the team's coding standards and needs manual cleanup. With moved blocks, the from address must exactly match the current state address, including module paths and index keys, and a typo silently creates a new resource instead of moving the existing one. Architects should always run terraform plan after adding moved blocks and verify that no destroy/create actions appear.
Code Example
# Detect drift on the payments infrastructure without changing anything
terraform plan -refresh-only -out=drift-report.tfplan
# Review the drift report to see what changed externally
terraform show drift-report.tfplan
# Apply the refresh to update state to match reality (no infra changes)
terraform apply -refresh-only drift-report.tfplan
# Import an existing RDS instance that was created manually in the console
# payments-data/main.tf
import {
# Specify the AWS resource ID of the existing database
id = "payments-orders-prod"
# Map it to this Terraform resource address
to = aws_db_instance.orders
}
resource "aws_db_instance" "orders" {
# Identifier matching the existing RDS instance name
identifier = "payments-orders-prod"
# Instance class matching the existing configuration
instance_class = "db.r6g.large"
# Engine matching the existing database
engine = "postgres"
# Engine version matching the existing database
engine_version = "16.3"
# Storage matching the existing allocation
allocated_storage = 500
# Prevent accidental deletion of the production database
deletion_protection = true
# Skip final snapshot only if you have other backup strategies
skip_final_snapshot = false
# Tag for operational identification
tags = {
Team = "payments"
Environment = "prod"
ManagedBy = "terraform"
}
}
# Refactor a resource into a module without destroying it
# Use moved block to update the state address
moved {
# Old address before modularization
from = aws_db_instance.orders
# New address inside the database module
to = module.orders_database.aws_db_instance.this
}Interview Tip
A junior engineer typically answers that terraform plan shows what will change, but for a senior/architect role, the interviewer is actually looking for drift management strategy and safe state operations. Explain the difference between refresh-only (detect drift without proposing config changes) and a normal plan (detect drift and propose config alignment), how import blocks enable declarative brownfield adoption in version-controlled configuration, and how moved blocks prevent destroy/create cycles during refactoring. A mature answer also covers automating refresh-only plans in CI for scheduled drift alerting, the limitation that refresh-only only covers Terraform-managed resources, and the risk of typos in moved block addresses silently creating new resources.
◈ Architecture Diagram
┌──────────┐
│ Cloud │
│ (actual) │
└────┬─────┘
│ refresh
┌────┴─────┐
│ State │
│ (stored) │
└────┬─────┘
│ compare
┌────┴─────┐
│ Config │
│ (desired)│
└────┬─────┘
│
┌────┴─────┐
│ Plan │
│ (action) │
└──────────┘💬 Comments
Quick Answer
Provider aliases define multiple AWS provider configurations for different regions or accounts within a single Terraform configuration. For_each on modules with provider maps enables deploying the same infrastructure across regions or accounts without duplicating code. Architects use assume_role in provider blocks to cross account boundaries securely, and module-level providers pass the correct provider to each regional deployment.
Detailed Answer
Think of a restaurant chain opening locations in different cities. Rather than writing a completely new business plan for each city, the headquarters uses one standard restaurant blueprint and customizes the local supplier list, health department contact, and rental agreement per location. Provider aliases in Terraform work the same way — one infrastructure blueprint is deployed across regions and accounts by swapping the provider configuration.
In AWS, multi-account architecture is the recommended pattern for blast-radius isolation: production, staging, shared services, security, and logging each live in separate AWS accounts under an AWS Organization. Multi-region deployment adds resilience and latency optimization by placing infrastructure closer to users. Without careful Terraform design, this creates an explosion of near-identical configuration files — one per account-region combination — that diverge over time and become unmaintainable.
Internally, Terraform provider aliases allow declaring multiple instances of the same provider with different configurations. A provider block with alias = "us_west" and region = "us-west-2" coexists with the default provider using region = "us-east-1". Resources and modules reference a specific provider using the provider or providers argument. For cross-account access, each aliased provider uses assume_role to temporarily adopt an IAM role in the target account. The for_each meta-argument on modules, combined with a providers map, enables deploying the same module across multiple regions or accounts from a single configuration. Each module instance receives its own provider, which determines where the infrastructure is created.
At production scale, the pattern involves a locals block that defines a map of regions or account-region pairs, a module block with for_each over that map, and a dynamic providers assignment that passes the correct aliased provider to each module instance. State should be split so that each account-region combination has its own state file — otherwise a single state file becomes a massive blast radius. The deployment pipeline should use separate workspaces or directories per account-region pair, with the provider configuration driven by workspace-specific variables. Teams should also use terraform_remote_state or SSM parameters to share outputs like VPC IDs across account-region boundaries.
The non-obvious gotcha is that Terraform does not support for_each on provider blocks themselves — you cannot dynamically generate provider aliases from a map. Each provider alias must be declared statically in the configuration. This means if you add a new region, you must add a new provider alias block, which is a manual step that cannot be fully automated. Some teams work around this by using Terragrunt to generate provider blocks from a configuration file, or by using a code generator that produces the provider declarations. Another trap is that assume_role credentials expire during long applies — for large deployments, the role session duration must be set high enough (up to 12 hours for chained roles) or the apply will fail midway with an expired token error.
Code Example
# providers.tf — Static provider aliases for each target region
provider "aws" {
# Default provider for the primary region
region = "us-east-1"
# Assume a role in the production account
assume_role {
role_arn = "arn:aws:iam::111111111111:role/TerraformDeployRole"
session_name = "terraform-payments-prod"
}
}
provider "aws" {
# Aliased provider for the secondary region
alias = "us_west_2"
region = "us-west-2"
# Same account, different region
assume_role {
role_arn = "arn:aws:iam::111111111111:role/TerraformDeployRole"
session_name = "terraform-payments-prod-west"
}
}
provider "aws" {
# Aliased provider for the EU region in a separate account
alias = "eu_west_1"
region = "eu-west-1"
# Assume role in the EU production account
assume_role {
role_arn = "arn:aws:iam::222222222222:role/TerraformDeployRole"
session_name = "terraform-payments-eu-prod"
}
}
# main.tf — Deploy the same networking module to each region
locals {
# Map of regions to their provider references and CIDR allocations
regions = {
us_east_1 = { cidr = "10.1.0.0/16", provider_key = "aws" }
us_west_2 = { cidr = "10.2.0.0/16", provider_key = "aws.us_west_2" }
eu_west_1 = { cidr = "10.3.0.0/16", provider_key = "aws.eu_west_1" }
}
}
# Deploy VPC module to US East (default provider)
module "vpc_us_east_1" {
# Source from the internal registry with version pin
source = "app.terraform.io/company/vpc/aws"
version = "~> 3.1"
# Pass region-specific CIDR
vpc_cidr = "10.1.0.0/16"
environment = "prod"
region_name = "us-east-1"
}
# Deploy VPC module to US West with aliased provider
module "vpc_us_west_2" {
source = "app.terraform.io/company/vpc/aws"
version = "~> 3.1"
providers = {
# Pass the US West provider alias to the module
aws = aws.us_west_2
}
vpc_cidr = "10.2.0.0/16"
environment = "prod"
region_name = "us-west-2"
}
# Deploy VPC module to EU West with cross-account provider
module "vpc_eu_west_1" {
source = "app.terraform.io/company/vpc/aws"
version = "~> 3.1"
providers = {
# Pass the EU provider alias (different account + region)
aws = aws.eu_west_1
}
vpc_cidr = "10.3.0.0/16"
environment = "prod"
region_name = "eu-west-1"
}Interview Tip
A junior engineer typically answers that you set the region in the provider block, but for a senior/architect role, the interviewer is actually looking for multi-account governance and code reuse architecture. Explain how provider aliases enable multiple region and account configurations, how assume_role provides secure cross-account access with temporary credentials, why for_each on modules reduces code duplication, and the critical limitation that provider blocks themselves cannot use for_each. A mature answer also covers state splitting per account-region pair, the assume_role session duration trap during long applies, and whether Terragrunt or code generation is needed to manage provider block proliferation at large scale.
◈ Architecture Diagram
┌──────────────────────┐ │ Root Config │ │ │ │ provider aws │ │ provider aws.west │ │ provider aws.eu │ └──┬───────┬───────┬───┘ │ │ │ ↓ ↓ ↓ ┌──────┐┌──────┐┌──────┐ │US-E-1││US-W-2││EU-W-1│ │Acct A││Acct A││Acct B│ │VPC ││VPC ││VPC │ └──────┘└──────┘└──────┘
💬 Comments
Quick Answer
Use a layered module architecture: platform modules for shared infra, team-facing wrapper modules for self-service, separate state per account/component, and automated drift detection via CI.
Detailed Answer
Layer 1: Platform Modules (maintained by platform team) - VPC, networking, IAM baseline, logging, security guardrails - Published as versioned modules in a private registry (Terraform Cloud or S3) - Semantic versioning: teams pin to major versions, get patches automatically
Layer 2: Team Modules (self-service wrappers) - Opinionated wrappers around platform modules - Example: team-service module creates ECS service + ALB + DNS + monitoring - Teams provide minimal inputs: service name, container image, CPU/memory - Enforces organizational standards (tagging, encryption, logging)
Layer 3: Team Configurations (per-team repos) - Each team has a repo with their environment definitions - Uses team modules via versioned references - Separate state files per environment (dev/staging/prod)
- Separate AWS accounts per environment per team (AWS Organizations) - Cross-account IAM roles for Terraform execution - Shared services account for networking, DNS, artifact repositories - State files in a central management account S3 bucket with per-team prefixes
- Scheduled CI pipeline runs terraform plan nightly across all state files - Alerts on any non-empty plan output (drift detected) - Quarantine: drifted resources tagged, team notified, auto-PR generated
- OPA/Sentinel policies in CI: no public S3 buckets, encryption required, tagging mandatory - Pre-commit hooks for terraform fmt and validate
Code Example
# Module registry structure
modules/
├── platform/
│ ├── vpc/ # Shared VPC module
│ ├── iam-baseline/ # Account security baseline
│ └── logging/ # CloudWatch + S3 log aggregation
├── team/
│ ├── ecs-service/ # Self-service ECS deployment
│ ├── rds-instance/ # Managed database provisioning
│ └── s3-bucket/ # Compliant S3 bucket
└── teams/
├── team-alpha/
│ ├── dev/
│ │ └── main.tf # Uses team/ecs-service v2.1
│ └── prod/
│ └── main.tf
└── team-beta/
└── ...Interview Tip
This is a staff+ platform engineering question. Key signals: versioned module registry, separation of concerns between platform and team layers, per-account state isolation, and automated drift detection. Don't just describe the structure — explain WHY each layer exists.
💬 Comments
Quick Answer
Decompose into smaller state files using component-based workspaces, implement dependency-based planning with targeted applies, use Terragrunt or custom orchestration for cross-state dependencies, and leverage Terraform Cloud/Spacelift for parallel execution with run triggers.
Detailed Answer
A 45-minute plan indicates a monolithic state file with thousands of resources. Terraform refreshes every resource in state during plan (API calls to the provider). With 500+ modules in a single state, you're making thousands of API calls sequentially. Additionally, the dependency graph computation becomes expensive for large graphs.
Split by blast radius and team ownership, not by environment. Instead of one root module, create component stacks: - networking/ (VPC, subnets, TGW) - changes rarely, affects everything - compute/ (EKS, ASG) - changes weekly - data/ (RDS, ElastiCache, S3) - changes monthly - platform/ (IAM, Route53, ACM) - changes rarely Each component has its own state file and can be planned/applied independently.
Use terraform_remote_state data source or (better) use SSM Parameter Store / Terraform Cloud outputs as a decoupling layer. Terragrunt's dependency blocks automate this: dependency "networking" { config_path = "../networking" }. This creates a DAG of state files that can be applied in order.
1. -target flag for focused plans (use sparingly, not as a habit) 2. -refresh=false when you know state is current (e.g., immediately after a previous apply) 3. Provider-level parallelism: parallelism=30 (default 10) for providers that support concurrent API calls 4. State file partitioning: Keep each state under 1000 resources for sub-5-minute plans
Implement a module registry (Terraform Cloud private registry or Artifactory) with semantic versioning. Enforce module version pinning. Use Atlantis or Spacelift for PR-based workflows with automatic plan-on-PR and policy checks before apply.
Code Example
# Terragrunt configuration for component-based architecture
# terragrunt.hcl (networking component)
terraform {
source = "git::https://github.com/company/tf-modules.git//networking?ref=v2.3.1"
}
include "root" {
path = find_in_parent_folders()
}
inputs = {
vpc_cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
# terragrunt.hcl (compute component - depends on networking)
terraform {
source = "git::https://github.com/company/tf-modules.git//eks?ref=v3.1.0"
}
dependency "networking" {
config_path = "../networking"
}
inputs = {
vpc_id = dependency.networking.outputs.vpc_id
subnet_ids = dependency.networking.outputs.private_subnet_ids
}
# Parallel plan with Terragrunt
terragrunt run-all plan --terragrunt-parallelism 5
# Terraform performance tuning
export TF_CLI_ARGS_plan="-parallelism=30"
terraform plan -refresh=false # Skip refresh when state is known-good
# Module registry usage in root module
module "vpc" {
source = "app.terraform.io/mycompany/vpc/aws"
version = "~> 3.0"
}Interview Tip
This question tests whether you've actually operated Terraform at scale. The key insight is that monolithic state is the enemy of velocity. Quantify the problem: 1000 resources = 1000 API calls during refresh = 10+ minutes just for plan. Mention Terragrunt as the standard tool for multi-state orchestration.
💬 Comments
Quick Answer
Use the Terraform Plugin Framework (not the legacy SDKv2) to define resources with schema, implement CRUD operations via gRPC protocol, handle state management, and test with acceptance tests that create real resources. Custom providers are appropriate for internal platforms, proprietary APIs, or when existing providers lack features.
Detailed Answer
- Internal platform APIs (service catalog, config management) that have no public provider - Wrapping a proprietary SaaS API (custom CMDB, internal DNS) - When the official provider lacks resources you need and contributing upstream is too slow - NOT for simple API calls (use null_resource with provisioners or http data source instead)
Terraform providers communicate with the Terraform core via gRPC using the Plugin Protocol (v6 for Plugin Framework). The provider binary is a separate process that Terraform launches. The protocol defines operations: GetProviderSchema, ValidateProviderConfig, ConfigureProvider, ReadResource, PlanResourceChange, ApplyResourceChange, ImportResourceState.
The modern Plugin Framework (hashicorp/terraform-plugin-framework) replaces SDKv2. Key concepts: 1. Provider: Configures authentication (API keys, endpoints) 2. Resources: Define schema (attributes with types, required/optional/computed), implement CRUD methods 3. Data Sources: Read-only lookups 4. Schema types: StringAttribute, Int64Attribute, ListAttribute, ObjectAttribute with plan modifiers and validators
- Unit tests: Test individual functions (parsing, validation) - Acceptance tests: Use resource.Test() framework that runs real Terraform apply/destroy cycles - Use TF_ACC=1 environment variable to enable acceptance tests - Implement ImportState for every resource to support terraform import
Publish to the Terraform Registry via GitHub releases with GoReleaser. Sign with GPG key. Follow naming convention: terraform-provider-<name>.
Code Example
# Provider implementation (Go - Plugin Framework)
package provider
import (
"context"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
)
type serviceResource struct {
client *internal.APIClient
}
func (r *serviceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{Computed: true},
"name": schema.StringAttribute{Required: true},
"tier": schema.StringAttribute{Optional: true, Default: stringdefault.StaticString("standard")},
},
}
}
func (r *serviceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan serviceModel
diags := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
svc, err := r.client.CreateService(plan.Name.ValueString(), plan.Tier.ValueString())
if err != nil {
resp.Diagnostics.AddError("Create failed", err.Error())
return
}
plan.ID = types.StringValue(svc.ID)
resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}
# Acceptance test
func TestAccServiceResource(t *testing.T) {
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: `resource "myplatform_service" "test" { name = "test-svc" }`,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("myplatform_service.test", "name", "test-svc"),
resource.TestCheckResourceAttrSet("myplatform_service.test", "id"),
),
},
},
})
}
# Build and install locally
go build -o terraform-provider-myplatform
mkdir -p ~/.terraform.d/plugins/mycompany.com/internal/myplatform/1.0.0/darwin_arm64
cp terraform-provider-myplatform ~/.terraform.d/plugins/mycompany.com/internal/myplatform/1.0.0/darwin_arm64/Interview Tip
Provider development questions test deep understanding of Terraform's architecture. Know the difference between Plugin Framework and SDKv2 (Framework is the modern approach). Emphasize that you'd contribute to existing providers before building custom ones. Mention acceptance testing as critical - unit tests alone are insufficient for infrastructure providers.
💬 Comments
Quick Answer
count identifies each resource instance purely by its numeric index (resource[0], resource[1]...), so if you ever remove a microservice from the middle of the list, every resource after it shifts down one index and Terraform plans to destroy and recreate all of them — a devastating blast radius for something as simple as decommissioning one service. The better approach is for_each over a map keyed by microservice name, wrapped in a reusable module, so each resource is addressed by a stable string key that never shifts regardless of what's added or removed elsewhere in the list.
Detailed Answer
Imagine a parking garage that assigns spots purely by arrival order — car 1 in spot 1, car 2 in spot 2, and so on. If the third car ever leaves, garage management doesn't leave spot 3 empty; they shove every car behind it forward by one spot to keep things tidy. Every car now has a new spot number, even though most of them never moved their actual car. That's exactly what count does to your infrastructure: the identity of a resource is just its position in a list, not anything meaningful about the resource itself.
Terraform was designed so that each resource in state needs a stable address to track across applies. count.index gives you a cheap way to create N similar resources, and it's fine for genuinely interchangeable things like N identical subnets in a CIDR range. But the moment the members of the list have individual identity — 50 microservices each with their own name, own image, own environment variables — count conflates 'position in the list' with 'identity of the thing,' and Terraform has no way to tell the difference between 'this microservice was removed' and 'everything after index 12 shifted.'
Internally, when you change a list count is iterating over — say removing 'checkout-worker' from position 12 out of 50 — Terraform's plan diff compares the old state addresses (microservice_ecs_service[12] through [49]) against the new ones. Since 'checkout-worker' is gone, positions 13-49 now map to different services than before, so Terraform sees resource[12] changing from checkout-worker's config to notifications-worker's config, and proposes destroying and recreating every single downstream resource just to relabel them — even though 37 of those services never actually changed.
for_each solves this by keying resources with a map, for example for_each = var.microservices where microservices is a map like { "payments-api" = {...}, "checkout-worker" = {...} }. Now each resource address is aws_ecs_service.svc["checkout-worker"], a string key tied to the service's actual name, completely independent of ordering or what else exists in the map. Removing checkout-worker from the map only destroys that one resource; every other service's address, and therefore its state entry, is untouched.
At production scale for 50 microservices specifically, the real answer goes one level further: wrap the for_each block inside a reusable module (module "microservice" { for_each = var.services source = "./modules/ecs-microservice" ... }), so the actual ECS service, task definition, target group, and autoscaling policy definitions live in one place and get parameterized per service via a values map — often sourced from a YAML or JSON file, or even a separate repo per team, so each microservice team owns its own entry without needing to touch the shared root module. The non-obvious gotcha: switching an existing count-based resource to for_each is itself a breaking, destructive change unless you first run terraform state mv for every resource to remap old numeric addresses to the new string-keyed addresses — engineers who skip this step and just swap count for for_each in the code will watch Terraform plan to destroy and recreate everything on the very next apply.
Code Example
# variables.tf — services keyed by name, not by numeric position
variable "microservices" {
type = map(object({
image = string # container image URI
cpu = number # vCPU units, e.g. 256
memory = number # MiB, e.g. 512
desired_count = number # baseline replica count
}))
default = {
"payments-api" = { image = "123456789012.dkr.ecr.us-east-1.amazonaws.com/payments-api:2.4", cpu = 512, memory = 1024, desired_count = 3 }
"checkout-worker" = { image = "123456789012.dkr.ecr.us-east-1.amazonaws.com/checkout-worker:1.1", cpu = 256, memory = 512, desired_count = 2 }
# ...48 more entries, often generated from a YAML file with `yamldecode()`
}
}
# main.tf — one reusable module invocation per microservice, keyed by name
module "microservice" {
for_each = var.microservices
source = "./modules/ecs-microservice"
name = each.key # e.g. "checkout-worker" — stable identity
image = each.value.image
cpu = each.value.cpu
memory = each.value.memory
desired_count = each.value.desired_count
cluster_arn = aws_ecs_cluster.platform.arn
}
# Removing "checkout-worker" from the map now only destroys module.microservice["checkout-worker"]
# Every other service keeps its exact same resource address and is left untouched.
terraform planInterview Tip
A junior engineer typically says 'for_each is better because it uses names instead of numbers,' which is correct but shallow. For a senior or architect role, the interviewer wants you to walk through the actual state-diff mechanics of why a single removal cascades into 37 unnecessary destroy-and-recreate operations under count, because that's the operational disaster that shows up as an unplanned multi-hour outage in a real change window. They're also listening for whether you know that migrating an existing count-based codebase to for_each requires terraform state mv for every resource first, since naively swapping the argument is itself destructive. Mentioning module composition — wrapping for_each around a reusable module sourced from a values map or external YAML file — signals you've actually operated a multi-team platform at this scale, not just read about the two arguments in documentation.
◈ Architecture Diagram
count (index-based) for_each (key-based)
┌───┬───┬───┬───┐ ┌────────────┬───────────┐
│[0]│[1]│[2]│[3]│ │"payments" │"checkout" │
└───┴─┬─┴───┴───┘ └────────────┴─────┬─────┘
│ remove [1] │ remove
▼ ▼
┌───┬───┬───┐ ┌────────────┐
│[0]│[1]│[2]│ ← everything │"payments" │ ← untouched
└───┴───┴───┘ shifts + rebuilds └────────────┘ others💬 Comments