36 Terraform advanced questions with detailed answers, HCL examples, and interview tips.
Quick Answer
Remote state gives a team a shared, durable view of managed infrastructure, while state locking prevents two Terraform runs from changing the same state at the same time. If state is split around convenience instead of ownership and dependency boundaries, teams create hidden coupling, stale outputs, and unsafe apply ordering.
Detailed Answer
Think of Terraform state like the official city property registry. If two clerks update the same parcel records at the same time, one may overwrite the other's change and the registry becomes untrustworthy. Remote state puts the registry in a shared office instead of on one clerk's laptop, and locking is the sign on the desk that says one clerk is actively editing this registry right now. Without that sign, production changes become a race.
In Terraform, state maps configuration resources to real infrastructure objects and stores attributes Terraform needs for planning. Local state is simple for one person, but it breaks down for teams because every operator needs the newest file and must coordinate manually. Remote backends such as HCP Terraform, S3 with locking, Azure Blob, or GCS move state to a shared system. Locking prevents concurrent plans or applies from corrupting the same state or producing plans from stale assumptions.
The internal flow is: Terraform loads configuration, initializes the backend, reads the latest state, attempts to acquire a lock, refreshes resource data through providers, builds a dependency graph, produces a plan, and applies changes if approved. During apply, state is updated as resources change. If the process crashes, the lock may need cleanup, but force-unlocking should be treated like removing a warning tag from dangerous machinery: only do it after confirming no run is active.
At scale, teams split state to reduce blast radius, improve performance, and align ownership. A network platform state might own VPCs, subnets, and transit gateways, while an application state consumes published subnet IDs. The dangerous split is by file size or folder convenience rather than lifecycle. If an app state creates security groups that the network state mutates, or two states manage different arguments on the same resource, Terraform cannot reason globally and drift becomes normal.
The non-obvious gotcha is that remote state outputs are not a service discovery system. They expose snapshots from another state, and consumers may act on outputs that are valid syntactically but operationally stale. Senior engineers prefer stable contracts, narrow outputs, versioned modules, explicit ownership, and separate data stores for values consumed by non-Terraform systems. They also design emergency unlock procedures, backend access controls, and audit trails before the first production incident.
Code Example
terraform init -backend-config=env/prod.s3backend # Initializes the shared production backend instead of using local state. terraform plan -out=prod.tfplan # Creates an auditable plan from the latest locked remote state. terraform apply prod.tfplan # Applies exactly the reviewed plan while Terraform holds the backend lock. terraform force-unlock LOCK_ID # Removes a stale lock only after confirming no apply is still running. terraform state list # Lists resources owned by this state so ownership boundaries can be reviewed.
Interview Tip
A junior engineer typically answers that remote state is for sharing the tfstate file, but for a senior/architect role, the interviewer is actually looking for operational safety. Explain state as Terraform's source of managed-object identity, locking as concurrency control, and state splitting as an ownership decision. Strong answers cover stale remote outputs, force-unlock risk, provider refresh behavior, and why two states must never manage the same resource. That shows you understand Terraform as a production change system, not just a provisioning CLI.
◈ Architecture Diagram
┌──────────┐
│ Engineer │
└────┬─────┘
↓
┌──────────┐
│ Backend │
└────┬─────┘
↓ lock
┌──────────┐
│ State │
└────┬─────┘
↓ graph
┌──────────┐
│ Plan │
└────┬─────┘
↓ apply
┌──────────┐
│ Cloud │
└──────────┘💬 Comments
Quick Answer
State locking prevents concurrent modifications by acquiring a lock on the state file before any write operation. When using backends like S3+DynamoDB, Terraform creates a lock entry with a unique ID. If a second operator attempts a write while locked, Terraform returns a ConditionalCheckFailedException and blocks until the lock is released or the operator force-unlocks.
Detailed Answer
Terraform state locking is a concurrency control mechanism that prevents two or more operators from writing to the same state file simultaneously, which would cause state corruption and potentially orphaned cloud resources. Think of it like a database row-level lock: before Terraform can modify state, it must first acquire an exclusive lock, and any other process attempting to modify the same state must wait or fail.
When you run terraform apply or terraform plan (with certain backends), Terraform sends a Lock request to the backend. For S3+DynamoDB, this means writing a record to the DynamoDB table with the state file's digest as the partition key and a unique LockID. The LockID contains the operator's hostname, the Terraform operation type, the workspace name, and a timestamp. DynamoDB's conditional write ensures atomicity: if a record already exists with that key, the write fails with a ConditionalCheckFailedException, and Terraform reports that the state is locked by another process.
In production, lock conflicts arise in several scenarios. The most common is when two engineers run terraform apply on the same workspace concurrently. Terraform will display the lock holder's information including their username, operation type, and when the lock was acquired. The blocked operator must wait for the first operation to complete. Another common scenario is a CI/CD pipeline crash: if a pipeline runner dies mid-apply, the lock remains in DynamoDB as a stale lock. This requires manual intervention using terraform force-unlock with the lock ID.
Force-unlock is dangerous in production because you cannot guarantee the previous operation completed cleanly. Before force-unlocking, you should verify the state of the actual infrastructure using AWS console or CLI, check the DynamoDB table directly to confirm the lock metadata, and review CloudTrail logs for any API calls made by the crashed process. A safer pattern is to implement lock timeouts in your CI/CD pipeline: wrap terraform apply in a timeout command and have the pipeline explicitly run terraform force-unlock only after confirming no infrastructure changes are in progress.
Different backends handle locking differently. Consul uses its built-in session-based locking with TTL. Azure Blob Storage uses native blob leases. Google Cloud Storage uses object generation numbers for optimistic locking. Not all backends support locking; the local backend does via filesystem locks, but NFS-mounted local backends have notoriously unreliable locking, which is why teams migrate to remote backends. The etcd backend uses its compare-and-swap primitive. Understanding your backend's locking semantics is critical for disaster recovery planning, because a corrupted lock table can block all infrastructure changes across your organization.
Code Example
# Backend configuration with S3 state locking via DynamoDB
terraform {
# Define the S3 backend for remote state storage
backend "s3" {
# S3 bucket storing the payments platform state files
bucket = "fintech-corp-terraform-state-prod"
# State file path scoped to the payments VPC workspace
key = "infrastructure/payments-vpc/terraform.tfstate"
# AWS region where the state bucket resides
region = "us-east-1"
# DynamoDB table that manages state locks
dynamodb_table = "terraform-state-locks-prod"
# Enable server-side encryption for state at rest
encrypt = true
# Use the shared infrastructure AWS profile
profile = "fintech-infra-admin"
}
}
# DynamoDB table resource for state locking (provisioned separately)
resource "aws_dynamodb_table" "terraform_locks" {
# Table name matching the backend configuration reference
name = "terraform-state-locks-prod"
# Pay-per-request to avoid capacity planning for lock operations
billing_mode = "PAY_PER_REQUEST"
# LockID is the required partition key for Terraform state locks
hash_key = "LockID"
# Define the LockID attribute as a string type
attribute {
name = "LockID"
type = "S"
}
# Tag for cost allocation and ownership tracking
tags = {
Team = "platform-engineering"
Environment = "production"
ManagedBy = "terraform-bootstrap"
}
}
# Example: force-unlock command when a CI pipeline crashes
# terraform force-unlock 2b6a6738-5ef0-7c20-a036-48eb6273784fInterview Tip
A junior engineer typically says 'we use DynamoDB for locking' without explaining the mechanism. To stand out, describe the conditional write to DynamoDB using LockID as the partition key, explain what metadata is stored in the lock record (hostname, operation, timestamp), and walk through a real incident scenario: a CI runner crashes mid-apply, the lock is orphaned, and you must verify infrastructure state before force-unlocking. Mention that not all backends support locking, and that NFS-based local backends have unreliable locking, which is a common migration driver to remote backends.
◈ Architecture Diagram
┌─────────────────────────────────────────────────────────────┐ │ Terraform State Locking Flow │ ├─────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ Lock Request ┌──────────────────┐ │ │ │ Operator A │──────────────────→│ DynamoDB Table │ │ │ │ terraform │ LockID: abc123 │ terraform-state │ │ │ │ apply │←──────────────────│ -locks-prod │ │ │ └──────────────┘ Lock Acquired └──────────────────┘ │ │ │ ↑ │ │ │ Write State │ Lock Request │ │ ↓ │ (BLOCKED) │ │ ┌──────────────┐ ┌──────────────────┐ │ │ │ S3 Bucket │ │ Operator B │ │ │ │ fintech-corp │ │ terraform apply │ │ │ │ -terraform │ │ (waiting...) │ │ │ │ -state-prod │ └──────────────────┘ │ │ └──────────────┘ │ │ │ │ │ │ Apply Complete │ │ ↓ │ │ ┌──────────────┐ Unlock Request ┌──────────────────┐ │ │ │ Operator A │──────────────────→│ DynamoDB Table │ │ │ │ (finished) │ Delete LockID │ (lock released) │ │ │ └──────────────┘ └──────────────────┘ │ │ │ │ │ ↓ │ │ ┌──────────────────┐ │ │ │ Operator B │ │ │ │ Lock Acquired │ │ │ │ (proceeds) │ │ │ └──────────────────┘ │ └─────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Use a hub-and-spoke model with separate state files per account, shared modules for common patterns, and assume-role providers to manage cross-account resources. Structure repositories with account-level directories, environment-level workspaces, and a central module registry for VPC, IAM, and security baseline configurations.
Detailed Answer
Structuring Terraform for a multi-account AWS organization requires solving three interconnected problems: provider authentication across accounts, state isolation between accounts, and code reuse across similar environments. Think of it like managing a franchise: each store (account) runs the same playbook but has its own inventory (state), and headquarters (management account) needs oversight into all of them.
The foundation is AWS Organizations with a well-defined account structure. Typically you have a management account (for Organizations API and billing), a security account (for GuardDuty, SecurityHub, CloudTrail aggregation), a shared-services account (for CI/CD, artifact repositories, DNS), and then workload accounts per environment or per team. Each account gets its own Terraform state file to ensure blast radius isolation: a misconfigured apply in the staging account cannot corrupt production state.
For provider configuration, the recommended pattern is assume-role chaining. Your CI/CD pipeline authenticates to a central deployment account using OIDC (for GitHub Actions) or instance profiles (for Jenkins on EC2), then assumes environment-specific roles in target accounts. Each account has a TerraformExecutionRole with least-privilege permissions. The provider block uses assume_role with the target account's role ARN, and you parameterize the account ID using variables or a map lookup.
The repository structure typically follows one of two patterns. The first is a monorepo with directory-per-account: infrastructure/accounts/production/, infrastructure/accounts/staging/, each with their own backend configuration and tfvars. The second is a module-based approach where a single root configuration uses for_each over a map of accounts to deploy baseline resources. The monorepo approach is simpler to reason about but leads to code duplication. The module approach is DRY but increases blast radius.
Shared modules are the cornerstone of multi-account management. You create versioned modules for common patterns: a VPC module that enforces CIDR allocation from a central IPAM, a security-baseline module that deploys Config rules and GuardDuty, an IAM-baseline module that creates standard roles. These modules are published to a private Terraform registry or referenced via Git tags.
Production gotchas are numerous. Cross-account resource references require careful handling: you cannot reference a resource in another account's state without remote state data sources or SSM Parameter Store lookups. VPC peering across accounts needs accepter-side resources managed by the accepter account's Terraform. Service Control Policies in the management account can block Terraform operations in child accounts if not carefully scoped. And state backend permissions must be tightly controlled: each account's Terraform role should only access its own state prefix in S3.
Code Example
# Provider configuration for multi-account AWS organization
# Map of account IDs for the fintech organization
locals {
# Central registry of all AWS account IDs by environment name
account_ids = {
production = "111111111111"
staging = "222222222222"
security = "333333333333"
shared-svcs = "444444444444"
}
# Construct the IAM role ARN for cross-account access
target_role_arn = "arn:aws:iam::${local.account_ids[var.environment]}:role/TerraformExecutionRole"
}
# Default provider assumes role into the target workload account
provider "aws" {
# Region standardized across the organization
region = "us-east-1"
# Assume the execution role in the target account
assume_role {
# Role ARN constructed from the environment variable
role_arn = local.target_role_arn
# Session name for CloudTrail audit traceability
session_name = "terraform-ci-${var.environment}"
}
# Default tags applied to every resource in this account
default_tags {
tags = {
Environment = var.environment
ManagedBy = "terraform"
CostCenter = "platform-engineering"
}
}
}
# Aliased provider for the shared-services account (DNS, ECR)
provider "aws" {
# Alias used when referencing shared-services resources
alias = "shared_services"
# Same region as the primary provider
region = "us-east-1"
# Assume role into the shared-services account
assume_role {
# Shared services account role for DNS and registry management
role_arn = "arn:aws:iam::${local.account_ids["shared-svcs"]}:role/TerraformDNSRole"
# Distinct session name for audit separation
session_name = "terraform-ci-shared-svcs"
}
}
# VPC module instantiation using the organization's standard module
module "payments_vpc" {
# Versioned module from the private Terraform registry
source = "app.terraform.io/fintech-corp/vpc/aws"
# Pin to a specific minor version for stability
version = "3.2.1"
# VPC name following the organization naming convention
vpc_name = "payments-vpc-${var.environment}"
# CIDR allocated from the central IPAM pool
vpc_cidr = var.vpc_cidr_blocks[var.environment]
# Enable DNS resolution for private hosted zone lookups
enable_dns_hostnames = true
# Deploy NAT gateways in each AZ for high availability
single_nat_gateway = false
}Interview Tip
A junior engineer typically describes a flat directory structure with one state file per environment but does not address cross-account authentication, blast radius isolation, or module versioning. To impress, walk through the assume-role chain from CI/CD to target account, explain why each account needs its own state prefix in S3 with separate DynamoDB lock entries, and discuss how Service Control Policies can unexpectedly block Terraform operations. Mention OIDC federation for keyless CI/CD authentication and explain how you version shared modules using a private registry with semantic versioning to prevent breaking changes.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐ │ AWS Organization Account Structure │ ├───────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────────┐ │ │ │ Management Account │ │ │ │ (Organizations API) │ │ │ │ SCPs, Billing │ │ │ └──────────┬──────────┘ │ │ │ │ │ ┌───────┴────────┬──────────────┬──────────────┐ │ │ ↓ ↓ ↓ ↓ │ │ ┌──────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ │ Security │ │ Shared │ │ Production│ │ Staging │ │ │ │ Account │ │ Services │ │ Account │ │ Account │ │ │ │ │ │ Account │ │ │ │ │ │ │ │ GuardDuty │ │ ECR, DNS │ │ payments │ │ payments │ │ │ │ SecHub │ │ CI/CD │ │ -vpc │ │ -vpc │ │ │ │ CloudTrail│ │ Artifacts │ │ user-auth │ │ user-auth │ │ │ └──────────┘ └─────┬─────┘ └───────────┘ └───────────┘ │ │ │ │ │ │ AssumeRole │ │ ↓ │ │ ┌──────────────┐ │ │ │ CI/CD Runner │ │ │ │ (OIDC Auth) │ │ │ │ │→ assume TerraformExecutionRole │ │ │ │→ per target account │ │ └──────────────┘ │ │ │ │ State Isolation: │ │ ┌────────────────────────────────────────────────────┐ │ │ │ s3://fintech-terraform-state/production/tfstate │ │ │ │ s3://fintech-terraform-state/staging/tfstate │ │ │ │ s3://fintech-terraform-state/security/tfstate │ │ │ └────────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Moved blocks declare that a resource has been renamed or relocated within your configuration, allowing Terraform to update the state mapping without destroying and recreating the actual infrastructure. They replace the manual terraform state mv command with a declarative, version-controlled approach to refactoring.
Detailed Answer
Terraform moved blocks, introduced in Terraform 1.1, provide a declarative way to refactor your configuration without destroying and recreating real infrastructure. Think of it like a postal forwarding address: you tell Terraform that the resource formerly known as aws_instance.web_server now lives at module.compute.aws_instance.payments_api, and Terraform updates its internal address book (the state file) without touching the actual mail (cloud resources).
Before moved blocks, refactoring Terraform code was perilous. If you renamed a resource from aws_rds_instance.database to aws_rds_instance.payments_db, Terraform would plan to destroy the old resource and create a new one, because it tracks resources by their configuration address. The workaround was terraform state mv, an imperative command that modifies state directly. This was error-prone, not version-controlled, and required coordination: every team member had to run the state mv command in the right order before applying, or their plan would show destructive changes.
Moved blocks solve this elegantly. You add a moved block to your configuration that declares the old and new addresses. When Terraform processes the configuration, it reads the moved blocks before computing the plan, updates the state mapping in memory, and then generates a plan based on the corrected addresses. The result is a plan that shows no changes (or only the changes you intentionally made), and the state file is updated as part of the normal apply process.
Moved blocks support several refactoring patterns. Resource renaming is the simplest: changing a resource's logical name. Module restructuring lets you move resources into or out of modules, or between modules. Adding for_each to a resource that was previously a single instance requires moving from the singleton address to an indexed address. You can also use moved blocks when splitting a monolithic configuration into smaller modules.
Production gotchas are important to understand. Moved blocks are processed in order, so if you have a chain of moves (A to B, then B to C), they must be listed in sequence. You cannot move a resource across provider boundaries: moving aws_instance to google_compute_instance is not valid because the underlying APIs and schemas differ. Moved blocks should be kept in configuration temporarily during the transition period and removed after all workspaces have applied the change, because stale moved blocks referencing non-existent source addresses will generate warnings. When moving resources that have count or for_each, you must specify the exact index or key in both the from and to addresses. Finally, moved blocks do not work across state file boundaries: you cannot move a resource from one state to another using moved blocks alone; that still requires terraform state mv with the -state flags or import blocks.
Code Example
# Refactoring: moving a standalone RDS instance into a database module
# This moved block tells Terraform the resource was relocated
moved {
# Original address when the RDS instance was defined at root level
from = aws_rds_cluster.payments_db
# New address after restructuring into a dedicated database module
to = module.payments_database.aws_rds_cluster.primary
}
# Refactoring: renaming a security group for clarity
moved {
# Old name that was too generic for the growing codebase
from = aws_security_group.api_sg
# New name that follows the team naming convention
to = aws_security_group.payments_api_ingress
}
# Refactoring: converting a single NAT gateway to for_each
moved {
# Original singleton NAT gateway resource
from = aws_nat_gateway.payments_vpc_nat
# New indexed address after adding for_each with AZ keys
to = aws_nat_gateway.payments_vpc_nat["us-east-1a"]
}
# The database module after refactoring (modules/payments-database/main.tf)
resource "aws_rds_cluster" "primary" {
# Cluster identifier following the fintech naming convention
cluster_identifier = "payments-db-${var.environment}"
# Aurora PostgreSQL engine for ACID-compliant transaction processing
engine = "aurora-postgresql"
# Engine version pinned for compatibility with payments schema
engine_version = "15.4"
# Database name for the payments transaction ledger
database_name = "payments_ledger"
# Master credentials managed through AWS Secrets Manager
master_username = "payments_admin"
# Reference the secret ARN for password rotation
manage_master_user_password = true
# Deploy across private subnets in the payments VPC
db_subnet_group_name = var.db_subnet_group_name
# Attach the database-specific security group
vpc_security_group_ids = [var.db_security_group_id]
# Enable deletion protection in production environments
deletion_protection = var.environment == "production" ? true : false
# Backup retention for compliance (30 days for production)
backup_retention_period = var.environment == "production" ? 30 : 7
}Interview Tip
A junior engineer typically describes terraform state mv as the only way to rename resources and may not know about moved blocks at all. To demonstrate depth, explain the historical pain point of imperative state manipulation, how moved blocks make refactoring declarative and version-controlled, and walk through a concrete scenario: migrating a standalone RDS instance into a module. Mention the ordering requirement for chained moves, the limitation that moves cannot cross provider boundaries, and the best practice of removing stale moved blocks after all workspaces have applied the transition.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐
│ Terraform Moved Block Processing │
├───────────────────────────────────────────────────────────────┤
│ │
│ Before Refactoring: │
│ ┌──────────────────┐ ┌───────────────────────┐ │
│ │ main.tf │ │ State File │ │
│ │ │ │ │ │
│ │ aws_rds_cluster │───────→│ aws_rds_cluster │ │
│ │ .payments_db │ │ .payments_db │ │
│ │ │ │ → arn:aws:rds:... │ │
│ └──────────────────┘ └───────────────────────┘ │
│ │
│ After Refactoring (with moved block): │
│ ┌──────────────────┐ ┌───────────────────────┐ │
│ │ main.tf │ │ State File │ │
│ │ │ │ │ │
│ │ moved { │ │ module.payments_ │ │
│ │ from = aws_rds │───────→│ database.aws_rds_ │ │
│ │ _cluster │ │ cluster.primary │ │
│ │ .payments_db │ │ → arn:aws:rds:... │ │
│ │ to = module. │ │ (SAME cloud resource)│ │
│ │ payments_ │ └───────────────────────┘ │
│ │ database. │ │
│ │ aws_rds_ │ │
│ │ cluster.primary│ │
│ │ } │ │
│ └──────────────────┘ │
│ │
│ Plan Output: │
│ ┌───────────────────────────────────────────────────┐ │
│ │ # aws_rds_cluster.payments_db has moved to │ │
│ │ # module.payments_database.aws_rds_cluster.primary│ │
│ │ No changes. Infrastructure is up-to-date. │ │
│ └───────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘💬 Comments
Quick Answer
Terraform plan detects drift by reading the current state of every resource via provider API calls and comparing it against the state file. It identifies differences as drift. However, it only checks attributes it manages, cannot detect out-of-band resource creation, misses resources not in state, and some providers do not report all attributes accurately.
Detailed Answer
Terraform plan's drift detection works through a refresh-then-diff process. Think of it like an inventory audit: Terraform reads the last known inventory (state file), physically checks every item in the warehouse (API calls to cloud providers), updates the inventory with actual findings (state refresh), and then compares the updated inventory against the blueprint (configuration). Any discrepancies between the refreshed state and the desired configuration become the plan.
The refresh phase is where drift detection happens. For every resource tracked in the state file, Terraform calls the provider's ReadResource RPC method, which translates to cloud API calls. For an aws_rds_cluster.payments_db, this triggers a DescribeDBClusters API call. The provider compares the API response against the state file's recorded attributes. If the production database's backup_retention_period was changed from 30 to 7 via the AWS console, the refresh detects this as drift and updates the in-memory state.
After refresh, Terraform diffs the refreshed state against the configuration. If your configuration says backup_retention_period = 30 but the refreshed state shows 7, the plan proposes changing it back to 30. This is Terraform's self-healing property: it converges actual infrastructure toward the declared configuration.
However, the limitations are significant and often misunderstood in production. First, Terraform only detects drift on resources it manages. If someone creates an additional security group rule via the AWS console that is not in Terraform's state, Terraform has no knowledge of it. This is the 'unknown unknowns' problem: Terraform cannot detect resources it does not track.
Second, not all providers report all attributes during refresh. Some cloud APIs return partial data, or certain attributes are write-only (like passwords). The AWS provider, for example, cannot detect drift on certain IAM policy document orderings because the API returns a canonicalized version that may not match the original.
Third, the refresh phase can be slow and expensive. In a large infrastructure with thousands of resources, the refresh makes thousands of API calls, which can hit rate limits and take tens of minutes. Terraform 1.5 introduced the -refresh=false flag to skip refresh for faster plans, but this trades drift detection for speed.
Fourth, eventual consistency in cloud APIs can cause false drift detection. After an AWS resource is created, the API may return stale data for seconds or minutes. Running plan immediately after apply can show phantom drift that resolves itself.
Fifth, Terraform cannot detect drift on resource dependencies that are not explicitly modeled. If a VPC peering connection's route table was modified outside Terraform but the peering resource itself was not, Terraform might not detect the functional impact. Tools like AWS Config, CloudTrail-based drift detection, or Driftctl (now part of Snyk) fill these gaps by scanning entire accounts for unmanaged resources.
Code Example
# Demonstrating drift detection behavior with refresh configuration
# Backend configuration for the payments infrastructure state
terraform {
# Required Terraform version for refresh-only plan support
required_version = ">= 1.5.0"
# S3 backend with state locking for the payments platform
backend "s3" {
# State bucket for the production payments infrastructure
bucket = "fintech-corp-terraform-state-prod"
# State file path for the payments database workspace
key = "payments-database/terraform.tfstate"
# Primary region for state storage
region = "us-east-1"
# Lock table to prevent concurrent modifications
dynamodb_table = "terraform-state-locks-prod"
}
}
# RDS cluster that we want to detect drift on
resource "aws_rds_cluster" "payments_db" {
# Cluster identifier for the payments transaction database
cluster_identifier = "payments-db-production"
# Aurora PostgreSQL engine for transaction processing
engine = "aurora-postgresql"
# Engine version validated by the DBA team
engine_version = "15.4"
# Backup retention: 30 days for PCI compliance
# If someone changes this via console, plan will detect drift
backup_retention_period = 30
# Deletion protection must stay enabled in production
deletion_protection = true
# Preferred maintenance window outside peak transaction hours
preferred_maintenance_window = "sun:03:00-sun:04:00"
}
# Lifecycle rule to ignore drift on specific attributes
resource "aws_autoscaling_group" "payments_api_fleet" {
# ASG name following the organization convention
name = "payments-api-fleet-production"
# Desired capacity managed by autoscaling policies, not Terraform
desired_capacity = 6
# Minimum instances for baseline transaction processing
min_size = 3
# Maximum instances during peak shopping events
max_size = 24
# Launch template for the payments API container hosts
launch_template {
# Reference the payments API launch template
id = aws_launch_template.payments_api.id
# Always use the latest validated AMI version
version = "$Latest"
}
# Ignore drift on desired_capacity because autoscaling changes it
lifecycle {
# Prevent Terraform from reverting autoscaler decisions
ignore_changes = [desired_capacity]
}
}
# Refresh-only plan command to detect drift without proposing changes
# terraform plan -refresh-only -out=drift-report.tfplanInterview Tip
A junior engineer typically says 'terraform plan shows what will change' without distinguishing between drift detection and configuration changes. To demonstrate advanced understanding, explain the two-phase process: refresh (API calls to detect drift) and diff (comparing refreshed state to configuration). Discuss the ignore_changes lifecycle rule as a deliberate drift acceptance pattern for autoscaling groups. Mention the -refresh-only flag for audit-only drift scans, and acknowledge limitations like write-only attributes, eventual consistency false positives, and the inability to detect unmanaged resources. Reference tools like Driftctl or AWS Config as complementary solutions.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐ │ Terraform Plan Drift Detection Flow │ ├───────────────────────────────────────────────────────────────┤ │ │ │ Phase 1: Refresh (Drift Detection) │ │ ┌──────────────┐ ReadResource ┌──────────────────┐ │ │ │ State File │ RPC calls │ Cloud Provider │ │ │ │ │──────────────────→│ APIs │ │ │ │ payments_db: │ │ │ │ │ │ retention=30 │←──────────────────│ Actual: ret=7 │ │ │ │ │ API Response │ (console change) │ │ │ └──────┬───────┘ └──────────────────┘ │ │ │ │ │ │ Update in-memory state │ │ ↓ │ │ ┌──────────────┐ │ │ │ Refreshed │ │ │ │ State │ payments_db: retention=7 (drift detected) │ │ └──────┬───────┘ │ │ │ │ │ Phase 2: Diff (Plan Generation) │ │ │ │ │ ↓ │ │ ┌──────────────┐ Compare ┌──────────────────┐ │ │ │ Refreshed │─────────────→│ Configuration │ │ │ │ State │ │ (main.tf) │ │ │ │ retention=7 │ │ retention=30 │ │ │ └──────────────┘ └──────────────────┘ │ │ │ │ │ ↓ │ │ ┌───────────────────────────────────────────────────┐ │ │ │ Plan Output: │ │ │ │ ~ aws_rds_cluster.payments_db │ │ │ │ ~ backup_retention_period: 7 → 30 │ │ │ │ (drift will be corrected on apply) │ │ │ └───────────────────────────────────────────────────┘ │ │ │ │ Limitations: │ │ ┌────────────────────────────────────────────────┐ │ │ │ ✗ Cannot detect unmanaged resources │ │ │ │ ✗ Write-only attributes invisible to refresh │ │ │ │ ✗ Eventual consistency → false drift │ │ │ │ ✗ Rate limits slow large-scale refresh │ │ │ └────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Implement a CI/CD pipeline that runs terraform plan on pull requests, posts the plan output as a PR comment for review, requires manual approval before apply, and uses remote state locking to prevent concurrent operations. Use OIDC authentication, separate plan and apply stages, and implement policy-as-code gates with Sentinel or OPA.
Detailed Answer
A production-grade Terraform CI/CD pipeline must solve five problems: authentication without long-lived credentials, plan visibility for reviewers, approval gates before destructive changes, concurrency control to prevent conflicting applies, and policy enforcement to catch compliance violations before they reach infrastructure. Think of it like a surgical operation workflow: the surgeon (engineer) proposes an operation (plan), it gets reviewed by a board (PR review), approved by an authority (manual approval gate), and only then executed in a controlled environment (apply) with safeguards (state locking).
Authentication should use OIDC federation. GitHub Actions, GitLab CI, and CircleCI all support OIDC tokens that can be exchanged for short-lived AWS credentials via STS AssumeRoleWithWebIdentity. This eliminates the need to store AWS access keys as CI secrets, which is a common audit finding. The OIDC trust policy should be scoped to specific repositories and branches to prevent unauthorized access.
The pipeline structure typically has three stages. The first stage runs on every pull request: terraform init, terraform validate, terraform fmt -check, and terraform plan -out=plan.tfplan. The plan output is captured and posted as a PR comment using a tool like tfcmt or a custom script that parses the plan JSON output. This gives reviewers visibility into exactly what will change.
The second stage is the approval gate. For non-production environments, this might be automatic after PR merge. For production, it requires explicit manual approval. In GitHub Actions, this is implemented using environments with required reviewers. In GitLab, it is a manual job gate. The approval should be from someone other than the PR author (four-eyes principle) and ideally from a platform engineering team member who understands the blast radius.
The third stage runs terraform apply using the saved plan file. This is critical: never re-run plan during apply, because infrastructure may have changed between the plan and apply stages. The saved plan file ensures exactly what was reviewed gets applied. After apply, the pipeline should post the apply output back to the PR or a Slack channel for visibility.
Policy-as-code adds guardrails. HashiCorp Sentinel (Terraform Cloud/Enterprise) or Open Policy Agent (open source) evaluate the plan against organizational policies: no public S3 buckets, all RDS instances must have encryption, all security groups must have descriptions. These checks run after plan but before approval, catching violations early.
Production gotchas include handling plan file expiration (plan files reference specific provider plugin versions and state serial numbers, so they expire when state changes), managing workspace-level parallelism (only one pipeline should operate on a workspace at a time), and dealing with long-running applies that exceed CI timeout limits. Some teams implement a Terraform-specific lock in Redis or DynamoDB beyond the state lock, to queue pipeline runs at the workspace level.
Code Example
# GitHub Actions workflow for Terraform CI/CD with approval gates
# File: .github/workflows/terraform-payments-infra.yml
name: Payments Infrastructure Terraform Pipeline
# Trigger on pull requests targeting the main branch
on:
pull_request:
# Only run when infrastructure code changes
paths:
- 'infrastructure/payments/**'
push:
branches:
- main
paths:
- 'infrastructure/payments/**'
# OIDC token permissions for AWS authentication
permissions:
# Allow requesting OIDC JWT tokens from GitHub
id-token: write
# Allow posting plan output as PR comments
pull-requests: write
# Allow reading repository contents
contents: read
# Prevent concurrent runs on the same branch/PR
concurrency:
# Group by workflow name and PR number or branch
group: terraform-payments-${{ github.event.pull_request.number || github.ref }}
# Cancel in-progress plan runs but never cancel apply
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Plan stage: runs on every pull request
terraform-plan:
# Use the latest Ubuntu runner for consistency
runs-on: ubuntu-latest
# Only run plan on pull requests, not on merge
if: github.event_name == 'pull_request'
steps:
# Checkout the payments infrastructure code
- uses: actions/checkout@v4
# Configure AWS credentials via OIDC federation
- uses: aws-actions/configure-aws-credentials@v4
with:
# OIDC role scoped to this repository and branch
role-to-assume: arn:aws:iam::111111111111:role/GitHubActions-TerraformPlan
# Region for API calls and state backend
aws-region: us-east-1
# Install the pinned Terraform version
- uses: hashicorp/setup-terraform@v3
with:
# Version locked to match team standard
terraform_version: 1.7.4
# Initialize Terraform with backend configuration
- name: Terraform Init
# Run init in the payments infrastructure directory
run: terraform init -input=false
working-directory: infrastructure/payments
# Run format check to enforce code style
- name: Terraform Format Check
# Fail the pipeline if code is not formatted
run: terraform fmt -check -recursive
working-directory: infrastructure/payments
# Generate the execution plan and save to file
- name: Terraform Plan
# Save plan to file for use in apply stage
run: terraform plan -input=false -out=payments.tfplan
working-directory: infrastructure/payments
# Post plan output as a PR comment for reviewers
- name: Post Plan to PR
# Use tfcmt for formatted plan comments
run: tfcmt plan -- terraform show payments.tfplan
working-directory: infrastructure/payments
# Apply stage: runs after merge with manual approval
terraform-apply:
# Use the latest Ubuntu runner for consistency
runs-on: ubuntu-latest
# Only run apply on push to main (after PR merge)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# Require manual approval from the platform-admins team
environment: production-payments
steps:
# Checkout the merged infrastructure code
- uses: actions/checkout@v4
# Configure AWS credentials with apply permissions
- uses: aws-actions/configure-aws-credentials@v4
with:
# Apply role has write permissions to production
role-to-assume: arn:aws:iam::111111111111:role/GitHubActions-TerraformApply
# Same region as the plan stage
aws-region: us-east-1
# Install the same Terraform version as plan stage
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.4
# Initialize and apply the configuration
- name: Terraform Init and Apply
# Auto-approve because approval happened via GitHub environment
run: |
terraform init -input=false
terraform apply -input=false -auto-approve
working-directory: infrastructure/paymentsInterview Tip
A junior engineer typically describes a simple pipeline that runs plan and apply sequentially without addressing authentication security, approval workflows, or concurrency control. To stand out, emphasize OIDC federation over stored credentials, explain why you save the plan file and never re-plan during apply (the infrastructure may have changed between stages), discuss the four-eyes principle for production approvals, and mention policy-as-code integration with OPA or Sentinel. Describe how you handle pipeline concurrency at the workspace level to prevent two merges from applying conflicting changes simultaneously.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐ │ Terraform CI/CD Pipeline with Approval Gates │ ├───────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ │ │ │ Engineer │ │ │ │ Opens PR │ │ │ └──────┬───────┘ │ │ │ │ │ ↓ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Stage 1: Plan (on PR) │ │ │ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │ │ │ │ init │→│validate│→│fmt chk │→│ plan │ │ │ │ │ └────────┘ └────────┘ └────────┘ └───┬────┘ │ │ │ └──────────────────────────────────────────┬───────┘ │ │ │ │ │ ┌────────────────────────────────────┘ │ │ ↓ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Policy Check (OPA / Sentinel) │ │ │ │ ┌────────────────────────────────────────┐ │ │ │ │ │ No public S3 buckets │ │ │ │ │ │ All RDS encrypted │ │ │ │ │ │ Security groups have descriptions │ │ │ │ │ └────────────────────────────────────────┘ │ │ │ └──────────────────────────────────────────┬───────┘ │ │ │ │ │ ┌────────────────────────────────────┘ │ │ ↓ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Plan posted as PR comment (tfcmt) │ │ │ │ Reviewer examines changes │ │ │ └──────────────────────────────────────────┬───────┘ │ │ │ │ │ ┌────────────────────────────────────┘ │ │ ↓ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Stage 2: Manual Approval │ │ │ │ (GitHub Environment: production-payments) │ │ │ │ Required reviewers: platform-admins team │ │ │ └──────────────────────────────────────────┬───────┘ │ │ │ │ │ ┌────────────────────────────────────┘ │ │ ↓ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Stage 3: Apply (on merge to main) │ │ │ │ ┌────────┐ ┌─────────────────────────────────┐ │ │ │ │ │ init │→│ apply -auto-approve │ │ │ │ │ └────────┘ └─────────────────────────────────┘ │ │ │ └──────────────────────────────────────────┬───────┘ │ │ │ │ │ ┌────────────────────────────────────┘ │ │ ↓ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Notify: Slack #payments-infra-changes │ │ │ └──────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Terraform providers are separate binary processes that communicate with the Terraform core via a gRPC-based plugin protocol. The core sends RPCs like PlanResourceChange and ApplyResourceChange, and the provider translates these into cloud API calls. This architecture allows providers to be developed, versioned, and distributed independently of the core binary.
Detailed Answer
Terraform's provider architecture is built on HashiCorp's go-plugin framework, which uses gRPC for inter-process communication. Think of it like a restaurant: Terraform core is the head chef who designs the menu (configuration) and coordinates the kitchen, while providers are specialized line cooks (AWS, Azure, GCP) who know how to prepare specific dishes (resources). They communicate via tickets (gRPC messages), and each cook can be replaced or upgraded without retraining the head chef.
When you run terraform init, Terraform downloads provider binaries from the configured registry (registry.terraform.io by default) and stores them in the .terraform/providers directory. Each provider is a standalone Go binary compiled for your platform. When Terraform needs to interact with a provider, it launches the binary as a subprocess. The provider binary starts a gRPC server on a random local port and communicates the port back to Terraform core via stdout using a handshake protocol. This is the 'plugin negotiation' phase.
The gRPC protocol defines several key RPCs. GetProviderSchema returns the provider's complete schema: all resource types, data sources, and their attributes with types and validation rules. This is how Terraform knows what arguments aws_rds_cluster accepts without hardcoding AWS-specific knowledge. ValidateResourceConfig checks that user-provided configuration is structurally valid before any API calls. PlanResourceChange computes the proposed new state for a resource given the current state and desired configuration. ApplyResourceChange executes the actual infrastructure change by calling cloud APIs. ReadResource fetches the current state of a resource for drift detection.
The protocol uses Protocol Buffers for serialization, with a schema called tfplugin6.proto (for protocol version 6, used by modern providers). Resource attribute values are encoded as MessagePack within the protobuf messages, using a type system called cty (a Go library for dynamic typing). This double encoding exists because protobuf alone cannot express Terraform's complex type system (nested blocks, sets of objects, sensitive values).
Provider development uses the Terraform Plugin Framework (the modern approach) or the older Terraform Plugin SDK v2. The framework provides abstractions for implementing the gRPC service methods. When you write a provider resource, you implement methods like Create, Read, Update, Delete, and the framework translates these into the appropriate gRPC responses.
Production implications of this architecture include: provider version pinning is critical because different provider versions expose different gRPC schemas, and a schema mismatch can corrupt state. The subprocess model means provider crashes do not crash Terraform core, but they can leave resources in an inconsistent state. Provider parallelism is controlled by the -parallelism flag, which limits how many concurrent RPC calls Terraform makes. Network-isolated environments need a provider mirror or filesystem mirror because init downloads from the internet by default. Understanding this architecture is essential for debugging provider-specific errors: when Terraform reports 'provider produced inconsistent result,' it means the provider's ApplyResourceChange returned state that does not match its PlanResourceChange prediction.
Code Example
# Provider version constraints and configuration internals
terraform {
# Required providers block declares provider dependencies
required_providers {
# AWS provider for payments infrastructure management
aws = {
# Official HashiCorp AWS provider source address
source = "hashicorp/aws"
# Pessimistic version constraint: allow patch updates only
version = "~> 5.31.0"
}
# PostgreSQL provider for database schema management
postgresql = {
# Community-maintained PostgreSQL provider
source = "cyrilgdn/postgresql"
# Exact version pin for schema compatibility
version = "= 1.21.0"
}
}
# Minimum Terraform version for protocol 6 provider support
required_version = ">= 1.5.0"
}
# Provider mirror configuration for air-gapped environments
# File: ~/.terraformrc (CLI configuration)
# provider_installation {
# filesystem_mirror {
# # Local mirror path for air-gapped production network
# path = "/opt/terraform/providers-mirror"
# # Include all providers from the HashiCorp namespace
# include = ["hashicorp/*", "cyrilgdn/*"]
# }
# # Fallback to direct registry for development environments
# direct {
# # Exclude providers that must come from the mirror
# exclude = ["hashicorp/*"]
# }
# }
# AWS provider configuration with retry and endpoint overrides
provider "aws" {
# Primary region for the payments platform infrastructure
region = "us-east-1"
# Retry configuration for transient API failures
retry_mode = "adaptive"
# Maximum number of API retry attempts before failing
max_retries = 10
# Assume role for cross-account access to production
assume_role {
# Production account Terraform execution role
role_arn = "arn:aws:iam::111111111111:role/TerraformExecutionRole"
# Session name for CloudTrail attribution
session_name = "terraform-payments-pipeline"
# External ID for confused deputy protection
external_id = "fintech-corp-tf-2024"
}
# Custom endpoint overrides for VPC endpoints or LocalStack
endpoints {
# Route S3 API calls through the VPC endpoint
s3 = "https://s3.us-east-1.vpce.amazonaws.com"
# Route STS calls through the VPC endpoint
sts = "https://sts.us-east-1.vpce.amazonaws.com"
}
}
# Debug provider communication with TF_LOG=TRACE
# Export TF_LOG=TRACE to see gRPC messages between core and providers
# Export TF_LOG_PROVIDER=TRACE for provider-only gRPC debuggingInterview Tip
A junior engineer typically says 'providers are plugins that talk to cloud APIs' without understanding the inter-process communication model. To demonstrate deep knowledge, explain that providers are separate binaries launched as subprocesses, communicating via gRPC using the tfplugin6 protocol. Describe the handshake process where the provider binary starts a gRPC server and communicates the port back to Terraform core. Mention key RPCs like PlanResourceChange and ApplyResourceChange, and explain why attribute values are double-encoded as MessagePack inside Protocol Buffers. Discuss production implications like provider mirroring for air-gapped environments and how to debug provider issues using TF_LOG_PROVIDER=TRACE.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐ │ Terraform Provider gRPC Plugin Architecture │ ├───────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────────────┐ │ │ │ Terraform Core │ │ │ │ (terraform binary) │ │ │ │ │ │ │ │ ┌────────────────────┐ │ │ │ │ │ Configuration │ │ │ │ │ │ Parser (HCL) │ │ │ │ │ └────────┬───────────┘ │ │ │ │ │ │ │ │ │ ┌────────┴───────────┐ │ │ │ │ │ Graph Builder │ │ │ │ │ │ (DAG of resources) │ │ │ │ │ └────────┬───────────┘ │ │ │ │ │ │ │ │ │ ┌────────┴───────────┐ │ │ │ │ │ gRPC Client │ │ │ │ │ └────────┬───────────┘ │ │ │ └───────────┼──────────────┘ │ │ │ gRPC (Protocol Buffers + MessagePack) │ │ │ │ │ ┌────────┴────────┬────────────────────┐ │ │ ↓ ↓ ↓ │ │ ┌──────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ AWS │ │ PostgreSQL │ │ Kubernetes │ │ │ │ Provider │ │ Provider │ │ Provider │ │ │ │ (binary) │ │ (binary) │ │ (binary) │ │ │ │ │ │ │ │ │ │ │ │ gRPC Srvr │ │ gRPC Server │ │ gRPC Server │ │ │ │ port:rand │ │ port:rand │ │ port:rand │ │ │ └─────┬─────┘ └──────┬───────┘ └─────────┬────────┘ │ │ │ │ │ │ │ ↓ ↓ ↓ │ │ ┌──────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ AWS APIs │ │ PostgreSQL │ │ Kubernetes API │ │ │ │ (EC2,RDS │ │ Server │ │ Server │ │ │ │ S3,IAM) │ │ │ │ │ │ │ └──────────┘ └──────────────┘ └──────────────────┘ │ │ │ │ Key gRPC RPCs: │ │ ┌────────────────────────────────────────────────────┐ │ │ │ GetProviderSchema → returns resource schemas │ │ │ │ ValidateResourceConfig → validates HCL input │ │ │ │ PlanResourceChange → computes proposed new state │ │ │ │ ApplyResourceChange → executes cloud API calls │ │ │ │ ReadResource → fetches current state (drift)│ │ │ └────────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Test Terraform code at three levels: static analysis with terraform validate and tflint, integration testing with the native terraform test framework (HCL-based tests that run plan or apply against real infrastructure), and end-to-end testing with Terratest (Go-based framework that provisions infrastructure, validates it, and tears it down). Use plan assertions to verify expected changes without applying.
Detailed Answer
Testing Terraform code requires a layered approach because infrastructure code has fundamentally different risk profiles than application code. Think of it like testing a building design: you have blueprint reviews (static analysis), structural simulations (plan-based testing), scale model tests (integration tests in isolated accounts), and the final building inspection (end-to-end tests against real infrastructure).
The first layer is static analysis, which catches errors without any provider interaction. terraform validate checks HCL syntax and basic type errors. terraform fmt -check enforces consistent formatting. tflint goes further with provider-aware linting: it can detect invalid instance types (aws_instance with instance_type = 'm5.huuge'), deprecated resource arguments, and naming convention violations. Custom tflint rules can enforce organizational standards. Checkov and tfsec scan for security misconfigurations: public S3 buckets, unencrypted databases, overly permissive security groups. These tools run in seconds and belong in pre-commit hooks and CI pipeline early stages.
The second layer is plan-based testing, which runs terraform plan and asserts on the planned changes without actually modifying infrastructure. The native terraform test framework (introduced in Terraform 1.6) allows writing tests in HCL files with .tftest.hcl extension. Each test file defines variables, runs plan or apply commands, and asserts on outputs or resource attributes. Plan-mode tests are fast and safe: they verify that your configuration produces the expected plan without touching real infrastructure. You can assert that specific resources will be created, that computed values match expectations, and that no unexpected destroys are planned.
The third layer is integration testing with terraform test in apply mode or with Terratest. Terraform test in apply mode provisions real infrastructure in an isolated test account, runs assertions against the live resources, and destroys everything afterward. Terratest (a Go library by Gruntwork) provides more flexibility: you can make HTTP requests to deployed endpoints, run SQL queries against provisioned databases, SSH into EC2 instances to verify configuration, and use retry logic for eventually consistent resources.
Terratest's power comes from Go's testing ecosystem. You write Go test functions that call terraform.InitAndApply, then use AWS SDK calls to validate the infrastructure matches expectations. For example, after deploying an RDS cluster, you can use the AWS SDK to verify encryption is enabled, multi-AZ is configured, and the parameter group has the expected settings. The test function calls terraform.Destroy in a deferred cleanup block.
Production gotchas for Terraform testing include: test account isolation is critical because integration tests create real resources with real costs. Use AWS Organizations with a dedicated testing account and service control policies to prevent tests from accessing production. Implement cost controls with AWS Budgets on test accounts. Parallelize tests carefully because Terraform state conflicts can occur if multiple test runs target the same state backend. Use unique naming prefixes or workspaces per test run. Terraform test has limitations with provider mocking (experimental in 1.7) and cannot easily test cross-module interactions. Terratest requires Go proficiency and has slower test execution times due to real infrastructure provisioning. A balanced strategy uses static analysis in pre-commit hooks, plan assertions in CI, and full integration tests nightly or before releases.
Code Example
# Native Terraform test file: tests/payments_vpc.tftest.hcl
# Test that the payments VPC is configured correctly
# Global variables for all test runs in this file
variables {
# Use a test-specific environment name to avoid conflicts
environment = "test"
# Smaller CIDR block for test VPC to conserve IP space
vpc_cidr = "10.250.0.0/24"
# Reduced instance count for cost-effective testing
min_api_instances = 1
}
# Plan-only test: verify VPC configuration without provisioning
run "verify_payments_vpc_plan" {
# Run in plan mode to avoid creating real infrastructure
command = plan
# Assert the VPC resource will be created with correct CIDR
assert {
# Check that the VPC CIDR matches the test input
condition = aws_vpc.payments_network.cidr_block == "10.250.0.0/24"
# Error message displayed when assertion fails
error_message = "VPC CIDR block does not match expected test allocation"
}
# Assert DNS support is enabled for service discovery
assert {
# DNS hostnames required for RDS endpoint resolution
condition = aws_vpc.payments_network.enable_dns_hostnames == true
# Error message for DNS configuration failure
error_message = "DNS hostnames must be enabled for payments VPC"
}
}
# Apply test: provision and validate real infrastructure
run "validate_payments_vpc_live" {
# Apply mode creates real resources in the test account
command = apply
# Assert the VPC was created and has an ID
assert {
# VPC ID should be populated after successful creation
condition = startswith(aws_vpc.payments_network.id, "vpc-")
# Error message if VPC creation failed
error_message = "VPC was not created successfully"
}
# Assert the correct number of subnets were created
assert {
# Expect 3 private subnets across availability zones
condition = length(aws_subnet.payments_private) == 3
# Error message for subnet count mismatch
error_message = "Expected 3 private subnets for multi-AZ deployment"
}
}
# Terratest example (Go): tests/payments_rds_test.go
# func TestPaymentsRDSCluster(t *testing.T) {
# t.Parallel()
# terraformDir := "../infrastructure/payments-database"
# uniqueID := random.UniqueId()
# terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
# TerraformDir: terraformDir,
# Vars: map[string]interface{}{
# "environment": "test",
# "cluster_identifier": fmt.Sprintf("payments-db-test-%s", uniqueID),
# },
# })
# defer terraform.Destroy(t, terraformOptions)
# terraform.InitAndApply(t, terraformOptions)
# clusterEndpoint := terraform.Output(t, terraformOptions, "cluster_endpoint")
# assert.Contains(t, clusterEndpoint, "payments-db-test")
# // Verify encryption via AWS SDK
# cluster := aws.GetRdsCluster(t, "us-east-1", fmt.Sprintf("payments-db-test-%s", uniqueID))
# assert.True(t, aws.IsRdsClusterEncrypted(cluster))
# }Interview Tip
A junior engineer typically mentions only terraform validate or basic linting and may not know about the native terraform test framework or Terratest. To stand out, describe the three-layer testing strategy: static analysis in pre-commit hooks (tflint, checkov), plan-based assertions in CI using terraform test with command = plan, and full integration tests with Terratest or terraform test in apply mode. Explain why you need a dedicated test AWS account with cost controls, how you handle test isolation with unique naming prefixes, and the trade-offs between native terraform test (HCL-based, simpler, limited mocking) and Terratest (Go-based, powerful assertions, requires Go expertise).
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐ │ Terraform Testing Pyramid │ ├───────────────────────────────────────────────────────────────┤ │ │ │ ╱╲ │ │ ╱ ╲ │ │ ╱ ╲ │ │ ╱ E2E ╲ │ │ ╱ Tests ╲ │ │ ╱──────────╲ │ │ ╱ Terratest ╲ │ │ ╱ (Go + AWS ╲ │ │ ╱ SDK calls) ╲ │ │ ╱───────────────────╲ │ │ ╱ Integration ╲ │ │ ╱ terraform test ╲ │ │ ╱ command = apply ╲ │ │ ╱───────────────────────────╲ │ │ ╱ Plan Assertions ╲ │ │ ╱ terraform test ╲ │ │ ╱ command = plan ╲ │ │ ╱────────────────────────────────────╲ │ │ ╱ Static Analysis ╲ │ │ ╱ tflint, checkov, terraform validate ╲ │ │ ╱─────────────────────────────────────────────╲ │ │ │ │ Pipeline Integration: │ │ ┌────────────┐ ┌──────────────┐ ┌───────────────────┐ │ │ │ Pre-commit │→│ CI Pipeline │→│ Nightly/Release │ │ │ │ Hook │ │ │ │ │ │ │ │ │ │ Plan-based │ │ Full integration │ │ │ │ tflint │ │ assertions │ │ tests in isolated │ │ │ │ fmt check │ │ terraform │ │ test account │ │ │ │ validate │ │ test (plan) │ │ Terratest (apply) │ │ │ │ checkov │ │ │ │ │ │ │ │ │ │ ~30 seconds │ │ ~15-30 minutes │ │ │ │ ~5 seconds │ │ │ │ │ │ │ └────────────┘ └──────────────┘ └───────────────────┘ │ └───────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Handle state corruption by first enabling S3 versioning for automatic backups, then recovering using terraform state pull to inspect the corrupted state, restoring from a previous version, or as a last resort using terraform import to rebuild state from existing infrastructure. Always maintain state backups and test recovery procedures regularly.
Detailed Answer
Terraform state corruption is one of the most dangerous operational incidents because the state file is the single source of truth mapping your configuration to real infrastructure. Think of it like a hospital's patient registry: if the registry is corrupted, you do not know which patient is in which room, and any action you take risks harming the wrong patient. State corruption can cause Terraform to destroy resources it thinks do not exist, create duplicates of resources it cannot find, or fail entirely with cryptic deserialization errors.
State corruption occurs in several ways. The most common is a partial write during terraform apply: if the process is killed mid-apply (OOM killer, network interruption, CI timeout), the state file may contain a partially updated resource. Another common cause is manual state file editing with JSON syntax errors. Concurrent writes without proper locking can interleave state updates. Provider bugs can write malformed attribute values. And rarely, S3 eventual consistency (in older S3 behavior) could serve a stale state version.
The first line of defense is prevention. Enable S3 versioning on your state bucket so every state write creates a new version, giving you point-in-time recovery. Enable MFA delete on the bucket to prevent accidental or malicious version deletion. Use DynamoDB state locking to prevent concurrent writes. Run terraform plan before apply to catch state inconsistencies early. Implement CI/CD pipelines that prevent direct state manipulation.
When corruption occurs, follow a structured recovery procedure. First, immediately stop all Terraform operations on the affected workspace. If your CI/CD pipeline has queued runs, cancel them. This prevents compounding the corruption.
Second, assess the damage. Run terraform state pull to download the current state file and inspect it. Check the JSON structure: is it valid JSON? Are the serial number and lineage fields present? Use jq to examine specific resources. Compare the state against your actual infrastructure using AWS CLI or console.
Third, attempt recovery from backup. If S3 versioning is enabled, list previous versions with aws s3api list-object-versions, download a known-good version, and push it back using terraform state push. The state push command validates the state format and updates the serial number. Be careful with the -force flag: it skips lineage checking, which is a safety mechanism that prevents pushing state from the wrong workspace.
Fourth, if no backup is available, perform selective state surgery. Use terraform state rm to remove the corrupted resource entries, then terraform import to re-import the existing infrastructure resources. This is tedious for large configurations but preserves the non-corrupted portions of state. For each imported resource, run terraform plan to verify the import produced a state entry that matches your configuration.
Fifth, as a last resort for total state loss, you can rebuild the entire state from scratch using terraform import for every resource. This is the infrastructure equivalent of a bare-metal restore. Tools like terraformer and former2 can help by scanning your AWS account and generating import commands, but they require careful validation.
After recovery, conduct a post-mortem. Implement additional safeguards: S3 bucket replication to a separate account for disaster recovery, automated state backup jobs that copy state to a different storage system, monitoring on state file size and serial number for anomaly detection, and regular recovery drills where you practice restoring state from backup in a non-production workspace.
Code Example
# State corruption recovery playbook
# Step 1: Pull and inspect the corrupted state
# Download the current state file for local inspection
# terraform state pull > corrupted-state-backup.json
# Step 2: Check S3 versioning for previous good state
# List all versions of the payments state file
# aws s3api list-object-versions \
# --bucket fintech-corp-terraform-state-prod \
# --prefix payments-database/terraform.tfstate \
# --query 'Versions[*].{VersionId:VersionId,Modified:LastModified,Size:Size}'
# Step 3: Download a known-good state version
# aws s3api get-object \
# --bucket fintech-corp-terraform-state-prod \
# --key payments-database/terraform.tfstate \
# --version-id "abc123def456" \
# recovered-state.json
# Step 4: Push the recovered state back
# terraform state push recovered-state.json
# Prevention: S3 bucket with versioning and replication
resource "aws_s3_bucket" "terraform_state" {
# Bucket name following the organization naming convention
bucket = "fintech-corp-terraform-state-prod"
# Prevent accidental deletion of the state bucket
force_destroy = false
# Tags for ownership and cost allocation
tags = {
Team = "platform-engineering"
Purpose = "terraform-state-storage"
Criticality = "critical"
}
}
# Enable versioning for point-in-time state recovery
resource "aws_s3_bucket_versioning" "terraform_state" {
# Reference the state storage bucket
bucket = aws_s3_bucket.terraform_state.id
# Enable versioning to retain all state file versions
versioning_configuration {
# Enabled status ensures every write creates a new version
status = "Enabled"
}
}
# Server-side encryption for state files at rest
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
# Reference the state storage bucket
bucket = aws_s3_bucket.terraform_state.id
# Encryption rule using AWS KMS for audit trail
rule {
# Apply encryption to all new state file versions
apply_server_side_encryption_by_default {
# Use a dedicated KMS key for state encryption
sse_algorithm = "aws:kms"
# KMS key managed by the platform engineering team
kms_master_key_id = aws_kms_key.terraform_state_encryption.arn
}
# Force encryption on all uploaded state files
bucket_key_enabled = true
}
}
# Cross-region replication for disaster recovery
resource "aws_s3_bucket_replication_configuration" "terraform_state_dr" {
# Source bucket for replication
bucket = aws_s3_bucket.terraform_state.id
# IAM role with permissions to replicate objects
role = aws_iam_role.terraform_state_replication.arn
# Replication rule for all state files
rule {
# Unique identifier for the replication rule
id = "state-dr-replication"
# Enable the replication rule
status = "Enabled"
# Replicate to a bucket in a different region
destination {
# DR bucket in us-west-2 for geographic redundancy
bucket = aws_s3_bucket.terraform_state_dr.arn
# Use the DR region's KMS key for encryption
storage_class = "STANDARD_IA"
}
}
}
# Lifecycle policy to manage state version retention
resource "aws_s3_bucket_lifecycle_configuration" "terraform_state" {
# Reference the state storage bucket
bucket = aws_s3_bucket.terraform_state.id
# Rule to manage old state file versions
rule {
# Unique identifier for the lifecycle rule
id = "state-version-retention"
# Enable the lifecycle rule
status = "Enabled"
# Transition old versions to cheaper storage after 30 days
noncurrent_version_transition {
# Move to Glacier after 30 days for cost savings
noncurrent_days = 30
# Glacier storage for long-term state version retention
storage_class = "GLACIER"
}
# Delete very old versions after 365 days
noncurrent_version_expiration {
# Retain versions for one year for compliance audits
noncurrent_days = 365
}
}
}
# Import command for rebuilding state (example)
# terraform import aws_rds_cluster.payments_db payments-db-production
# terraform import aws_vpc.payments_network vpc-0a1b2c3d4e5f67890
# terraform import aws_security_group.payments_api_ingress sg-0a1b2c3d4e5f67890Interview Tip
A junior engineer typically mentions only terraform import as a recovery mechanism and may not have a structured recovery playbook. To demonstrate operational maturity, walk through the full incident response: stop all operations, assess damage with state pull and jq inspection, recover from S3 version history, and validate with terraform plan. Discuss preventive measures: S3 versioning with MFA delete, cross-region replication for geographic redundancy, DynamoDB locking, and lifecycle policies for version retention. Mention that terraform state push requires careful handling of the lineage field to avoid cross-workspace contamination, and that regular recovery drills in non-production environments are essential for team readiness.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐ │ Terraform State Recovery Workflow │ ├───────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ │ │ │ Corruption │ │ │ │ Detected! │ │ │ └──────┬───────┘ │ │ │ │ │ ↓ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Step 1: Stop All Operations │ │ │ │ Cancel CI/CD queued runs │ │ │ │ Notify platform-engineering team │ │ │ └──────────────────────────────────────────┬───────┘ │ │ │ │ │ ┌────────────────────────────────────┘ │ │ ↓ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Step 2: Assess Damage │ │ │ │ terraform state pull > corrupted-backup.json │ │ │ │ jq '.resources | length' corrupted-backup.json │ │ │ │ Compare against AWS CLI inventory │ │ │ └──────────────────────────────────────────┬───────┘ │ │ │ │ │ ┌──────────────┬─────────────────────┘ │ │ ↓ ↓ │ │ ┌────────────┐ ┌───────────────┐ │ │ │ S3 Version │ │ No Backup │ │ │ │ Available? │ │ Available │ │ │ └──────┬─────┘ └───────┬───────┘ │ │ │ │ │ │ ↓ ↓ │ │ ┌────────────┐ ┌───────────────┐ │ │ │ Step 3A: │ │ Step 3B: │ │ │ │ Restore │ │ Rebuild │ │ │ │ from S3 │ │ from scratch │ │ │ │ version │ │ │ │ │ │ │ │ terraform │ │ │ │ aws s3api │ │ state rm │ │ │ │ get-object │ │ (corrupted) │ │ │ │ --version-id│ │ │ │ │ │ │ │ terraform │ │ │ │ terraform │ │ import │ │ │ │ state push │ │ (each resource│ │ │ │ │ │ from cloud) │ │ │ └──────┬─────┘ └───────┬───────┘ │ │ │ │ │ │ └───────┬───────┘ │ │ ↓ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Step 4: Validate Recovery │ │ │ │ terraform plan (expect no changes) │ │ │ │ Verify resource count matches cloud inventory │ │ │ └──────────────────────────────────────────┬───────┘ │ │ │ │ │ ┌────────────────────────────────────┘ │ │ ↓ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Step 5: Post-Mortem │ │ │ │ Document root cause │ │ │ │ Implement additional safeguards │ │ │ │ Schedule recovery drill for next quarter │ │ │ └──────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Import blocks make import intent reviewable in configuration, while ad hoc CLI imports mutate state immediately and can be hard to audit. They still require matching resource blocks and careful address selection.
Detailed Answer
Think of importing infrastructure like adding existing buildings to a city map. You need the right address, the right owner, and the right description; otherwise future construction permits target the wrong building.
Terraform import exists so unmanaged resources can come under infrastructure-as-code control. Modern import blocks make that workflow declarative, reviewable, and repeatable instead of a one-off terminal action.
The workflow is to identify resource identity, declare an import block with the destination address, create or generate a matching resource block, run plan, then apply. Terraform records the remote object in state at the declared address.
In production, risk lives in the address and configuration match. If attributes do not match real infrastructure, the first post-import plan may propose dangerous changes. Engineers check lifecycle rules, tags, provider aliases, and module paths carefully.
The non-obvious gotcha is assuming import means Terraform understands the whole resource relationship. It only records the target object; you still need to model dependencies, ownership, and ignored provider-managed fields correctly.
Code Example
import {
to = module.network.aws_security_group.payments # Imports into the final module address, not a temporary root address.
id = "sg-0abc1234def567890" # Uses the provider-specific security group identity.
}
terraform plan -generate-config-out=generated.tf # Generates starter configuration for review when supported.Interview Tip
A junior engineer typically answers with the command or product feature, but for a senior/architect role, the interviewer is actually looking for how you operate Terraform under pressure. Tie the answer to ownership, rollout safety, observability, rollback, and blast-radius control. Mention what signal proves the change is healthy and what you would never do during an incident without first checking live state.
◈ Architecture Diagram
┌──────────┐
│ Learn │
└────┬─────┘
↓
┌──────────┐
│ Terrafor │
└────┬─────┘
↓
┌──────────┐
│ Operate │
└──────────┘💬 Comments
Quick Answer
Deploy EKS using a layered module structure: a networking module for VPC/subnets, a cluster module for the EKS control plane with OIDC provider, and a node-groups module for managed node groups with launch templates. Each module has its own state file and communicates through remote state data sources or SSM parameters.
Detailed Answer
Deploying EKS with Terraform is like building a three-story building: the foundation is networking (VPC, subnets, NAT gateways), the structural frame is the EKS control plane (API server, etcd, OIDC provider), and the floors are the node groups (compute capacity where workloads actually run). Each layer depends on the one below it, and you should be able to rebuild any floor without demolishing the entire building.
The networking module provisions a dedicated VPC with public subnets for load balancers, private subnets for worker nodes, and optionally isolated subnets for databases. EKS requires specific subnet tags: kubernetes.io/cluster/<cluster-name> = shared on all subnets, kubernetes.io/role/elb = 1 on public subnets for internet-facing ALBs, and kubernetes.io/role/internal-elb = 1 on private subnets for internal services. The module outputs subnet IDs and the VPC ID for consumption by the cluster module. NAT gateways should be deployed per-AZ in production for high availability, meaning three NAT gateways across us-east-1a, us-east-1b, and us-east-1c.
The cluster module creates the EKS control plane using the aws_eks_cluster resource or the terraform-aws-modules/eks/aws community module. Critical configurations include the Kubernetes version (pin to a specific minor version like 1.29), the cluster endpoint access (private-only or public-and-private with CIDR restrictions), envelope encryption for secrets using a dedicated KMS key, and the OIDC provider for IAM Roles for Service Accounts (IRSA). The OIDC provider is frequently missed but essential: it enables pods to assume IAM roles without injecting AWS credentials, which is the only secure way to grant AWS access to workloads.
The node groups module manages EKS managed node groups with launch templates. Production clusters typically need multiple node groups: a system node group (t3.xlarge, 3 nodes, taints for system workloads like CoreDNS and kube-proxy), an application node group (m5.2xlarge, 3-15 nodes with cluster autoscaler), and optionally a GPU node group (g4dn.xlarge for ML inference). Each node group uses a custom launch template to specify the AMI (Amazon EKS-optimized AMI), bootstrap arguments, block device mappings (100Gi gp3 root volume), and user data for kubelet configuration. Instance refresh policies ensure rolling updates when the launch template changes.
In production, state separation between these modules is critical. If your node group Terraform runs into an error, you do not want it to affect the EKS control plane state. Use separate state files: one for networking, one for the cluster, and one per node group pool. Pass data between modules using terraform_remote_state data sources or aws_ssm_parameter lookups. This blast radius isolation means a botched node group change cannot accidentally destroy the control plane.
A common gotcha is the chicken-and-egg problem with EKS add-ons. The aws-auth ConfigMap (which controls IAM-to-Kubernetes RBAC mapping) requires a running cluster, but node groups need the aws-auth ConfigMap to join the cluster. The solution is to use the EKS access entries API (available since EKS platform version eks.8) instead of managing aws-auth directly, or to use the kubernetes provider with the EKS cluster's endpoint and token to manage the ConfigMap in the same apply as the cluster creation.
Code Example
# modules/eks-cluster/main.tf — EKS control plane module
# Create the EKS cluster with private endpoint and envelope encryption
resource "aws_eks_cluster" "payments_cluster" {
# Cluster name following org naming convention
name = "payments-eks-${var.environment}"
# Pin to specific Kubernetes minor version
version = "1.29"
# IAM role for the EKS control plane service
role_arn = aws_iam_role.eks_cluster_role.arn
# VPC configuration for the control plane ENIs
vpc_config {
# Private subnets from the networking module
subnet_ids = var.private_subnet_ids
# Enable private API endpoint for in-VPC access
endpoint_private_access = true
# Restrict public endpoint to CI/CD runner CIDRs only
endpoint_public_access = true
# CIDR blocks allowed to reach the public endpoint
public_access_cidrs = ["10.0.0.0/8", "172.16.0.0/12"]
# Security group for additional control plane access rules
security_group_ids = [aws_security_group.eks_cluster_sg.id]
}
# Envelope encryption for Kubernetes secrets using KMS
encryption_config {
# Encrypt the secrets resource type stored in etcd
resources = ["secrets"]
provider {
# Dedicated KMS key for EKS secrets encryption
key_arn = aws_kms_key.eks_secrets_key.arn
}
}
# Enable all control plane logging for audit and troubleshooting
enabled_cluster_log_types = ["api", "audit", "authenticator", "controllerManager", "scheduler"]
# Tags for cost allocation and ownership tracking
tags = {
Team = "payments-platform"
Environment = var.environment
ManagedBy = "terraform"
}
}
# OIDC provider for IAM Roles for Service Accounts (IRSA)
resource "aws_iam_openid_connect_provider" "eks_oidc" {
# OIDC issuer URL from the EKS cluster
url = aws_eks_cluster.payments_cluster.identity[0].oidc[0].issuer
# Audience for the STS AssumeRoleWithWebIdentity call
client_id_list = ["sts.amazonaws.com"]
# TLS certificate thumbprint for the OIDC provider
thumbprint_list = [data.tls_certificate.eks_oidc.certificates[0].sha1_fingerprint]
}
# modules/eks-nodegroups/main.tf — Managed node groups
resource "aws_eks_node_group" "application_nodes" {
# Reference the payments EKS cluster by name
cluster_name = var.cluster_name
# Node group name identifying workload type
node_group_name = "payments-app-nodes-${var.environment}"
# IAM role for EC2 instances in this node group
node_role_arn = aws_iam_role.node_group_role.arn
# Deploy nodes into private subnets only
subnet_ids = var.private_subnet_ids
# Instance types optimized for payment processing workloads
instance_types = ["m5.2xlarge"]
# Use AL2023 EKS-optimized AMI
ami_type = "AL2023_x86_64_STANDARD"
# Autoscaling configuration for the node group
scaling_config {
# Minimum nodes for baseline capacity
min_size = 3
# Desired nodes for normal transaction volume
desired_size = 6
# Maximum nodes for peak shopping events
max_size = 15
}
# Launch template for custom node configuration
launch_template {
# Reference the custom launch template
id = aws_launch_template.app_nodes.id
# Use the latest version of the launch template
version = aws_launch_template.app_nodes.latest_version
}
# Rolling update strategy to avoid downtime
update_config {
# Update 1 node at a time for safe rolling deploys
max_unavailable = 1
}
}
# Launch template for application node group
resource "aws_launch_template" "app_nodes" {
# Template name matching the node group convention
name_prefix = "payments-app-nodes-${var.environment}"
# 100Gi gp3 root volume for container images and logs
block_device_mappings {
device_name = "/dev/xvda"
ebs {
# 100GB root volume for container runtime storage
volume_size = 100
# gp3 for consistent baseline IOPS without cost of io2
volume_type = "gp3"
# Encrypt node volumes with the account default KMS key
encrypted = true
}
}
# Tag instances for cost tracking and identification
tag_specifications {
resource_type = "instance"
tags = {
Name = "payments-app-node-${var.environment}"
NodeGroup = "application"
Environment = var.environment
}
}
}Interview Tip
A junior engineer typically answers by describing a single Terraform file that creates everything — VPC, cluster, and nodes — in one flat configuration. But for a senior or architect role, the interviewer is actually looking for how you decompose EKS into independently deployable layers with separate state files. Explain why networking, cluster, and node groups should be separate modules with blast radius isolation. Describe the OIDC provider setup for IRSA and why injecting AWS credentials into pods is an anti-pattern. Mention specific instance types and node group strategies (system vs application vs GPU), and discuss the aws-auth ConfigMap chicken-and-egg problem and how EKS access entries solve it.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐ │ EKS Terraform Module Structure │ ├───────────────────────────────────────────────────────────────┤ │ │ │ Layer 1: Networking Module (state: networking.tfstate) │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ payments-vpc (10.0.0.0/16) │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ │ │ Public │ │ Public │ │ Public │ │ │ │ │ │ Subnet │ │ Subnet │ │ Subnet │ │ │ │ │ │ 1a (ALB) │ │ 1b (ALB) │ │ 1c (ALB) │ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ │ │ Private │ │ Private │ │ Private │ │ │ │ │ │ Subnet │ │ Subnet │ │ Subnet │ │ │ │ │ │ 1a (Nodes)│ │ 1b (Nodes)│ │ 1c (Nodes)│ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ NAT GW x3 (one per AZ for HA) │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ outputs: vpc_id, subnet_ids │ │ ↓ │ │ Layer 2: Cluster Module (state: eks-cluster.tfstate) │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ payments-eks-prod │ │ │ │ ┌──────────────┐ ┌────────────┐ ┌─────────────┐ │ │ │ │ │ Control Plane │ │ OIDC │ │ KMS Key │ │ │ │ │ │ K8s 1.29 │ │ Provider │ │ (secrets │ │ │ │ │ │ API + etcd │ │ (for IRSA) │ │ encryption)│ │ │ │ │ └──────────────┘ └────────────┘ └─────────────┘ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ outputs: cluster_name, oidc_arn, endpoint │ │ ↓ │ │ Layer 3: Node Groups (state: eks-nodegroups.tfstate) │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ ┌────────────┐ ┌────────────┐ ┌────────────────┐│ │ │ │ │ System │ │ Application│ │ GPU Nodes ││ │ │ │ │ Nodes │ │ Nodes │ │ (optional) ││ │ │ │ │ t3.xlarge │ │ m5.2xlarge │ │ g4dn.xlarge ││ │ │ │ │ 3 fixed │ │ 3-15 auto │ │ 0-4 auto ││ │ │ │ │ 100Gi gp3 │ │ 100Gi gp3 │ │ 100Gi gp3 ││ │ │ │ └────────────┘ └────────────┘ └────────────────┘│ │ │ └─────────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Use a multi-account strategy with AWS Organizations where each environment (Dev, QA, UAT, Prod) gets its own AWS account under an organizational unit. Terraform manages this through assume-role provider configurations, per-account state files in a centralized S3 bucket with prefixed keys, and shared modules versioned in a private registry.
Detailed Answer
Structuring AWS accounts for multiple environments is like managing a hospital: you would never put the emergency room (production), the training lab (dev), the simulation center (QA), and the dress rehearsal ward (UAT) in the same building with shared keys and power circuits. AWS Organizations provides the building-per-department model, giving each environment complete blast radius isolation at the account boundary.
The multi-account structure typically follows an organizational unit (OU) hierarchy. The root OU contains the management account (billing and Organizations API only — never deploy workloads here). Below it, you create OUs for Security (GuardDuty delegated admin, SecurityHub, CloudTrail aggregation), SharedServices (CI/CD runners, ECR registry, Route53 hosted zones, Terraform state bucket), and Workloads. The Workloads OU contains sub-OUs for NonProd (Dev, QA, UAT accounts) and Prod (production account). Service Control Policies (SCPs) at each OU level enforce guardrails: NonProd accounts cannot provision p4d.24xlarge instances or create public-facing resources, while the Prod OU has SCPs preventing deletion of CloudTrail logs or disabling encryption.
In Terraform, each account is targeted through provider assume_role blocks. The CI/CD pipeline authenticates to the SharedServices account via OIDC, then assumes a TerraformExecutionRole in the target account. This role is provisioned by an account-baseline module that every new account receives. The role has permissions scoped to the services that environment needs — Dev gets broad permissions for experimentation, while Prod has tightly scoped permissions with explicit deny on destructive actions like deleting RDS clusters without snapshots.
State file organization follows the account boundary. A single S3 bucket in the SharedServices account stores all state files, with key prefixes per account: s3://org-terraform-state/111111111111/networking/terraform.tfstate for the Dev account networking stack, s3://org-terraform-state/222222222222/networking/terraform.tfstate for Prod. Each account's TerraformExecutionRole has an S3 policy that restricts access to only its own prefix, preventing a Dev pipeline misconfiguration from reading or writing Prod state.
The single-account approach — using naming conventions and tags to separate environments — is tempting for small teams but creates dangerous failure modes at scale. A single IAM policy mistake can grant Dev workloads access to Prod databases. Security groups in a shared VPC can be referenced across environments. Billing attribution becomes guesswork with cost allocation tags instead of per-account bills. Most critically, AWS service quotas are shared: a runaway Dev autoscaling group can exhaust EC2 limits and prevent Prod from scaling during a traffic spike.
The gotcha with multi-account is cross-account resource sharing. VPC peering or Transit Gateway connects environments for legitimate data flows (QA reading anonymized Prod data, shared ECR images). Terraform must manage both sides of a peering connection: the requester in one account and the accepter in another, each with their own provider alias. This requires careful orchestration — apply the requester first, then the accepter — or use a two-phase apply with data sources that look up the peering connection ID.
Code Example
# organizations.tf — AWS Organizations account structure
# Create the organizational unit hierarchy
resource "aws_organizations_organizational_unit" "workloads" {
# Parent is the organization root
name = "Workloads"
# Attach to the root of the organization
parent_id = aws_organizations_organization.org.roots[0].id
}
resource "aws_organizations_organizational_unit" "nonprod" {
# Sub-OU for non-production environments
name = "NonProd"
# Parent is the Workloads OU
parent_id = aws_organizations_organizational_unit.workloads.id
}
resource "aws_organizations_organizational_unit" "prod" {
# Sub-OU for production environment with stricter SCPs
name = "Prod"
# Parent is the Workloads OU
parent_id = aws_organizations_organizational_unit.workloads.id
}
# Account definitions for each environment
resource "aws_organizations_account" "environments" {
# Create one account per environment using for_each
for_each = {
dev = { email = "[email protected]", ou = aws_organizations_organizational_unit.nonprod.id }
qa = { email = "[email protected]", ou = aws_organizations_organizational_unit.nonprod.id }
uat = { email = "[email protected]", ou = aws_organizations_organizational_unit.nonprod.id }
prod = { email = "[email protected]", ou = aws_organizations_organizational_unit.prod.id }
}
# Account name following organization convention
name = "valuemomentum-${each.key}"
# Unique root email per account (AWS requirement)
email = each.value.email
# Place account in the correct OU
parent_id = each.value.ou
# IAM role created in the new account for cross-account access
role_name = "TerraformExecutionRole"
# Prevent accidental account closure
close_on_deletion = false
}
# Provider configuration for targeting a specific environment
locals {
# Map environment names to account IDs
account_map = {
dev = aws_organizations_account.environments["dev"].id
qa = aws_organizations_account.environments["qa"].id
uat = aws_organizations_account.environments["uat"].id
prod = aws_organizations_account.environments["prod"].id
}
}
# Provider block for the target environment account
provider "aws" {
# Region standardized across all accounts
region = "us-east-1"
# Assume the execution role in the target environment account
assume_role {
# Construct role ARN from the environment variable
role_arn = "arn:aws:iam::${local.account_map[var.environment]}:role/TerraformExecutionRole"
# Session name for CloudTrail traceability
session_name = "terraform-${var.environment}-pipeline"
}
# Default tags applied to every resource in this account
default_tags {
tags = {
Environment = var.environment
ManagedBy = "terraform"
Project = "valuemomentum-platform"
}
}
}
# SCP preventing destructive actions in production
resource "aws_organizations_policy" "prod_guardrails" {
# Policy name identifying its purpose
name = "prod-environment-guardrails"
# Description for audit and compliance documentation
description = "Prevent destructive actions in production accounts"
# SCP policy type for organizational guardrails
type = "SERVICE_CONTROL_POLICY"
# Policy document denying dangerous operations
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "PreventRDSDeletionWithoutSnapshot"
Effect = "Deny"
Action = ["rds:DeleteDBCluster", "rds:DeleteDBInstance"]
Resource = "*"
Condition = {
Bool = { "rds:SkipFinalSnapshot" = "true" }
}
}
]
})
}Interview Tip
A junior engineer typically answers 'we use separate folders or workspaces for each environment in the same AWS account' without recognizing the security and blast radius risks. But for a senior or architect role, the interviewer is actually looking for your understanding of AWS Organizations, OU hierarchy design, and SCP guardrails. Explain why account-level isolation is superior to tag-based separation: separate IAM boundaries, independent service quotas, clean billing, and impossible cross-environment resource references. Discuss the TerraformExecutionRole pattern with least-privilege per environment, state file prefix isolation in S3, and cross-account networking challenges like Transit Gateway versus VPC peering orchestration.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐ │ AWS Organizations Multi-Account Structure │ ├───────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ Root OU │ │ │ │ ┌─────────────────────────────────┐ │ │ │ │ │ Management Account (billing) │ │ │ │ │ └─────────────────────────────────┘ │ │ │ └──────────────┬──────────────────────────┘ │ │ ┌────────┴────────┬──────────────────┐ │ │ ↓ ↓ ↓ │ │ ┌───────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ Security │ │ SharedSvcs │ │ Workloads OU │ │ │ │ OU │ │ OU │ │ │ │ │ │ │ │ │ │ ┌─────────┐ │ │ │ │ GuardDuty │ │ CI/CD runners│ │ │ NonProd │ │ │ │ │ SecHub │ │ ECR registry │ │ │ ┌─────┐│ │ │ │ │ CloudTrail│ │ TF State S3 │ │ │ │ Dev ││ │ │ │ │ │ │ Route53 │ │ │ │ QA ││ │ │ │ └───────────┘ └──────────────┘ │ │ │ UAT ││ │ │ │ │ │ └─────┘│ │ │ │ │ └─────────┘ │ │ │ │ ┌─────────┐ │ │ │ │ │ Prod │ │ │ │ │ │ ┌─────┐│ │ │ │ │ │ │Prod ││ │ │ │ │ │ └─────┘│ │ │ │ │ └─────────┘ │ │ │ └──────────────┘ │ │ │ │ State Isolation: │ │ s3://org-terraform-state/ │ │ ├── 111111111/dev/networking/terraform.tfstate │ │ ├── 222222222/qa/networking/terraform.tfstate │ │ ├── 333333333/uat/networking/terraform.tfstate │ │ └── 444444444/prod/networking/terraform.tfstate │ └───────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Store state in an S3 bucket with versioning enabled, server-side encryption (SSE-KMS), and a DynamoDB table for state locking. Structure the bucket with environment-prefixed keys (prod/networking/terraform.tfstate) and restrict access using IAM policies scoped to each environment's prefix. Prevent corruption through locking, versioning for rollback, and CI/CD-only access patterns.
Detailed Answer
Terraform state file management is like managing a bank vault's ledger: the ledger (state file) records what is in every safe deposit box (cloud resource), and if the ledger is corrupted or leaked, you either lose track of assets or expose their locations to unauthorized parties. The storage, encryption, access control, and corruption prevention for state files must be treated with the same rigor as production database backups.
The S3 backend is the standard for AWS-centric teams. The bucket itself requires several hardening measures: versioning enabled so you can recover previous state versions if an apply corrupts the current state, server-side encryption with a dedicated KMS key (not the default aws/s3 key) so you can audit and rotate encryption independently, public access blocked via the S3 Block Public Access settings, and a bucket policy that explicitly denies unencrypted uploads. The bucket should be in a dedicated SharedServices or Management account, separate from any workload account, so that workload account compromises cannot directly access state.
The key structure within the bucket follows a hierarchy: {account-id-or-env}/{stack-name}/terraform.tfstate. For example: prod/networking/terraform.tfstate, prod/eks-cluster/terraform.tfstate, prod/payments-database/terraform.tfstate. This structure enables per-stack state isolation and per-environment IAM scoping. The Prod account's TerraformExecutionRole gets an S3 policy allowing s3:GetObject and s3:PutObject only on keys prefixed with prod/, while the Dev role can only access dev/. This prevents a Dev pipeline misconfiguration from overwriting Prod state.
DynamoDB state locking prevents concurrent modifications. Create a single DynamoDB table (PAY_PER_REQUEST billing) with a partition key named LockID of type String. Every Terraform operation acquires a lock before modifying state by writing a conditional item to this table. If two engineers run terraform apply simultaneously on the same stack, the second operation receives a ConditionalCheckFailedException and waits. The lock record contains the operator's hostname, the operation type, and a timestamp, which helps diagnose stale locks from crashed CI pipelines.
Corruption prevention goes beyond locking. S3 versioning provides a recovery path: if an apply fails midway and leaves state inconsistent, you can restore a previous version using aws s3api list-object-versions and aws s3api get-object with the desired VersionId. Terraform also writes a backup of the previous state locally before modifying it (terraform.tfstate.backup), though this is less useful in CI/CD where runners are ephemeral. For critical production stacks, enable S3 Replication to copy state to a bucket in another region for disaster recovery.
The most dangerous corruption scenario is partial apply failure: Terraform creates some resources but crashes before writing updated state. The created resources become orphans — they exist in AWS but are not tracked by Terraform. Recovery requires manually importing the orphaned resources using terraform import or, in Terraform 1.5+, using import blocks. To reduce this risk, break large configurations into smaller stacks so each apply touches fewer resources, and use the -target flag only as a last resort since it creates partial state updates by design.
Code Example
# State backend configuration with full security hardening
terraform {
# S3 backend for remote state storage
backend "s3" {
# Dedicated state bucket in the SharedServices account
bucket = "valuemomentum-terraform-state-prod"
# Environment-prefixed key for access control scoping
key = "prod/payments-platform/networking/terraform.tfstate"
# Primary region for state storage
region = "us-east-1"
# DynamoDB table for state locking
dynamodb_table = "terraform-state-locks"
# Enable SSE-KMS encryption with a dedicated key
encrypt = true
# KMS key ARN for state file encryption
kms_key_id = "arn:aws:kms:us-east-1:555555555555:key/mrk-abc123"
# Use the SharedServices account profile for state access
profile = "valuemomentum-shared-services"
}
}
# S3 bucket for Terraform state (provisioned once by bootstrap)
resource "aws_s3_bucket" "terraform_state" {
# Bucket name following organization naming convention
bucket = "valuemomentum-terraform-state-prod"
# Prevent accidental deletion of the state bucket
force_destroy = false
tags = {
Purpose = "terraform-state-storage"
ManagedBy = "bootstrap-terraform"
}
}
# Enable versioning for state file recovery
resource "aws_s3_bucket_versioning" "state_versioning" {
# Reference the state bucket
bucket = aws_s3_bucket.terraform_state.id
# Enable versioning to recover from corruption
versioning_configuration {
status = "Enabled"
}
}
# Server-side encryption with dedicated KMS key
resource "aws_s3_bucket_server_side_encryption_configuration" "state_encryption" {
# Reference the state bucket
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
# Use KMS encryption instead of AES-256
sse_algorithm = "aws:kms"
# Dedicated KMS key for independent rotation and audit
kms_master_key_id = aws_kms_key.terraform_state_key.arn
}
# Enforce encryption on all objects including uploads
bucket_key_enabled = true
}
}
# Block all public access to the state bucket
resource "aws_s3_bucket_public_access_block" "state_public_block" {
# Reference the state bucket
bucket = aws_s3_bucket.terraform_state.id
# Block all forms of public access
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# DynamoDB table for state locking
resource "aws_dynamodb_table" "terraform_locks" {
# Table name matching backend configuration
name = "terraform-state-locks"
# Pay-per-request to avoid capacity planning
billing_mode = "PAY_PER_REQUEST"
# LockID is the required partition key for Terraform
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
# Enable point-in-time recovery for lock table safety
point_in_time_recovery {
enabled = true
}
}
# IAM policy scoping Prod role to only prod/ state prefix
# data "aws_iam_policy_document" "prod_state_access" {
# statement {
# effect = "Allow"
# actions = ["s3:GetObject", "s3:PutObject"]
# resources = ["arn:aws:s3:::valuemomentum-terraform-state-prod/prod/*"]
# }
# statement {
# effect = "Deny"
# actions = ["s3:*"]
# resources = ["arn:aws:s3:::valuemomentum-terraform-state-prod/dev/*"]
# }
# }Interview Tip
A junior engineer typically answers 'we store state in S3 with DynamoDB locking' and stops there. But for a senior or architect role, the interviewer is actually looking for the complete security posture: SSE-KMS with a dedicated key (not the default aws/s3 key), S3 versioning for corruption recovery, IAM policies scoped to environment-specific key prefixes, and the state bucket living in a separate SharedServices account. Explain the partial apply failure scenario where resources become orphans and how you recover using terraform import. Mention S3 cross-region replication for disaster recovery and why you should never use -target in normal operations because it creates partial state divergence.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐ │ Terraform State Security Architecture │ ├───────────────────────────────────────────────────────────────┤ │ │ │ SharedServices Account │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ S3: valuemomentum-terraform-state-prod │ │ │ │ ┌──────────────────────────────────────────────┐ │ │ │ │ │ Versioning: Enabled │ │ │ │ │ │ Encryption: SSE-KMS (dedicated key) │ │ │ │ │ │ Public Access: Blocked │ │ │ │ │ │ Replication: us-east-1 → us-west-2 (DR) │ │ │ │ │ └──────────────────────────────────────────────┘ │ │ │ │ │ │ │ │ Key Structure: │ │ │ │ ├── dev/ │ │ │ │ │ ├── networking/terraform.tfstate │ │ │ │ │ ├── eks-cluster/terraform.tfstate │ │ │ │ │ └── payments-db/terraform.tfstate │ │ │ │ ├── qa/ │ │ │ │ │ └── ... │ │ │ │ ├── uat/ │ │ │ │ │ └── ... │ │ │ │ └── prod/ ← Prod role can ONLY access this │ │ │ │ ├── networking/terraform.tfstate │ │ │ │ ├── eks-cluster/terraform.tfstate │ │ │ │ └── payments-db/terraform.tfstate │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ DynamoDB: terraform-state-locks │ │ │ │ ┌──────────────────────────────────────────────┐ │ │ │ │ │ LockID (PK) │ Info │ Who │ Operation │ │ │ │ │ │ prod/net/... │ ... │ ci │ apply │ │ │ │ │ └──────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ Access Pattern: │ │ CI/CD Runner → OIDC → AssumeRole → Scoped S3 Access │ │ ┌──────────┐ ┌────────────┐ ┌──────────────┐ │ │ │ Pipeline │───→│ Prod Role │───→│ prod/* only │ │ │ │ (OIDC) │ │ (IAM) │ │ (S3 policy) │ │ │ └──────────┘ └────────────┘ └──────────────┘ │ └───────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Prevent cross-environment contamination through four layers: separate state files per environment with IAM-scoped access, provider configurations locked to specific AWS accounts via assume_role, module versioning with pinned tags so an untested module change cannot propagate, and CI/CD pipeline guardrails that validate the target environment before apply.
Detailed Answer
Preventing cross-environment contamination in Terraform is like building firewalls between apartments in a building: you need physical separation (state isolation), locked doors (IAM boundaries), independent utilities (provider configurations), and a building code (CI/CD guardrails) that prevents shortcuts through shared walls.
The first layer is state file isolation. Each environment must have its own state file with its own backend configuration. Never share a state file between environments, even with workspaces, if the blast radius of corruption is unacceptable. The state file contains sensitive data including resource IDs, IP addresses, and sometimes plaintext outputs. An S3 bucket policy should restrict each environment's Terraform role to only its own key prefix: the prod role can access s3://state-bucket/prod/* but is explicitly denied s3://state-bucket/dev/*. This prevents a misconfigured prod pipeline from reading or overwriting dev state.
The second layer is provider-level isolation. Each environment's provider block must assume a role in its specific AWS account. Even if someone accidentally passes the wrong tfvars file, the provider configuration ensures Terraform operates in the correct account. Add a validation check using the aws_caller_identity data source: compare the actual account ID against the expected one and fail early if they do not match. This catches the scenario where an engineer runs terraform apply with prod credentials but dev configuration, or vice versa.
The third layer is module versioning. When environments share modules from a private registry or Git repository, use pinned version tags. Dev might use module version 2.3.0-rc1 while Prod uses 2.2.0 (the last stable release). Without version pinning, a module change pushed to the main branch immediately affects every environment that references source = "git::...?ref=main". This is the most common cause of accidental cross-environment impact: someone fixes a bug in a shared VPC module, the fix has a typo, and every environment that references the module head picks up the broken code on next apply.
The fourth layer is CI/CD pipeline guardrails. The pipeline should validate environment consistency before plan: check that the workspace name matches the tfvars file, verify the AWS account ID matches the target environment, and confirm the Git branch is allowed to deploy to that environment (only main can deploy to prod). Implement a pre-plan script that runs aws sts get-caller-identity and compares the account against an expected value from the pipeline configuration.
Remote state data sources are a particularly dangerous vector for cross-environment bleed. When a production EKS module reads the networking module's state via terraform_remote_state, it must reference the production networking state, not dev. Parameterize the remote state data source's backend configuration using the environment variable: data.terraform_remote_state.networking.config.key should resolve to prod/networking/terraform.tfstate, not a hardcoded path. A common gotcha is using terraform_remote_state with a hardcoded key that works in dev but points to prod state when someone copies the configuration without updating the key.
The ultimate safeguard is defense in depth: even if one layer fails, the others prevent damage. If the IAM policy has a bug that allows dev access to prod state, the provider's assume_role still locks operations to the dev account. If the provider configuration is wrong, the account ID validation check fails before any resources are touched.
Code Example
# Account identity validation — fail fast on wrong account
# Fetch the actual AWS account identity
data "aws_caller_identity" "current" {}
# Validate the account ID matches the expected environment
locals {
# Map of expected account IDs per environment
expected_accounts = {
dev = "111111111111"
qa = "222222222222"
uat = "333333333333"
prod = "444444444444"
}
# Check if current account matches the target environment
account_validated = (
data.aws_caller_identity.current.account_id ==
local.expected_accounts[var.environment]
)
}
# Validation resource that fails plan if accounts mismatch
resource "null_resource" "account_validation" {
# This count trick fails if account does not match
count = local.account_validated ? 0 : "ERROR: Running in wrong AWS account"
}
# Remote state data source — parameterized per environment
data "terraform_remote_state" "networking" {
# S3 backend for reading the networking layer state
backend = "s3"
config = {
# Same state bucket as all other stacks
bucket = "valuemomentum-terraform-state-prod"
# Key parameterized by environment to prevent cross-env reads
key = "${var.environment}/networking/terraform.tfstate"
# Same region as the backend
region = "us-east-1"
}
}
# Use networking outputs safely scoped to the correct environment
resource "aws_eks_cluster" "payments_cluster" {
# Cluster name scoped to the environment
name = "payments-eks-${var.environment}"
version = "1.29"
role_arn = aws_iam_role.eks_cluster_role.arn
vpc_config {
# Subnet IDs from the SAME environment's networking state
subnet_ids = data.terraform_remote_state.networking.outputs.private_subnet_ids
endpoint_private_access = true
endpoint_public_access = var.environment == "prod" ? false : true
}
}
# Module versioning — pinned per environment
module "payments_vpc" {
# Pinned Git tag prevents untested changes from propagating
source = "git::https://github.com/valuemomentum/tf-modules.git//vpc?ref=v2.2.0"
# In dev, you might test a release candidate:
# source = "git::https://github.com/valuemomentum/tf-modules.git//vpc?ref=v2.3.0-rc1"
vpc_name = "payments-vpc-${var.environment}"
vpc_cidr = var.vpc_cidr
environment = var.environment
}
# CI/CD pre-plan validation script (run before terraform plan)
# #!/bin/bash
# EXPECTED_ACCOUNT=$(jq -r ".${ENVIRONMENT}" accounts.json)
# ACTUAL_ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
# if [ "$EXPECTED_ACCOUNT" != "$ACTUAL_ACCOUNT" ]; then
# echo "FATAL: Expected account $EXPECTED_ACCOUNT but authenticated to $ACTUAL_ACCOUNT"
# exit 1
# fiInterview Tip
A junior engineer typically answers 'we use separate state files' without explaining the full defense-in-depth strategy. But for a senior or architect role, the interviewer is actually looking for multiple layers of protection: IAM-scoped state access, provider-level account locking via assume_role, account identity validation using aws_caller_identity, parameterized remote state data sources, and pinned module versions. Walk through a real failure scenario: an engineer runs terraform apply with prod.tfvars but dev credentials — explain which layer catches the error. Mention that remote state data sources are particularly dangerous because a hardcoded key can silently read the wrong environment's state, and show how parameterizing the key with var.environment prevents this.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐
│ Cross-Environment Protection Layers │
├───────────────────────────────────────────────────────────────┤
│ │
│ Layer 1: State Isolation (IAM-Scoped) │
│ ┌──────────────┐ DENY ┌──────────────┐ │
│ │ Dev Role │─────X─────│ prod/* │ │
│ │ (IAM) │ │ state keys │ │
│ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ALLOW ┌──────────────┐ │
│ │ Dev Role │───────────│ dev/* │ │
│ │ (IAM) │ │ state keys │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ Layer 2: Provider Account Lock │
│ ┌──────────────────────────────────────────┐ │
│ │ provider "aws" { │ │
│ │ assume_role { │ │
│ │ role_arn = ".../${var.env}/Role" │ │
│ │ } │ │
│ │ } │ │
│ │ → Operations locked to target account │ │
│ └──────────────────────────────────────────┘ │
│ │
│ Layer 3: Account ID Validation │
│ ┌──────────────────────────────────────────┐ │
│ │ aws_caller_identity.account_id │ │
│ │ == expected_accounts[var.environment] │ │
│ │ → FAIL FAST if wrong account │ │
│ └──────────────────────────────────────────┘ │
│ │
│ Layer 4: Module Version Pinning │
│ ┌──────────────────────────────────────────┐ │
│ │ Dev: source = "...?ref=v2.3.0-rc1" │ │
│ │ Prod: source = "...?ref=v2.2.0" │ │
│ │ → Untested changes cannot reach prod │ │
│ └──────────────────────────────────────────┘ │
│ │
│ Layer 5: CI/CD Pipeline Guardrails │
│ ┌──────────────────────────────────────────┐ │
│ │ Branch → Environment mapping │ │
│ │ main → prod (requires approval) │ │
│ │ develop → dev (auto-apply) │ │
│ │ Pre-plan: sts get-caller-identity check │ │
│ └──────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘💬 Comments
Quick Answer
Implement a multi-stage pipeline: PR triggers terraform plan with output posted as a PR comment, OPA/Sentinel policy checks validate compliance, manual approval gates (GitHub Environments with required reviewers) protect production, and merge-to-main triggers terraform apply using the saved plan file. Use OIDC for keyless authentication and concurrency controls to prevent parallel applies on the same stack.
Detailed Answer
A Terraform CI/CD pipeline is like an air traffic control system: every infrastructure change (flight) must file a plan (flight plan), get reviewed by controllers (PR reviewers), receive clearance (approval gate), and land on the correct runway (target environment) — all while preventing two planes from using the same runway simultaneously (state locking and concurrency control).
The pipeline begins with authentication. Modern pipelines use OIDC federation instead of stored AWS credentials. GitHub Actions requests a JWT token from GitHub's OIDC provider, presents it to AWS STS via AssumeRoleWithWebIdentity, and receives short-lived credentials scoped to the Terraform execution role. The OIDC trust policy restricts which repositories, branches, and environments can assume the role: production apply roles should only be assumable by the main branch, while plan roles can be assumed by any branch. This eliminates long-lived access keys that could be exfiltrated from CI secrets.
The plan stage runs on every pull request. It executes terraform init, terraform validate, terraform fmt -check, and terraform plan -out=plan.tfplan. The plan output is captured and posted as a PR comment using tools like tfcmt or the native GitHub Actions Terraform setup action. Reviewers see exactly what resources will be created, modified, or destroyed — including sensitive changes like security group rule modifications or IAM policy updates. The saved plan file is uploaded as a CI artifact for use in the apply stage.
Policy-as-code gates run between plan and approval. Open Policy Agent (OPA) evaluates the plan JSON (terraform show -json plan.tfplan) against organizational policies: no S3 buckets without encryption, no security groups with 0.0.0.0/0 ingress on port 22, all RDS instances must have deletion protection in production. These checks are non-negotiable — a policy violation fails the pipeline regardless of who approves the PR. Sentinel serves the same purpose in Terraform Cloud/Enterprise environments.
The approval gate differs by environment. Dev and QA may auto-apply on merge — the PR review itself is sufficient approval. UAT requires team lead approval via a GitHub Environment with one required reviewer. Production requires two approvals from the platform-admins team, with a 15-minute wait timer to prevent hasty approvals. These are configured as GitHub Environments with protection rules, which the apply job references via the environment keyword.
The apply stage triggers after merge to main. Critically, it should use the saved plan file from the plan stage rather than re-running plan, because infrastructure may have changed between plan review and apply execution. If the saved plan is stale (state serial mismatch), Terraform rejects it and the pipeline must re-plan. After successful apply, the pipeline posts results to a Slack channel (#infra-changes-prod) and creates a GitHub deployment record for audit trail.
Concurrency control prevents two merged PRs from applying simultaneously to the same stack. GitHub Actions concurrency groups scoped to the stack name (concurrency: group: terraform-payments-prod) ensure only one apply runs at a time. Queued runs wait for the current apply to complete. Combined with DynamoDB state locking, this provides two layers of concurrent modification prevention.
Code Example
# .github/workflows/terraform-payments.yml
# Multi-stage Terraform pipeline with OIDC and approval gates
name: Payments Infrastructure Pipeline
# Trigger on PRs and pushes to main affecting payments infra
on:
pull_request:
paths: ['infrastructure/envs/prod/**', 'infrastructure/modules/**']
push:
branches: [main]
paths: ['infrastructure/envs/prod/**', 'infrastructure/modules/**']
# OIDC permissions for keyless AWS authentication
permissions:
id-token: write
contents: read
pull-requests: write
# Prevent concurrent applies on the same stack
concurrency:
group: terraform-payments-prod-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Stage 1: Validate and plan on every PR
plan:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
# Checkout the infrastructure code
- uses: actions/checkout@v4
# OIDC authentication — plan role (read-only)
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::444444444444:role/GitHubActions-TerraformPlan
aws-region: us-east-1
# Install pinned Terraform version
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.4
# Initialize the backend and download providers
- name: Init
run: terraform -chdir=infrastructure/envs/prod init -input=false
# Validate syntax and configuration
- name: Validate
run: terraform -chdir=infrastructure/envs/prod validate
# Format check to enforce style standards
- name: Format Check
run: terraform fmt -check -recursive infrastructure/
# Generate execution plan and save to file
- name: Plan
run: terraform -chdir=infrastructure/envs/prod plan -input=false -out=prod.tfplan
# Export plan as JSON for OPA policy evaluation
- name: Export Plan JSON
run: terraform -chdir=infrastructure/envs/prod show -json prod.tfplan > plan.json
# Run OPA policy checks against the plan
- name: OPA Policy Check
run: |
opa eval --data policies/ --input plan.json "data.terraform.deny[msg]" --fail-defined
# Post plan output as a PR comment for reviewers
- name: Comment Plan on PR
uses: borchero/terraform-plan-comment@v2
with:
working-directory: infrastructure/envs/prod
# Upload plan artifact for the apply stage
- uses: actions/upload-artifact@v4
with:
name: prod-tfplan
path: infrastructure/envs/prod/prod.tfplan
retention-days: 5
# Stage 2: Apply after merge with manual approval
apply:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# Production environment with required approvers and wait timer
environment:
name: production-payments
url: https://console.aws.amazon.com/eks
steps:
- uses: actions/checkout@v4
# OIDC authentication — apply role (read-write)
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::444444444444:role/GitHubActions-TerraformApply
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.4
# Re-init and apply (saved plan may be stale after merge)
- name: Init and Apply
run: |
terraform -chdir=infrastructure/envs/prod init -input=false
terraform -chdir=infrastructure/envs/prod apply -input=false -auto-approve
# Notify team of successful deployment
- name: Slack Notification
if: success()
uses: slackapi/slack-github-action@v1
with:
payload: '{"text": "Prod payments infra deployed by ${{ github.actor }}"}'Interview Tip
A junior engineer typically describes a simple pipeline that runs terraform init, plan, and apply in sequence without addressing authentication security, policy gates, or concurrency control. But for a senior or architect role, the interviewer is actually looking for a complete production pipeline architecture. Explain OIDC federation and why stored credentials are an anti-pattern. Describe the split between plan roles (read-only, any branch) and apply roles (read-write, main branch only). Walk through OPA policy checks that run on the plan JSON output to catch compliance violations before human review. Discuss concurrency groups in GitHub Actions combined with DynamoDB state locking as two layers of protection against parallel applies. Mention the stale plan problem — when the saved plan's state serial does not match current state — and how the pipeline handles re-planning.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐ │ Terraform CI/CD Pipeline with Approval Gates │ ├───────────────────────────────────────────────────────────────┤ │ │ │ PR Opened │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Stage 1: Plan (on every PR) │ │ │ │ │ │ │ │ OIDC → AssumeRole (Plan Role, read-only) │ │ │ │ ┌──────┐ ┌────────┐ ┌────┐ ┌──────┐ │ │ │ │ │ init │→│validate│→│fmt │→│ plan │ │ │ │ │ └──────┘ └────────┘ └────┘ └──┬───┘ │ │ │ │ │ │ │ │ │ ┌──────┴──────┐ │ │ │ │ │ plan.json │ │ │ │ │ └──────┬──────┘ │ │ │ │ ↓ │ │ │ │ ┌────────────────────┐ │ │ │ │ │ OPA Policy Check │ │ │ │ │ │ - no public S3 │ │ │ │ │ │ - encryption on │ │ │ │ │ │ - tags required │ │ │ │ │ └────────┬───────────┘ │ │ │ │ ↓ │ │ │ │ ┌────────────────────┐ │ │ │ │ │ PR Comment with │ │ │ │ │ │ plan output │ │ │ │ │ └────────────────────┘ │ │ │ └──────────────────────────────────────────────────┘ │ │ │ │ PR Approved + Merged to main │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Stage 2: Manual Approval Gate │ │ │ │ GitHub Environment: production-payments │ │ │ │ Required reviewers: 2 from platform-admins │ │ │ │ Wait timer: 15 minutes │ │ │ └──────────────────────────────────────────────────┘ │ │ ↓ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Stage 3: Apply (after approval) │ │ │ │ │ │ │ │ OIDC → AssumeRole (Apply Role, read-write) │ │ │ │ ┌──────┐ ┌────────────────┐ │ │ │ │ │ init │→│ apply │ │ │ │ │ └──────┘ │ -auto-approve │ │ │ │ │ └───────┬────────┘ │ │ │ │ ↓ │ │ │ │ ┌──────────────┐ │ │ │ │ │ Slack notify │ │ │ │ │ │ #infra-changes│ │ │ │ │ └──────────────┘ │ │ │ └──────────────────────────────────────────────────┘ │ │ │ │ Concurrency: group=terraform-payments-prod (1 at a time) │ └───────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Detect drift by running terraform plan -refresh-only to compare actual infrastructure against state without proposing changes. Remediate by either importing the manual change into Terraform (terraform import or import blocks), reverting the manual change by running terraform apply to converge back to the declared configuration, or updating the Terraform code to reflect the intentional change and then applying.
Detailed Answer
Terraform state drift is like someone rearranging furniture in a room that has a blueprint: the blueprint (state file) says the couch is by the window, but someone physically moved it to the center of the room (console change). Terraform detects this discrepancy during the refresh phase of plan and proposes moving the couch back to the window (converging to declared state). The question is whether the move was intentional or accidental — and that determines whether you update the blueprint or move the couch back.
Drift detection happens during the refresh phase of terraform plan. For every resource tracked in state, Terraform calls the cloud provider's API to read the current configuration. If the API response differs from what state records, Terraform updates its in-memory state and then diffs that against your HCL configuration. The -refresh-only flag runs only the refresh phase without proposing configuration-driven changes, making it a pure drift detection scan. The output shows which attributes have drifted and their before/after values.
There are three categories of drift, each requiring a different remediation strategy. The first is accidental drift: an engineer manually opened port 443 on a security group to debug a connectivity issue and forgot to revert it. The fix is to run terraform apply, which converges the security group back to the declared configuration, removing the manually added rule. This is Terraform's self-healing property — the declared state is the source of truth.
The second is intentional drift: an operations engineer manually scaled up an RDS instance from db.r6g.xlarge to db.r6g.2xlarge during a traffic incident. The change was correct and should be preserved. The fix is to update the Terraform code to reflect the new instance class, then run terraform plan to verify the plan shows no changes (the code now matches reality). If you run apply without updating the code, Terraform would downgrade the instance back to the original size — potentially causing another outage.
The third is untracked resource creation: someone created a new S3 bucket via the console that Terraform knows nothing about. Since Terraform only tracks resources in its state, it cannot detect untracked resources. Tools like AWS Config, Driftctl (now Snyk IaC), or CloudQuery scan the entire account and compare against Terraform state to find resources that exist but are not managed. Once identified, you either import the resource into Terraform using import blocks (Terraform 1.5+) or the terraform import command, or you delete the resource if it should not exist.
Proactive drift prevention is better than reactive detection. Implement AWS Config rules that alert on configuration changes not made by the Terraform execution role. Set up CloudTrail-based alarms that trigger when console users modify resources tagged with ManagedBy=terraform. Use IAM policies that restrict console users to read-only access for Terraform-managed resource types. Schedule a daily terraform plan -refresh-only in CI that posts drift reports to a Slack channel — this catches drift within 24 hours instead of discovering it during the next deployment.
The lifecycle meta-argument ignore_changes is the escape hatch for expected drift. Auto-scaling groups change desired_capacity based on scaling policies, ECS services change task_count, and some resources have attributes that are set once and then managed externally. Adding these attributes to ignore_changes tells Terraform to skip them during drift comparison, preventing false positives and accidental reverts of legitimate operational changes.
Code Example
# Drift detection and remediation workflow
# Step 1: Run refresh-only plan to detect drift without proposing changes
# terraform plan -refresh-only -out=drift-check.tfplan
# This shows which resources have drifted from their recorded state
# Step 2: Review the drift report
# terraform show drift-check.tfplan
# Example output:
# ~ aws_security_group_rule.payments_api_ingress
# from_port: 443 → 8080 (someone changed the port manually)
# Step 3a: Revert accidental drift — apply converges back to declared state
# terraform apply
# This restores the security group rule to port 443 as declared in code
# Step 3b: Adopt intentional drift — update code to match reality
resource "aws_rds_cluster" "payments_db" {
# Cluster identifier for the payments transaction database
cluster_identifier = "payments-db-prod"
# Updated instance class to match the manual scaling during incident
# Previously: db.r6g.xlarge — changed during traffic spike on 2026-06-15
engine = "aurora-postgresql"
engine_version = "15.4"
deletion_protection = true
backup_retention_period = 30
}
# Step 3c: Import untracked resources using import blocks (TF 1.5+)
import {
# S3 bucket created manually via console during incident response
to = aws_s3_bucket.payments_audit_logs
# The actual bucket name to import from AWS
id = "valuemomentum-payments-audit-logs-prod"
}
# Resource block to match the imported bucket's configuration
resource "aws_s3_bucket" "payments_audit_logs" {
# Bucket name matching the manually created bucket
bucket = "valuemomentum-payments-audit-logs-prod"
tags = {
Purpose = "audit-log-storage"
Environment = "prod"
ManagedBy = "terraform"
ImportedOn = "2026-06-20"
}
}
# Lifecycle ignore_changes for expected drift patterns
resource "aws_autoscaling_group" "payments_api_fleet" {
# ASG name following the naming convention
name = "payments-api-fleet-prod-use1"
# Baseline desired capacity — autoscaler adjusts this
desired_capacity = 6
# Minimum instances for SLA compliance
min_size = 3
# Maximum instances during peak events
max_size = 24
launch_template {
id = aws_launch_template.payments_api.id
version = "$Latest"
}
lifecycle {
# Ignore desired_capacity — managed by cluster autoscaler
# Ignore target_group_arns — managed by EKS ingress controller
ignore_changes = [desired_capacity, target_group_arns]
}
}
# Scheduled drift detection in CI (runs daily at 6 AM UTC)
# .github/workflows/drift-detection.yml
# name: Daily Drift Detection
# on:
# schedule:
# - cron: '0 6 * * *'
# jobs:
# detect-drift:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - run: terraform -chdir=infrastructure/envs/prod init
# - run: terraform -chdir=infrastructure/envs/prod plan -refresh-only -detailed-exitcode
# # Exit code 2 means drift detected
# - if: failure()
# run: |
# curl -X POST $SLACK_WEBHOOK -d '{"text": "DRIFT DETECTED in prod payments infra"}'Interview Tip
A junior engineer typically answers 'run terraform plan to see the drift and then apply to fix it' without distinguishing between accidental and intentional drift. But for a senior or architect role, the interviewer is actually looking for a complete drift management strategy. Explain the three categories: accidental drift (revert via apply), intentional drift (update code to match reality), and untracked resources (import or delete). Describe the -refresh-only flag for pure drift scanning without proposing code-driven changes. Discuss proactive detection using scheduled CI plans with -detailed-exitcode (exit code 2 means drift) and Slack notifications. Mention ignore_changes as the escape hatch for expected drift like autoscaler-managed capacity, and tools like AWS Config or Driftctl for detecting unmanaged resources that Terraform cannot see.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐ │ Terraform State Drift Detection & Remediation │ ├───────────────────────────────────────────────────────────────┤ │ │ │ ┌───────────────┐ Manual ┌──────────────────┐ │ │ │ Terraform │ Change │ AWS Console │ │ │ │ State File │ │ or CLI │ │ │ │ │ │ │ │ │ │ sg port: 443 │ │ sg port: 8080 │ │ │ │ (declared) │ │ (actual) │ │ │ └───────┬───────┘ └──────────────────┘ │ │ │ │ │ │ └──────────────┬─────────────────────┘ │ │ ↓ │ │ ┌──────────────────────┐ │ │ │ terraform plan │ │ │ │ -refresh-only │ │ │ │ │ │ │ │ DRIFT DETECTED: │ │ │ │ sg port: 443 → 8080 │ │ │ └──────────┬───────────┘ │ │ │ │ │ ┌──────────────┼──────────────┐ │ │ ↓ ↓ ↓ │ │ ┌──────────────┐┌─────────────┐┌──────────────────┐ │ │ │ Accidental ││ Intentional ││ Untracked │ │ │ │ Drift ││ Drift ││ Resource │ │ │ │ ││ ││ │ │ │ │ terraform ││ Update HCL ││ terraform import │ │ │ │ apply ││ to match ││ or delete the │ │ │ │ (revert to ││ reality, ││ resource │ │ │ │ declared) ││ then apply ││ │ │ │ └──────────────┘└─────────────┘└──────────────────┘ │ │ │ │ Proactive Detection: │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Daily CI Job (cron: 0 6 * * *) │ │ │ │ terraform plan -refresh-only -detailed-exitcode │ │ │ │ │ │ │ │ Exit 0 → No drift → all clear │ │ │ │ Exit 2 → Drift detected → Slack alert │ │ │ └──────────────────────────────────────────────────┘ │ │ │ │ Expected Drift (ignore_changes): │ │ ┌──────────────────────────────────────────────────┐ │ │ │ ASG desired_capacity → managed by autoscaler │ │ │ │ ECS task_count → managed by scaling policy │ │ │ │ ignore_changes = [desired_capacity] │ │ │ └──────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Terraform provisions infrastructure (VPCs, EC2 instances, RDS databases, EKS clusters) — the 'what exists.' Ansible configures that infrastructure (installing packages, hardening OS, deploying applications) — the 'how it is set up.' Terraform creates the server; Ansible makes it useful.
Detailed Answer
Think of building a house. Terraform is the general contractor who builds the structure — foundation, walls, roof, plumbing, and electrical. Ansible is the interior designer who makes it livable — painting walls, installing appliances, setting up the home network, and arranging furniture. You would never ask the interior designer to pour concrete, and you would not ask the general contractor to pick curtain colors. Each tool excels in its domain, and trying to force one to do the other's job creates fragile, unmaintainable automation.
Terraform excels at declarative infrastructure provisioning through cloud provider APIs. It manages the lifecycle of resources — creating, updating, and destroying VPCs, subnets, security groups, EC2 instances, RDS databases, EKS clusters, S3 buckets, and IAM roles. Terraform's state file tracks what exists and what needs to change, enabling plan-before-apply workflows that show exactly what will be created, modified, or destroyed. For banking infrastructure, Terraform manages the network topology (hub-and-spoke VPC with transit gateway), the compute layer (EKS clusters, EC2 bastion hosts), the data layer (RDS PostgreSQL for settlements-db, ElastiCache for session management), and the security layer (KMS keys, WAF rules, Security Hub integration). Terraform is idempotent through its state — running apply twice produces no changes if the infrastructure matches the desired state.
Ansible excels at procedural configuration management through SSH or WinRM. It manages what happens inside servers — installing packages, configuring services, managing files, hardening operating systems, and deploying applications to non-containerized targets. For banking, Ansible handles CIS benchmark hardening on EC2 instances, installing and configuring monitoring agents (Datadog, Splunk forwarder), managing SSL certificates on legacy application servers, configuring LDAP authentication, and deploying Java applications to Tomcat servers that have not yet been containerized. Ansible is idempotent through its modules — each module checks the current state and only makes changes if needed.
The integration pattern has two approaches: Terraform-first and dynamic inventory. In the Terraform-first approach, Terraform provisions the infrastructure and outputs the IP addresses, hostnames, and connection details. These outputs feed into Ansible's inventory — either through a static inventory file generated by Terraform's local_file resource, or through Ansible's AWS dynamic inventory plugin that discovers EC2 instances by tags. The workflow is: Terraform apply creates servers with specific tags, then Ansible runs with the aws_ec2 dynamic inventory plugin filtering by those tags, configuring each server according to its role. In a banking pipeline, this might look like: Terraform creates three EC2 instances tagged role=settlements-processor, and Ansible's playbook installs Java 17, deploys the settlements application JAR, configures the JVM parameters, sets up the Splunk forwarder, and applies CIS hardening — all based on the role tag.
In production at a bank, the separation becomes clear when you consider change frequency and blast radius. Terraform changes are infrequent and high-impact — modifying a VPC peering connection or upgrading an RDS instance affects the entire platform. These changes go through Terraform Enterprise with Sentinel policies, approval gates, and change windows. Ansible changes are more frequent and targeted — updating an application configuration, rotating a certificate, or patching a package on 50 servers. These changes run through Ansible Automation Platform (AWX/Tower) with job templates, RBAC, and audit logging. Both tools store their code in Git, but they have separate repositories, separate CI/CD pipelines, and separate approval workflows because their risk profiles are fundamentally different.
The biggest gotcha is overlapping responsibility. If Terraform creates a security group and Ansible also tries to manage the same security group rules, they fight each other — Terraform's next apply will revert Ansible's changes, and Ansible's next run will override Terraform's state. Define clear boundaries: Terraform manages everything at the cloud API level (resources you see in the AWS console), and Ansible manages everything at the OS level (things you configure via SSH). Another common mistake is using Terraform's provisioner blocks (remote-exec, local-exec) to do configuration management — provisioners run only on resource creation, not on subsequent applies, they have poor error handling, and they mix infrastructure provisioning with configuration in ways that are hard to debug and maintain. Use Terraform to create, Ansible to configure, and keep them in separate pipelines.
Code Example
# Terraform provisions the infrastructure
# infra/settlements-servers.tf
resource "aws_instance" "settlements_processor" {
count = 3
ami = data.aws_ami.rhel9.id
instance_type = "m6i.xlarge"
subnet_id = module.vpc.private_subnets[count.index % 3]
key_name = var.ssh_key_name
vpc_security_group_ids = [aws_security_group.settlements.id]
# Tags that Ansible dynamic inventory will discover
tags = {
Name = "settlements-processor-${count.index + 1}"
Role = "settlements-processor"
Environment = "production"
Team = "payments"
Ansible = "true" # Flag for dynamic inventory filter
}
root_block_device {
volume_size = 100
encrypted = true
kms_key_id = aws_kms_key.banking.arn
}
}
# Output for Ansible inventory generation
output "settlements_processor_ips" {
value = aws_instance.settlements_processor[*].private_ip
}
---
# Ansible dynamic inventory configuration
# ansible/inventory/aws_ec2.yml
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
filters:
tag:Ansible: "true"
tag:Environment: production
instance-state-name: running
keyed_groups:
- key: tags.Role
prefix: role
- key: tags.Team
prefix: team
hostnames:
- private-ip-address
compose:
ansible_host: private_ip_address
ansible_user: ec2-user
---
# Ansible playbook for settlements-processor configuration
# ansible/playbooks/settlements-processor.yml
- name: Configure settlements processor servers
hosts: role_settlements_processor
become: true
roles:
- cis_hardening # CIS benchmark Level 2
- splunk_forwarder # Compliance: centralized logging
- monitoring_agent # Datadog agent installation
- java_runtime # Java 17 with banking-approved JVM settings
- settlements_app # Deploy settlements application
# roles/settlements_app/tasks/main.yml
- name: Create application directory
file:
path: /opt/settlements-processor
state: directory
owner: appuser
group: appgroup
mode: '0750'
- name: Deploy settlements processor JAR
copy:
src: "artifacts/settlements-processor-{{ app_version }}.jar"
dest: /opt/settlements-processor/settlements-processor.jar
owner: appuser
mode: '0640'
notify: restart settlements
- name: Configure JVM settings for banking workload
template:
src: jvm.conf.j2
dest: /opt/settlements-processor/jvm.conf
notify: restart settlements
# roles/settlements_app/templates/jvm.conf.j2
# JVM settings for settlements-processor (banking-optimized)
JAVA_OPTS="-Xms4g -Xmx8g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-Djavax.net.ssl.trustStore=/etc/pki/java/cacerts \
-Ddb.endpoint={{ settlements_db_endpoint }} \
-Ddb.pool.size=50 \
-Daudit.log.enabled=true \
-Dcompliance.mode=pci-dss"
---
# Pipeline: Terraform first, then Ansible
# .github/workflows/infra-and-config.yaml
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- name: Terraform Apply
run: terraform apply -auto-approve
working-directory: infra/
ansible:
needs: terraform # Runs AFTER infrastructure exists
runs-on: ubuntu-latest
steps:
- name: Run Ansible playbook
run: |
ansible-playbook \
-i inventory/aws_ec2.yml \
playbooks/settlements-processor.yml \
-e "app_version=${{ github.sha }}"Interview Tip
A junior engineer typically says 'Terraform and Ansible do the same thing' or tries to use one tool for everything. Demonstrate understanding by clearly articulating the boundary: Terraform manages cloud API resources (infrastructure lifecycle), Ansible manages OS-level configuration (what happens inside servers). Use the house-building analogy — contractor vs interior designer. Mention the anti-patterns: Terraform provisioners for configuration management (they only run on creation, not updates), and overlapping resource management that causes state fights. In banking, explain that the tools have different change frequencies and risk profiles, so they deserve separate pipelines, separate approval workflows, and separate repositories.
◈ Architecture Diagram
┌─────────────────────────────────────────────────────────────────┐ │ Terraform + Ansible: Separation of Concerns │ │ │ │ ┌───────────────────────────────────────────────────────────┐ │ │ │ Terraform (Infrastructure Layer - Cloud API) │ │ │ │ │ │ │ │ VPC ─→ Subnets ─→ Security Groups ─→ EC2 Instances │ │ │ │ EKS Clusters ─→ RDS Databases ─→ S3 Buckets │ │ │ │ IAM Roles ─→ KMS Keys ─→ Route 53 Records │ │ │ │ │ │ │ │ Output: Instance IPs, Tags, Endpoints │ │ │ └──────────────────────────┬────────────────────────────────┘ │ │ │ Tags / Dynamic Inventory │ │ ▼ │ │ ┌───────────────────────────────────────────────────────────┐ │ │ │ Ansible (Configuration Layer - OS/SSH) │ │ │ │ │ │ │ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │ │ │ │ │ CIS Harden │ │ Install Java │ │ Deploy App JAR │ │ │ │ │ │ (OS level) │ │ Configure JVM│ │ Configure DB conn │ │ │ │ │ └─────────────┘ └──────────────┘ └───────────────────┘ │ │ │ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │ │ │ │ │ Splunk Fwd │ │ Datadog Agent│ │ SSL Certs │ │ │ │ │ │ (logging) │ │ (monitoring) │ │ (compliance) │ │ │ │ │ └─────────────┘ └──────────────┘ └───────────────────┘ │ │ │ └───────────────────────────────────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────────────────────┐ │ │ │ Pipeline: terraform apply → ansible-playbook │ │ │ │ Boundary: Cloud API resources │ OS-level configuration │ │ │ └───────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Sentinel policies are written as code that evaluates Terraform plans before apply. In banking, they enforce PCI-DSS requirements (encryption at rest, no public access), cost controls (instance size limits), tagging standards, and network segmentation rules — all as hard-mandatory checks that cannot be overridden.
Detailed Answer
Think of Sentinel as a building code inspector who reviews blueprints before construction begins. The architect (engineer) designs the building (infrastructure), submits the blueprints (Terraform plan) to the inspector (Sentinel), and construction (apply) only proceeds if the blueprints comply with all building codes (policies). Unlike a human inspector who might miss details or be inconsistent, Sentinel checks every single blueprint against every single code — automatically, consistently, and without exception.
Sentinel policies in Terraform Enterprise evaluate the Terraform plan, state, and configuration before an apply is permitted. Each policy is a code file written in the Sentinel language that imports Terraform plan data and applies boolean logic to determine pass or fail. Policies are organized into policy sets, and each set is attached to specific workspaces. For a bank, you might have a 'pci-dss-mandatory' policy set attached to all production workspaces, a 'cost-governance' set attached to all workspaces, and a 'network-segmentation' set attached to networking workspaces. Policy sets reference a Git repository, so policies are version-controlled, peer-reviewed, and have their own CI/CD lifecycle.
PCI-DSS compliance policies are the cornerstone for banking. These include: all storage resources (S3, EBS, RDS, EFS) must have encryption at rest enabled using customer-managed KMS keys (not AWS-managed keys), no security group rules can allow ingress from 0.0.0.0/0 on any port, all RDS instances must have automated backups with a minimum 7-day retention, all CloudTrail logs must be enabled and sent to a centralized logging account, all EC2 instances must be deployed in private subnets (no public IP assignment), and all load balancers must have access logging enabled. Each of these requirements becomes a Sentinel policy that evaluates the Terraform plan and blocks the apply if any resource violates the rule.
Sentinel enforcement levels create a tiered governance model. Hard-mandatory policies cannot be overridden by anyone — they represent non-negotiable regulatory requirements like encryption at rest. Soft-mandatory policies can be overridden by workspace administrators with a documented reason — useful for temporary exceptions during migrations or emergency fixes. Advisory policies log warnings but do not block applies — useful for best practices that are not yet mandatory, like tagging standards for new cost centers. In banking, most PCI-DSS policies are hard-mandatory, cost governance policies are soft-mandatory (to allow approved exceptions for high-performance workloads), and new policy rollouts start as advisory for a sprint to identify false positives before becoming mandatory.
Writing effective Sentinel policies requires understanding the Terraform plan structure. The tfplan/v2 import provides access to all resource changes (creates, updates, deletes), their before and after values, and the provider configuration. Policies filter resources by type (aws_s3_bucket, aws_db_instance), then assert conditions on their attributes. For complex policies, you can use Sentinel modules — reusable functions shared across policies. For example, a 'require_tags' module that checks for mandatory tags (cost-center, owner, data-classification, compliance-scope) can be imported by multiple policies. Testing Sentinel policies uses the sentinel test command with mock Terraform plan data, ensuring policies behave correctly before deployment.
The biggest gotcha is policy sprawl and maintenance burden. As the organization adds more policies, engineers face longer feedback cycles — waiting for 50 policies to evaluate on every plan. Keep policies focused and fast by using resource-type filters early in the policy to skip irrelevant resources. Another gotcha is false positives — a policy that blocks legitimate infrastructure changes erodes trust in the governance system and leads to engineers finding workarounds. Invest in thorough policy testing with both passing and failing plan fixtures. Test edge cases like resource updates (not just creates), data sources, and resources managed by different providers. Finally, communicate policy changes clearly — when a new hard-mandatory policy is introduced, give teams a sprint to remediate existing infrastructure before enforcement begins.
Code Example
# Sentinel Policy: Enforce encryption at rest on all storage
# policies/pci-dss/enforce-encryption.sentinel
import "tfplan/v2" as tfplan
import "strings"
# Find all S3 buckets being created or updated
s3_buckets = filter tfplan.resource_changes as _, rc {
rc.type is "aws_s3_bucket" and
(rc.change.actions contains "create" or rc.change.actions contains "update")
}
# Find all RDS instances being created or updated
rds_instances = filter tfplan.resource_changes as _, rc {
rc.type is "aws_db_instance" and
(rc.change.actions contains "create" or rc.change.actions contains "update")
}
# Find all EBS volumes
ebs_volumes = filter tfplan.resource_changes as _, rc {
rc.type is "aws_ebs_volume" and
rc.change.actions contains "create"
}
# Rule: S3 buckets must have server-side encryption
s3_encrypted = rule {
all s3_buckets as _, bucket {
bucket.change.after.server_side_encryption_configuration is not null
}
}
# Rule: RDS must use encryption with customer-managed KMS key
rds_encrypted = rule {
all rds_instances as _, db {
db.change.after.storage_encrypted is true and
db.change.after.kms_key_id is not null and
not strings.has_prefix(db.change.after.kms_key_id, "alias/aws/")
}
}
# Rule: EBS volumes must be encrypted
ebs_encrypted = rule {
all ebs_volumes as _, vol {
vol.change.after.encrypted is true
}
}
main = rule {
s3_encrypted and rds_encrypted and ebs_encrypted
}
---
# Sentinel Policy: No public network access
# policies/pci-dss/no-public-access.sentinel
import "tfplan/v2" as tfplan
# Find security group rules allowing 0.0.0.0/0
sg_rules = filter tfplan.resource_changes as _, rc {
rc.type is "aws_security_group_rule" and
rc.change.actions contains "create" and
rc.change.after.type is "ingress"
}
no_open_ingress = rule {
all sg_rules as _, rule {
rule.change.after.cidr_blocks not contains "0.0.0.0/0"
}
}
# No EC2 instances with public IPs
ec2_instances = filter tfplan.resource_changes as _, rc {
rc.type is "aws_instance" and
rc.change.actions contains "create"
}
no_public_ec2 = rule {
all ec2_instances as _, instance {
instance.change.after.associate_public_ip_address is false or
instance.change.after.associate_public_ip_address is null
}
}
main = rule {
no_open_ingress and no_public_ec2
}
---
# Sentinel Policy: Mandatory tagging for audit trail
# policies/governance/mandatory-tags.sentinel
import "tfplan/v2" as tfplan
# Required tags for all taggable resources
required_tags = ["cost-center", "owner", "data-classification", "compliance-scope"]
# All resources that support tags
taggable_resources = filter tfplan.resource_changes as _, rc {
rc.change.actions contains "create" and
rc.change.after.tags is not null
}
mandatory_tags = rule {
all taggable_resources as _, resource {
all required_tags as tag {
resource.change.after.tags contains tag
}
}
}
main = rule { mandatory_tags }
---
# Policy set configuration in Terraform
resource "tfe_policy_set" "pci_dss" {
name = "pci-dss-mandatory"
description = "PCI-DSS compliance policies - hard mandatory"
organization = "bank-platform"
kind = "sentinel"
enforcement_mode = "hard-mandatory" # Cannot be overridden
vcs_repo {
identifier = "bank/sentinel-policies"
branch = "main"
oauth_token_id = var.github_oauth_id
}
# Attach to all production workspaces
workspace_ids = [
tfe_workspace.payments_prod.id,
tfe_workspace.settlements_prod.id,
tfe_workspace.fraud_detector_prod.id,
]
}Interview Tip
A junior engineer typically describes Sentinel as 'just if-then rules' without understanding the enforcement levels, testing strategy, and organizational rollout process. Show depth by explaining the three enforcement levels (hard-mandatory for regulatory requirements, soft-mandatory for governance with exception process, advisory for new policy rollouts). Discuss the policy lifecycle: write policy, test with mock plan data, deploy as advisory for one sprint to catch false positives, then promote to mandatory. In banking, walk through specific PCI-DSS policies — encryption at rest with customer-managed KMS keys, no public ingress, mandatory audit logging — and explain how Sentinel evaluates the Terraform plan before any infrastructure change can proceed.
◈ Architecture Diagram
┌─────────────────────────────────────────────────────────────────┐ │ Sentinel Policy Enforcement in TFE │ │ │ │ ┌──────────┐ ┌──────────────┐ ┌───────────────────────┐ │ │ │ Engineer │───→│ Git Push / │───→│ TFE Workspace │ │ │ │ writes │ │ PR Merge │ │ Queues Plan │ │ │ │ Terraform│ └──────────────┘ └───────────┬───────────┘ │ │ └──────────┘ │ │ │ ▼ │ │ ┌────────────────────────────────┐ │ │ │ Terraform Plan Generated │ │ │ └────────────────┬───────────────┘ │ │ │ │ │ ▼ │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ Sentinel Policy Evaluation │ │ │ │ │ │ │ │ ┌─────────────────────┐ Enforcement Levels: │ │ │ │ │ PCI-DSS Policies │ ■ Hard-mandatory (no override) │ │ │ │ │ • Encryption at rest│ ■ Soft-mandatory (admin override)│ │ │ │ │ • No public access │ ■ Advisory (warn only) │ │ │ │ │ • Audit logging │ │ │ │ │ └─────────────────────┘ │ │ │ │ ┌─────────────────────┐ │ │ │ │ │ Cost Governance │ │ │ │ │ │ • Instance limits │ │ │ │ │ │ • Tag requirements │ │ │ │ │ └─────────────────────┘ │ │ │ └───────────────┬──────────────────────┬─────────────────────┘ │ │ │ │ │ │ ┌──────▼──────┐ ┌──────▼──────┐ │ │ │ PASS │ │ FAIL │ │ │ │ → Approve │ │ → Block │ │ │ │ & Apply │ │ Apply │ │ │ └─────────────┘ └─────────────┘ │ └─────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Configure Terraform Enterprise workspaces with scheduled plan-only runs (e.g., nightly) that detect differences between actual infrastructure and the Terraform state. Alert on drift via webhook notifications, categorize drift by severity, and either auto-remediate safe drifts or create tickets for manual review.
Detailed Answer
Think of drift detection like a nightly security guard doing rounds. The guard has a checklist of how every door, window, and safe should look. If something has changed — a window left open, a safe combination altered, a new lock installed — the guard reports it. Drift detection in Terraform works the same way: scheduled plan runs compare what actually exists in AWS against what Terraform expects, and any discrepancy is flagged for investigation.
Infrastructure drift occurs when the actual state of cloud resources diverges from the Terraform-declared state. This happens through manual console changes (someone modifies a security group via the AWS console), changes by other tools (an automation script modifies a resource that Terraform also manages), auto-scaling events that modify resource attributes, and AWS service updates that change default behaviors. In a banking environment, drift is a compliance risk — if your Terraform code declares that an RDS instance has encryption enabled but someone disables it through the console, your compliance posture is degraded and your Terraform state does not reflect reality.
Terraform Enterprise enables scheduled plan-only runs on workspaces. You configure a workspace to run terraform plan automatically at a set interval — typically nightly for production workspaces and weekly for non-production. The plan compares the current Terraform configuration and state against the actual infrastructure via provider API calls. If the plan detects changes (resources to update, create, or destroy), it means drift has occurred. TFE marks the run as 'planned and finished' with a non-empty plan, and you can configure webhook notifications to alert your team via Slack, PagerDuty, or a custom drift-tracking system.
At enterprise scale, not all drift is equal. A changed tag is low-severity drift that might be auto-remediated. A modified security group rule is high-severity drift that requires immediate investigation — someone may have opened a port that violates PCI-DSS. A deleted resource is critical drift that needs urgent attention. Build a drift classification system: the webhook from TFE sends the plan summary to a Lambda function or custom service that parses the plan output, categorizes each change by resource type and attribute, assigns a severity level, and routes the notification appropriately. Low-severity drift creates a Jira ticket for the next sprint. High-severity drift pages the security team. Critical drift triggers an incident response.
For drift remediation, there are two approaches. Auto-remediation configures TFE to automatically apply the plan when drift is detected, restoring infrastructure to the declared state. This is appropriate for low-risk drifts like tag changes or description updates, but dangerous for high-risk resources — auto-applying a plan that wants to recreate an RDS instance would cause downtime. Selective auto-remediation uses Sentinel policies to evaluate the drift plan: if the only changes are to tags and descriptions, auto-apply; if the plan includes any destroy or replace actions, block and alert. Manual remediation requires a human to review the drift, determine whether the Terraform code or the infrastructure should be updated, and either apply the plan or update the code to match the new reality.
The biggest gotcha is drift detection generating noise that teams ignore. If your scheduled plans consistently show drift from resources that Terraform partially manages (like ASG instance counts that change with auto-scaling), the team learns to dismiss all drift alerts. Use lifecycle ignore_changes blocks in Terraform for attributes that are expected to drift (like ASG desired_count), and ensure your scheduled plans only flag genuine unauthorized changes. Another gotcha is the API rate limiting — running terraform plan across 200 workspaces simultaneously hammers the AWS API. Stagger your scheduled plans across the night, and use workspace tags to group and schedule them in batches. Finally, drift detection only catches drift in resources Terraform manages — resources created manually outside of Terraform are invisible. Complement TFE drift detection with AWS Config rules that detect unmanaged resources.
Code Example
# TFE workspace with scheduled drift detection
resource "tfe_workspace" "payments_infra_prod" {
name = "payments-infra-production"
organization = "bank-platform"
terraform_version = "1.7.0"
auto_apply = false
vcs_repo {
identifier = "bank/payments-infrastructure"
branch = "main"
oauth_token_id = var.github_oauth_id
}
}
# Scheduled plan-only run for nightly drift detection
resource "tfe_workspace_run_schedule" "payments_drift_check" {
workspace_id = tfe_workspace.payments_infra_prod.id
# Run plan every night at 2 AM ET (7 AM UTC)
cron_schedule = "0 7 * * *"
# Plan only — do not auto-apply
plan_only = true
}
# Webhook notification for drift alerts
resource "tfe_notification_configuration" "drift_alert" {
name = "drift-detection-alert"
enabled = true
workspace_id = tfe_workspace.payments_infra_prod.id
destination_type = "generic" # Custom webhook
url = "https://drift-handler.bank.internal/webhook"
triggers = [
"run:needs_attention", # Plan with changes detected
"run:errored", # Plan failed (possible API issue)
]
}
---
# Drift classification Lambda (triggered by TFE webhook)
# drift-handler/handler.py
import json
import boto3
def classify_drift(event):
"""Classify drift severity based on resource type and change type."""
plan_summary = event.get('plan_summary', {})
changes = plan_summary.get('resource_changes', [])
severity = 'low'
findings = []
for change in changes:
resource_type = change['type']
actions = change['actions']
# Critical: any destroy or replace action
if 'delete' in actions or 'replace' in actions:
severity = 'critical'
findings.append(f"CRITICAL: {resource_type} will be {actions}")
# High: security-related resources modified
elif resource_type in [
'aws_security_group_rule',
'aws_iam_policy',
'aws_iam_role_policy',
'aws_kms_key',
'aws_s3_bucket_policy'
]:
severity = max(severity, 'high')
findings.append(f"HIGH: {resource_type} drifted")
# Low: tags, descriptions, non-functional changes
else:
findings.append(f"LOW: {resource_type} drifted")
return severity, findings
def route_alert(severity, findings, workspace_name):
"""Route drift alerts based on severity."""
if severity == 'critical':
# Page security team immediately
pagerduty_alert(f"CRITICAL drift in {workspace_name}", findings)
create_jira_incident(workspace_name, findings)
elif severity == 'high':
# Slack alert to security channel
slack_alert('#security-ops', workspace_name, findings)
create_jira_ticket('HIGH', workspace_name, findings)
else:
# Low priority — create ticket for next sprint
create_jira_ticket('LOW', workspace_name, findings)
---
# Terraform lifecycle blocks to reduce drift noise
# Ignore expected drift from auto-scaling
resource "aws_autoscaling_group" "payments_api" {
# ... configuration ...
lifecycle {
ignore_changes = [
desired_capacity, # Changes with auto-scaling
target_group_arns, # Changes with blue-green deploys
]
}
}
# Ignore expected drift from external secret rotation
resource "aws_db_instance" "settlements_db" {
# ... configuration ...
lifecycle {
ignore_changes = [
password, # Rotated by Vault, not managed by Terraform
]
}
}Interview Tip
A junior engineer typically does not think about drift at all, or mentions it as an afterthought. Show operational maturity by describing a systematic approach: scheduled nightly plans, severity classification of drift findings, and tiered response (auto-remediate safe changes, alert on security-related drift, page on critical drift). In banking, emphasize that drift is a compliance risk — a manually modified security group could violate PCI-DSS, and drift detection provides the audit evidence that your infrastructure matches your declared state. Mention lifecycle ignore_changes for expected drift to reduce noise, and AWS Config rules to catch resources created outside of Terraform.
◈ Architecture Diagram
┌─────────────────────────────────────────────────────────────────┐ │ Infrastructure Drift Detection Pipeline │ │ │ │ ┌──────────────────┐ │ │ │ TFE Workspace │ Scheduled: Nightly at 2 AM │ │ │ Plan-Only Run │──────────────────────────────┐ │ │ └──────────────────┘ │ │ │ ▼ │ │ ┌───────────────────────────────────────────────────────────┐ │ │ │ terraform plan (read-only) │ │ │ │ │ │ │ │ Declared State ←──compare──→ Actual Infrastructure │ │ │ │ (Terraform code) (AWS API responses) │ │ │ └──────────────────────┬────────────────────────────────────┘ │ │ │ │ │ ┌──────────▼──────────┐ │ │ │ Changes Detected? │ │ │ └──┬──────────────┬───┘ │ │ No │ │ Yes │ │ ┌──────▼────┐ ┌──────▼───────────────────────────┐ │ │ │ No drift │ │ Webhook → Drift Classifier │ │ │ │ All good │ │ │ │ │ └───────────┘ │ ┌─────────┐ ┌────────┐ ┌─────┐ │ │ │ │ │CRITICAL │ │ HIGH │ │ LOW │ │ │ │ │ │Delete/ │ │SecGroup│ │Tags │ │ │ │ │ │Replace │ │IAM/KMS │ │Desc │ │ │ │ │ │→ Page │ │→ Slack │ │→Jira│ │ │ │ │ │ SecOps │ │ Alert │ │ Tkt │ │ │ │ │ └─────────┘ └────────┘ └─────┘ │ │ │ └──────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Publish vetted, hardened Terraform modules to a private registry (TFE or Artifactory) with semantic versioning. Each module has an owning team responsible for maintenance, security updates, and documentation. Consumer teams pin module versions and upgrade through a managed process.
Detailed Answer
Think of a private module registry like an internal app store for a bank. Instead of every team building their own RDS setup from scratch (with varying levels of security hardening), the platform team publishes a vetted 'RDS Module' to the internal store. Application teams install it, configure their specific parameters (database name, size), and get encryption, backup, monitoring, and compliance built in. The platform team updates the module when security requirements change, and consumer teams upgrade on their own schedule — just like updating an app on your phone.
A private Terraform module registry serves as the single source of truth for approved infrastructure patterns. In Terraform Enterprise, the private registry is built in — you publish modules from Git repositories with the naming convention terraform-<provider>-<name> (like terraform-aws-eks-cluster or terraform-aws-rds-postgresql). In organizations using open-source Terraform, alternatives include JFrog Artifactory's Terraform provider, a self-hosted registry using the Terraform Registry Protocol, or even Git-based module references with version tags. The key principle is that production infrastructure should only use modules from the private registry, never ad-hoc inline resources — this is enforced via Sentinel policies that check the module source in every Terraform plan.
Semantic versioning is critical for module governance. Every module follows semver (major.minor.patch): patch versions fix bugs without changing behavior, minor versions add features backward-compatibly, and major versions include breaking changes that require consumer updates. When the platform team updates the RDS module to require a new mandatory tag (minor version bump), consumer teams see the new version in the registry but continue using their pinned version until they are ready to upgrade. When a major version changes the module interface (removing an input variable or changing an output format), consumer teams must explicitly update their code. Version constraints in consumer code (version = '~> 2.0' means any 2.x version) allow automatic adoption of patches and minor updates while protecting against breaking changes.
Team ownership is formalized through module CODEOWNERS files and documentation. Each module has an owning team listed in the repository's CODEOWNERS file, ensuring that any PR to the module requires review from the owners. The owning team is responsible for security patching (updating provider versions, fixing CVEs), documentation (README with usage examples, input/output descriptions, and architecture diagrams), testing (automated tests using Terratest or terraform-compliance that run in CI), and deprecation communication (announcing when older versions will lose support). In a banking organization, module ownership maps to infrastructure domains: the networking team owns the VPC and transit gateway modules, the database team owns the RDS and ElastiCache modules, and the platform team owns the EKS and observability modules.
The module development lifecycle follows a structured process. A team identifies a repeated infrastructure pattern (every team needs an S3 bucket with encryption, versioning, access logging, and lifecycle policies). They build a module, test it with Terratest (creating real infrastructure in a sandbox account, validating it, then destroying it), write documentation, and publish version 1.0.0 to the private registry. Consumer teams adopt the module with a pinned version constraint. When a security requirement changes (for example, a new PCI-DSS control requires S3 Object Lock), the module team releases version 1.1.0 with the new feature as an optional input, and version 2.0.0 if the feature must be mandatory (breaking change for consumers not passing the new input). The module team announces the update through internal channels and provides migration guides for major version bumps.
The biggest gotcha is creating modules that are either too opinionated or too flexible. A module that hardcodes the instance type, subnet, and tags is useless because every consumer has different requirements. A module that exposes every single AWS resource argument as an input variable is just a wrapper around the provider with no added value. Good modules encode organizational opinions (encryption is always on, backups are always enabled, monitoring is always configured) while exposing legitimate customization points (instance size, database name, backup retention period). Another gotcha is orphaned modules — modules published to the registry but never updated, with no clear owner. Implement a quarterly module health review where each module is checked for dependency updates, provider compatibility, and active ownership. Deprecate modules that are no longer maintained with clear migration paths to replacements.
Code Example
# Private module structure: terraform-aws-rds-postgresql
# Published to TFE private registry
#
# terraform-aws-rds-postgresql/
# ├── main.tf # Core RDS resources
# ├── variables.tf # Input variables
# ├── outputs.tf # Output values
# ├── versions.tf # Provider version constraints
# ├── sentinel.hcl # Policy tests
# ├── README.md # Usage documentation
# ├── CODEOWNERS # Team ownership
# ├── CHANGELOG.md # Version history
# └── test/
# └── rds_test.go # Terratest integration tests
# main.tf - Encodes banking compliance requirements
resource "aws_db_instance" "this" {
identifier = "${var.team}-${var.service_name}-${var.environment}"
engine = "postgres"
engine_version = var.engine_version
instance_class = var.instance_class
# MANDATORY: Encryption at rest (PCI-DSS)
storage_encrypted = true
kms_key_id = var.kms_key_arn
# MANDATORY: Automated backups
backup_retention_period = max(var.backup_retention_days, 7) # Min 7 days
backup_window = "03:00-04:00"
# MANDATORY: Multi-AZ for production
multi_az = var.environment == "production" ? true : var.multi_az
# MANDATORY: No public access
publicly_accessible = false
db_subnet_group_name = aws_db_subnet_group.this.name
vpc_security_group_ids = [aws_security_group.this.id]
# MANDATORY: Audit logging for compliance
enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]
# MANDATORY: Deletion protection in production
deletion_protection = var.environment == "production" ? true : false
tags = merge(var.additional_tags, {
ManagedBy = "terraform"
Module = "terraform-aws-rds-postgresql"
ModuleVersion = "2.3.0"
Team = var.team
DataClassification = var.data_classification
ComplianceScope = "pci-dss"
})
}
# variables.tf - Expose customization, encode opinions
variable "service_name" {
description = "Name of the service this database supports"
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]+$", var.service_name))
error_message = "Service name must be lowercase alphanumeric with hyphens."
}
}
variable "instance_class" {
description = "RDS instance class"
type = string
default = "db.r6g.large"
validation {
condition = can(regex("^db\\.", var.instance_class))
error_message = "Must be a valid RDS instance class."
}
}
variable "data_classification" {
description = "Data classification level (required for compliance)"
type = string
validation {
condition = contains(["public", "internal", "confidential", "restricted"], var.data_classification)
error_message = "Must be one of: public, internal, confidential, restricted."
}
}
---
# Consumer usage - payments team
# services/payments-api/infra/database.tf
module "settlements_db" {
source = "app.terraform.io/bank-platform/rds-postgresql/aws"
version = "~> 2.3" # Accept patches, not major bumps
service_name = "settlements-processor"
team = "payments"
environment = "production"
instance_class = "db.r6g.xlarge"
engine_version = "15.4"
kms_key_arn = data.aws_kms_key.banking.arn
data_classification = "restricted" # PII and financial data
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
subnet_ids = data.terraform_remote_state.network.outputs.database_subnets
additional_tags = {
CostCenter = "payments-engineering"
}
}
---
# Sentinel policy: enforce private registry usage
# policies/governance/require-private-registry.sentinel
import "tfconfig/v2" as tfconfig
approved_registry = "app.terraform.io/bank-platform"
module_calls = filter tfconfig.module_calls as _, mc {
mc.source is not ""
}
all_modules_from_registry = rule {
all module_calls as _, mc {
mc.source matches approved_registry + "/.+"
}
}
main = rule { all_modules_from_registry }
---
# Terratest - automated module validation
# test/rds_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestRdsModule(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../",
Vars: map[string]interface{}{
"service_name": "test-settlements",
"team": "platform-test",
"environment": "sandbox",
"data_classification": "internal",
"kms_key_arn": "arn:aws:kms:us-east-1:123456:key/test",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Validate encryption is enabled
encrypted := terraform.Output(t, terraformOptions, "storage_encrypted")
assert.Equal(t, "true", encrypted)
}Interview Tip
A junior engineer typically describes modules as 'reusable Terraform code' without addressing governance, versioning, or ownership. Show platform engineering maturity by explaining the full lifecycle: a team identifies a pattern, builds a module with hardened defaults (encryption always on, backups always enabled), tests with Terratest, publishes to the private registry with semantic versioning, and maintains it with a CODEOWNERS file. Discuss the balance between opinionated and flexible — good modules encode security opinions while exposing legitimate customization. In banking, mention Sentinel policies that enforce all production Terraform must use private registry modules, preventing ad-hoc inline resources that bypass security controls.
◈ Architecture Diagram
┌─────────────────────────────────────────────────────────────────┐
│ Terraform Module Governance Lifecycle │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Module Development (Platform Team) │ │
│ │ │ │
│ │ Identify Pattern → Build Module → Terratest → Publish │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Hardened │→ │ Unit + │→ │ Semver │→ │ Private │ │ │
│ │ │ Defaults │ │ Integ │ │ Tag │ │ Registry │ │ │
│ │ │ (encrypt,│ │ Tests │ │ v2.3.0 │ │ Publish │ │ │
│ │ │ backup) │ │ │ │ │ │ │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Private Registry (app.terraform.io/bank-platform) │ │
│ │ │ │
│ │ terraform-aws-rds-postgresql v2.3.0 (DB Team) │ │
│ │ terraform-aws-eks-cluster v3.1.0 (Platform Team) │ │
│ │ terraform-aws-vpc-banking v1.8.0 (Network Team) │ │
│ │ terraform-aws-s3-compliant v2.0.0 (Platform Team) │ │
│ └───────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Consumer Teams (Pin versions, upgrade on schedule) │ │
│ │ │ │
│ │ module "settlements_db" { │ │
│ │ source = "app.terraform.io/.../rds-postgresql/aws" │ │
│ │ version = "~> 2.3" # Accept patches only │ │
│ │ } │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Sentinel: All prod modules MUST come from private registry │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘💬 Comments
Quick Answer
Use a remote backend like S3 with DynamoDB locking, encrypt state at rest with KMS, restrict access via IAM policies to CI/CD pipelines only, enable versioning for rollback, and split state files by team or component to limit blast radius.
Detailed Answer
Think of Terraform state as a shared bank ledger. If multiple tellers write to the same page at the same time, the numbers get corrupted. If someone photocopies the ledger and takes it home, sensitive account details are exposed. Managing state securely means controlling who can read it, who can write to it, and ensuring only one person writes at a time.
Terraform state contains the full mapping between your HCL code and real infrastructure — including resource IDs, IP addresses, and sometimes sensitive values like database passwords and private keys. In a multi-team environment, this state must be stored remotely (never in Git), encrypted, locked during operations, and access-controlled. The standard pattern on AWS is an S3 bucket with server-side encryption (KMS), versioning enabled for recovery, and a DynamoDB table for state locking.
The locking mechanism prevents concurrent writes. When terraform plan or apply runs, it acquires a lock in DynamoDB using the state file path as the key. If another team member or pipeline tries to run simultaneously, they get a lock error and must wait. This prevents state corruption from concurrent modifications. The lock is released automatically when the operation completes, or can be force-unlocked with terraform force-unlock if a pipeline crashes mid-operation.
At multi-team scale, state should be split by ownership boundary. Each team or component gets its own state file — the networking team manages vpc/terraform.tfstate, the platform team manages eks/terraform.tfstate, and application teams manage their own app/terraform.tfstate. This means a terraform destroy by the app team cannot touch the VPC or EKS cluster. Cross-team references use terraform_remote_state data sources or SSM parameters rather than managing resources across state boundaries.
The non-obvious gotcha is that terraform_remote_state requires read access to the other team's state file, which contains all their sensitive outputs. A safer alternative is writing shared values to SSM Parameter Store or Vault, so consumers get only the specific values they need without accessing the full state. Also, state files should never be committed to Git — even encrypted state contains sensitive metadata that can leak through Git history even after deletion.
Code Example
# backend.tf — Secure remote state configuration
terraform {
backend "s3" {
bucket = "acme-terraform-state-prod" # Dedicated state bucket with versioning
key = "platform/eks/terraform.tfstate" # Team/component scoped path
region = "us-east-1" # State bucket region
dynamodb_table = "acme-terraform-locks" # Prevents concurrent state modifications
encrypt = true # Server-side encryption at rest
kms_key_id = "arn:aws:kms:us-east-1:111111111111:key/abc-123" # Customer-managed KMS key
}
}
# IAM policy restricting state access to CI/CD pipeline role only
# {
# "Effect": "Allow",
# "Action": ["s3:GetObject", "s3:PutObject"],
# "Resource": "arn:aws:s3:::acme-terraform-state-prod/platform/eks/*",
# "Condition": {
# "StringEquals": { "aws:PrincipalArn": "arn:aws:iam::111111111111:role/terraform-platform-ci" }
# }
# }
# Safer cross-team reference via SSM instead of remote_state
data "aws_ssm_parameter" "vpc_id" {
name = "/infrastructure/prod/vpc/id" # Written by networking team's Terraform
}
# Force-unlock a stuck lock (use with caution)
# terraform force-unlock LOCK_IDInterview Tip
A junior engineer typically says 'we store state in S3 with locking,' but for a senior role, the interviewer is actually looking for the full security and governance model. Explain encryption (KMS), access control (IAM policies scoped to CI/CD roles only), state splitting by team/component for blast radius isolation, and why terraform_remote_state is risky for cross-team references (exposes full state). Mentioning SSM Parameter Store as a safer alternative and the force-unlock procedure for stuck locks shows production operations depth. Describing how you prevent human terraform apply by restricting state write access to pipelines demonstrates mature DevOps governance.
◈ Architecture Diagram
┌──────────────────────────────┐
│ Team A State Team B State│
│ platform/eks/ apps/payments/│
│ (isolated) (isolated) │
└──────┬───────────────┬───────┘
↓ ↓
┌──────────────────────────────┐
│ S3 Bucket (versioned+KMS) │
│ + DynamoDB Lock Table │
│ + IAM: CI/CD roles only │
└──────────────────────────────┘💬 Comments
Quick Answer
Use for_each when resources are identified by a meaningful key (like a name or environment), and count when you need N identical copies. count uses numeric indices, so inserting or removing an item in the middle shifts all subsequent indices, causing Terraform to destroy and recreate resources. for_each uses map keys, so additions and removals only affect the specific resource.
Detailed Answer
Think of assigned seating at a dinner. count is like numbering seats 1 through 10 — if guest #3 cancels, everyone from seat 4 onward shifts down, causing chaos with place cards and meal orders. for_each is like name tags on seats — removing 'Alice' only affects Alice's seat, and everyone else stays put. The difference seems minor until you have production infrastructure attached to those seats.
count creates resources indexed by number: aws_subnet.private[0], aws_subnet.private[1], aws_subnet.private[2]. If you remove the second item from your list, what was index [2] becomes [1], and Terraform sees this as 'destroy [2] and modify [1]' — which means destroying and recreating a subnet that might have running pods on it. This is catastrophic for stateful resources.
for_each creates resources indexed by string key: aws_subnet.private["us-east-1a"], aws_subnet.private["us-east-1b"], aws_subnet.private["us-east-1c"]. If you remove us-east-1b, Terraform only destroys that one subnet. The others are untouched because their keys did not change. This makes for_each the safe default for any resource that has a natural identifier.
Use count only for truly identical resources where the number matters but individual identity does not — like creating N replicas of a test instance that can be destroyed and recreated freely. Use for_each for everything else: subnets (keyed by AZ), IAM users (keyed by name), security group rules (keyed by port or CIDR), DNS records (keyed by hostname), and S3 buckets (keyed by purpose). Converting from count to for_each on existing resources requires terraform state mv to remap indices to keys, or use moved blocks.
The non-obvious gotcha is that for_each requires a set or map — not a list. If you pass a list, you must convert it with toset(). Also, the for_each key becomes part of the resource address, so changing a key destroys and recreates that resource. Choose stable, meaningful keys that will not change over the resource's lifetime. Using the AZ name as a key for subnets is stable. Using an auto-generated ID is not.
Code Example
# BAD: count — removing an item shifts all indices
variable "subnet_cidrs" {
default = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
}
resource "aws_subnet" "private" {
count = length(var.subnet_cidrs) # Removing item 1 shifts item 2 to index 1
cidr_block = var.subnet_cidrs[count.index]
vpc_id = aws_vpc.main.id
}
# GOOD: for_each — removing an item only affects that resource
variable "subnets" {
default = {
"us-east-1a" = "10.0.1.0/24" # Keyed by AZ — stable identifier
"us-east-1b" = "10.0.2.0/24" # Removing this only destroys this subnet
"us-east-1c" = "10.0.3.0/24" # Others are untouched
}
}
resource "aws_subnet" "private" {
for_each = var.subnets # Map keys become resource address keys
availability_zone = each.key # AZ from the map key
cidr_block = each.value # CIDR from the map value
vpc_id = aws_vpc.main.id
tags = {
Name = "payments-private-${each.key}" # Descriptive name with AZ
}
}
# Migrate from count to for_each using moved blocks
moved {
from = aws_subnet.private[0]
to = aws_subnet.private["us-east-1a"] # Remaps without destroy/recreate
}Interview Tip
A junior engineer typically says 'for_each is for maps and count is for numbers,' but for a senior role, the interviewer is actually looking for understanding of WHY it matters. Explain the index-shifting problem with count — that removing an item causes downstream resources to be destroyed and recreated. Give a concrete example: removing a subnet with count destroys pods running on the next subnet. Then explain that for_each uses stable string keys, so only the specific resource is affected. Mentioning the toset() requirement for lists, the moved block for safe migration, and the guideline 'use for_each unless resources are truly interchangeable' shows architectural maturity.
◈ Architecture Diagram
┌─ count (index-based) ─────┐ │ [0] [1] [2] │ │ Remove [1]: │ │ [0] [2→1] ← shifted! │ │ TF destroys old [2] │ └───────────────────────────┘ ┌─ for_each (key-based) ────┐ │ ["a"] ["b"] ["c"] │ │ Remove ["b"]: │ │ ["a"] ["c"] ← untouched │ │ TF only destroys ["b"] │ └───────────────────────────┘
💬 Comments
Quick Answer
Use moved blocks in Terraform 1.1+ to declare that a resource has moved from its old address to a new address inside a module. Terraform updates the state file without destroying or recreating the resource. For older versions, use terraform state mv to manually remap resource addresses.
Detailed Answer
Think of reorganizing books on a shelf. You are not buying new books or throwing old ones away — you are just moving them from one shelf to another and updating the library catalog. The physical books (cloud resources) stay exactly the same; only the catalog entry (state file address) changes. If you do not update the catalog, the librarian thinks the book is missing from the old shelf and orders a new one while discarding the one on the new shelf.
When you refactor Terraform code to extract resources into modules, the resource address changes. A resource that was aws_security_group.payments_sg becomes module.networking.aws_security_group.payments_sg. Without telling Terraform about this move, it sees the old address as 'resource to destroy' and the new address as 'resource to create' — which means production downtime while it deletes and recreates your security group.
The moved block, introduced in Terraform 1.1, solves this declaratively. You add a moved block to your configuration that tells Terraform: 'the resource that used to be at address X is now at address Y.' When you run terraform plan, Terraform sees the move instruction, updates the state file to reflect the new address, and shows no create or destroy actions. The moved block can stay in your code permanently as documentation, or be removed after all teams have applied the change.
At production scale, refactoring into modules is a multi-step process. First, write the module and add moved blocks for every resource being relocated. Second, run terraform plan to verify zero creates and zero destroys — only state moves. Third, apply to update the state. Fourth, optionally remove the moved blocks after all environments have applied. For complex refactors involving dozens of resources, use terraform state mv as a batch operation with a script that generates all the state mv commands.
The non-obvious gotcha is that moved blocks only work within the same state file. If you are splitting resources into a completely separate state file (different backend), you need terraform state mv -state-out to export resources, then terraform import in the new state. This two-step process has a window where the resource exists in neither state, so plan carefully and do it during a maintenance window.
Code Example
# Before refactoring — resources are at the root level
# resource "aws_security_group" "payments_sg" { ... }
# resource "aws_subnet" "private_a" { ... }
# resource "aws_subnet" "private_b" { ... }
# After refactoring — resources moved into a module
module "networking" {
source = "./modules/networking" # New module containing the resources
vpc_id = aws_vpc.main.id
}
# Moved blocks tell Terraform the resources relocated (no destroy/recreate)
moved {
from = aws_security_group.payments_sg
to = module.networking.aws_security_group.payments_sg # New address inside module
}
moved {
from = aws_subnet.private_a
to = module.networking.aws_subnet.private["us-east-1a"] # Can also change to for_each
}
moved {
from = aws_subnet.private_b
to = module.networking.aws_subnet.private["us-east-1b"] # Maps old resource to new key
}
# Verify with plan — should show 0 to add, 0 to change, 0 to destroy
# terraform plan -var-file=prod.tfvars
# For older Terraform versions, use state mv manually
# terraform state mv aws_security_group.payments_sg module.networking.aws_security_group.payments_sg
# terraform state mv aws_subnet.private_a 'module.networking.aws_subnet.private["us-east-1a"]'Interview Tip
A junior engineer typically says they would destroy and recreate resources when refactoring, but for a senior role, the interviewer is actually looking for zero-downtime refactoring awareness. Explain the moved block as the declarative approach (Terraform 1.1+) and terraform state mv as the imperative fallback. The key is verifying with terraform plan that the refactor shows zero creates and zero destroys before applying. Mentioning the cross-state limitation (moved blocks only work within one state file) and the import/export workflow for cross-state migrations shows you have done real-world refactoring at scale.
◈ Architecture Diagram
┌─ Before ─────────────────┐
│ aws_sg.payments_sg │
│ aws_subnet.private_a │
│ aws_subnet.private_b │
└──────────┬───────────────┘
↓ moved blocks
┌─ After ──────────────────┐
│ module.networking │
│ aws_sg.payments_sg │
│ aws_subnet.private[a] │
│ aws_subnet.private[b] │
└──────────────────────────┘
Plan: 0 add, 0 destroy ✓💬 Comments
Quick Answer
Detect drift with terraform plan -refresh-only which compares the state file against actual cloud resources without proposing changes. Remediate by either updating HCL to match reality (accept the change) or running terraform apply to revert the resource (enforce declared state). Prevent drift with SCPs blocking console access, AWS Config rules, and scheduled drift detection in CI.
Detailed Answer
Think of a restaurant menu board. The manager updates the board (Terraform code), but a waiter changes the daily special price by hand on the physical sign (console change). The menu board and the sign now disagree. Drift detection is the manager checking the sign against the board every morning, and remediation is deciding whether to update the board or fix the sign.
Infrastructure drift occurs when real-world resources diverge from Terraform's state file. This happens when someone makes changes through the AWS console, another automation tool modifies resources, or AWS auto-applies changes like security patches. Terraform does not continuously monitor infrastructure — it only detects drift when you run terraform plan, which triggers a refresh that reads current resource attributes from cloud APIs.
terraform plan -refresh-only is the detection tool. It reads the current state of every resource in the state file and shows what has changed without proposing any modifications. The output shows 'Objects have changed outside of Terraform' with specific attribute differences. For automated detection, run this command on a schedule (daily via CI) and alert when the exit code indicates drift (terraform plan -refresh-only -detailed-exitcode returns exit code 2 when drift exists). AWS Config rules provide complementary detection for specific compliance violations like open security groups.
Remediation depends on intent. If the manual change was a valid emergency fix (someone tightened a security group during an incident), update the HCL code to include the change and apply. If the change was unauthorized or accidental, run terraform apply with the existing HCL to revert the resource to its declared state. For resources manually created outside Terraform, use terraform import to bring them under management, then write the corresponding HCL.
The non-obvious gotcha is that running terraform apply -refresh-only updates the state file to match reality, which makes Terraform think the manual change is now the desired state. If you later modify the HCL and apply, the manual change persists because it is now the state baseline. Always treat refresh-only as a detection command, not a fix. Also, some drift is expected and harmless — AWS tags added by auto-scaling, EKS-managed security group rules, or Lambda layer versions updated by deployment pipelines. Use lifecycle ignore_changes for attributes managed by external systems.
Code Example
# Detect drift without making changes
terraform plan -refresh-only -var-file=prod.tfvars # Shows what changed outside Terraform
# Automated drift detection in CI (runs daily)
# terraform plan -refresh-only -var-file=prod.tfvars -detailed-exitcode
# Exit code 0 = no drift
# Exit code 2 = drift detected → trigger Slack alert
# Remediate: revert unauthorized change (enforce declared state)
terraform apply -var-file=prod.tfvars # Reverts resources to match HCL code
# Remediate: accept a valid manual change (update HCL to match reality)
# Edit security_group.tf to include the manually added rule, then:
terraform apply -var-file=prod.tfvars # State, code, and reality now aligned
# Import a manually-created resource into Terraform
terraform import aws_security_group.emergency_sg sg-0abc123def456 # Add to state
# Then write the matching resource block in HCL
# Ignore expected drift from external systems
resource "aws_eks_cluster" "payments" {
name = "payments-cluster"
version = "1.31"
lifecycle {
ignore_changes = [version] # EKS auto-upgrades minor versions
}
}Interview Tip
A junior engineer typically does not mention drift detection at all, but for a senior role, the interviewer is actually looking for a proactive detection and remediation strategy. Explain the three-step approach: detect with terraform plan -refresh-only, remediate by either reverting or accepting, and prevent with SCPs and scheduled CI detection. Mention the -detailed-exitcode flag for automation (exit 2 = drift), the lifecycle ignore_changes block for expected drift, and the danger of apply -refresh-only accidentally accepting unwanted changes. Describing how you run daily drift checks in CI with alerting shows operational maturity that most candidates lack.
◈ Architecture Diagram
┌──────────┐
│ Manual │
│ Change │
└────┬─────┘
↓
┌──────────┐
│ Drift │
│ Detected │
│ (plan │
│ refresh) │
└────┬─────┘
↓
┌────────────────────┐
│ Remediate: │
│ 1. Revert (apply) │
│ 2. Accept (update) │
│ 3. Import (new) │
└────────────────────┘💬 Comments
Quick Answer
Enforce pre-apply checks using policy-as-code tools: Sentinel policies in Terraform Cloud/Enterprise for hard/soft-mandatory rules, OPA/Conftest for open-source policy evaluation against terraform plan JSON output, and Checkov/tfsec for static analysis of HCL files in CI. All checks run during terraform plan in the CI pipeline and block apply if violations are found.
Detailed Answer
Think of a building permit process. Before construction (terraform apply) begins, inspectors (policy tools) review the blueprints (terraform plan) for code violations, fire safety issues, and zoning compliance. No permit means no construction. The inspectors do not do the building — they only verify that the plans meet standards before the builder starts work.
Security and compliance checks in Terraform operate at three layers. Static analysis (pre-plan) scans HCL source code for known bad patterns like unencrypted S3 buckets, public security groups, or missing tags. Tools like Checkov, tfsec, and Trivy scan .tf files directly in CI and fail the pipeline if violations are found. This catches issues before Terraform even generates a plan.
Plan-time policy evaluation (post-plan, pre-apply) is the most powerful layer. terraform plan -out=plan.tfplan generates a plan file, then terraform show -json plan.tfplan converts it to JSON. This JSON contains the exact changes Terraform will make — resources to create, modify, or destroy with all their attributes. Policy tools like OPA/Conftest or Sentinel evaluate this JSON against rules: 'no security group may have ingress from 0.0.0.0/0', 'all RDS instances must have encryption enabled', 'no resource may be destroyed in the prod workspace without approval.' If any policy fails, the pipeline blocks apply.
In Terraform Cloud/Enterprise, Sentinel policies are built-in. Policies are categorized as advisory (warning only), soft-mandatory (can be overridden by authorized users), or hard-mandatory (cannot be overridden). This three-tier approach lets teams enforce critical security rules strictly while allowing flexibility for less critical standards. Cost estimation is also available, alerting when a plan would increase monthly spend beyond a threshold.
The non-obvious gotcha is that static analysis alone misses context-dependent violations. A security group rule allowing 0.0.0.0/0 on port 443 is fine for a public ALB but dangerous for an RDS instance. Plan-time policy evaluation sees the full resource context (what type of resource, what VPC, what tags) and can make smarter decisions. Teams should use both layers: static analysis for quick feedback during development, and plan-time policies for authoritative enforcement before apply. Also, policy tests are code too — version them in Git, review them in PRs, and test them against sample plans.
Code Example
# Layer 1: Static analysis with Checkov in CI
checkov -d . --framework terraform --check CKV_AWS_18,CKV_AWS_145 # Check S3 encryption and RDS encryption
# checkov exit code 1 = violations found, pipeline fails
# Layer 2: Plan-time policy with OPA/Conftest
terraform plan -out=plan.tfplan -var-file=prod.tfvars # Generate plan
terraform show -json plan.tfplan > plan.json # Convert to JSON
conftest test plan.json -p policies/ # Evaluate against OPA policies
# Example OPA policy: policies/security.rego
# package main
# deny[msg] {
# resource := input.resource_changes[_]
# resource.type == "aws_security_group_rule"
# resource.change.after.cidr_blocks[_] == "0.0.0.0/0"
# resource.change.after.from_port != 443
# msg := sprintf("SG rule allows 0.0.0.0/0 on port %d (only 443 allowed)", [resource.change.after.from_port])
# }
# Layer 3: Sentinel policy in Terraform Cloud (hard-mandatory)
# policy "require-encryption" {
# enforcement_level = "hard-mandatory"
# source = "./policies/require-encryption.sentinel"
# }
# CI pipeline flow
# 1. checkov -d . ← static scan
# 2. terraform plan -out=plan.tfplan ← generate plan
# 3. conftest test plan.json ← policy check
# 4. [manual approval for prod] ← human gate
# 5. terraform apply plan.tfplan ← apply only if all passInterview Tip
A junior engineer typically mentions running terraform validate or a linter, but for a senior role, the interviewer is actually looking for a multi-layer policy enforcement strategy. Describe the three layers: static analysis (Checkov/tfsec on HCL files), plan-time policies (OPA/Conftest on plan JSON), and Sentinel in TFE/TFC for enterprise governance. Explain the difference between advisory, soft-mandatory, and hard-mandatory enforcement levels. Mentioning that static analysis misses context (a 0.0.0.0/0 rule on port 443 is valid for an ALB but not for RDS) and that plan-time policies catch this shows you understand the limitations of each layer. Describing the CI pipeline flow — scan → plan → policy check → approval → apply — demonstrates production-ready DevSecOps maturity.
💬 Comments
Quick Answer
Enable state file versioning in the backend (S3 versioning), use state locking (DynamoDB), and recover by restoring the previous state version. Prevent by never running terraform apply concurrently.
Detailed Answer
1. Concurrent terraform apply without state locking 2. Manual edits to state file 3. Interrupted apply (network loss, process kill) leaving partial state 4. Provider bugs writing malformed state entries
1. Stop all Terraform operations — prevent further corruption 2. Identify the corruption: terraform plan will usually show the error. Common: Error: Invalid state file 3. Restore from backup: - S3 backend: Use S3 versioning to restore previous state version - aws s3api list-object-versions --bucket my-tf-state --prefix env/prod/terraform.tfstate - Download the last known good version and upload as current 4. For partial corruption: Use terraform state rm to remove the corrupted resource, then terraform import to re-import it 5. Validate: Run terraform plan and verify the diff matches expected state
- Always use remote backend with state locking (S3 + DynamoDB, GCS, Terraform Cloud) - Enable S3 versioning on the state bucket with lifecycle rules (keep 30+ versions) - CI/CD only: Never run terraform from local machines in production - State backup before destructive operations: terraform state pull > backup.tfstate - Use -lock-timeout=5m to handle transient lock conflicts
Split state files by environment and component (networking, compute, data). Smaller state files reduce blast radius and lock contention. Use Terraform Cloud/Enterprise workspaces for state management at scale.
Code Example
# List state versions in S3 aws s3api list-object-versions \ --bucket my-tf-state \ --prefix prod/terraform.tfstate \ --max-items 5 # Restore a previous version aws s3api get-object \ --bucket my-tf-state \ --key prod/terraform.tfstate \ --version-id "abc123" \ restored.tfstate # Remove corrupted resource from state terraform state rm 'aws_instance.corrupted' # Re-import the resource terraform import 'aws_instance.corrupted' i-0123456789abcdef0 # Verify terraform plan
Interview Tip
This question tests operational maturity. Always mention prevention first (locking, versioning, CI-only), then recovery. Interviewers want to see you've dealt with this in production, not just read about it.
💬 Comments
Quick Answer
Usually caused by a provider upgrade changing resource schemas, a backend state key change, or a module source change. Fix by checking the plan output for 'forces replacement' reasons and using lifecycle blocks or targeted applies.
Detailed Answer
1. Provider upgrade: New provider version changes a resource attribute from optional to ForceNew. Example: changing aws_instance.ami forces replacement. 2. State key mismatch: Refactoring module structure (renaming modules, moving resources between modules) causes Terraform to see new resources instead of existing ones 3. ForceNew attributes changed: Some attributes can't be updated in-place (e.g., aws_rds_instance.engine, aws_launch_template.name) 4. Backend migration issue: State file doesn't match the current backend configuration 5. Count/for_each index shift: Changing from count to for_each or reordering count indices
`` # Check WHY each resource is being replaced terraform plan -out=plan.tfplan terraform show plan.tfplan | grep 'forces replacement' ``
1. State move for refactoring: terraform state mv 'module.old.aws_instance.web' 'module.new.aws_instance.web' 2. Import for orphaned resources: terraform import 'aws_instance.web' i-abc123 3. Lifecycle ignore for computed attributes: ``hcl lifecycle { ignore_changes = [tags["LastModified"]] } ` 4. Pin provider version: required_providers { aws = { version = "~> 5.30" } }` 5. Use moved blocks (Terraform 1.1+) for declarative state refactoring
- Always review plan output carefully before apply - Pin provider versions in production - Use moved blocks when refactoring - Test provider upgrades in dev first
Code Example
# Terraform moved block for safe refactoring
moved {
from = module.old_name.aws_instance.web
to = module.new_name.aws_instance.web
}
# State move command
terraform state mv \
'module.vpc.aws_subnet.private[0]' \
'module.vpc.aws_subnet.private["us-east-1a"]'
# Check what forces replacement
terraform plan -no-color 2>&1 | grep -B5 'forces replacement'
# Pin provider version
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.30"
}
}
}Interview Tip
Show that you check the 'forces replacement' reason in the plan output before taking any action. Mention the moved block as the modern solution for refactoring — it's declarative and reviewable in PRs, unlike imperative state mv commands.
💬 Comments
Quick Answer
OpenTofu is an MPL-2.0 fork of Terraform that maintains API compatibility but diverges on features like client-side state encryption, early variable evaluation, and provider-defined functions. Migration is straightforward for CLI users but complex for Terraform Cloud users who need alternatives like Spacelift, env0, or self-hosted backends.
Detailed Answer
HashiCorp changed Terraform's license from MPL-2.0 to BSL 1.1 in August 2023. BSL allows free use but prohibits competing commercial offerings. OpenTofu (Linux Foundation) forked from the last MPL-licensed version (1.5.x) and has since diverged with independent features.
1. State encryption: OpenTofu supports client-side state encryption natively (AES-GCM with key providers for AWS KMS, GCP KMS, etc.). Terraform doesn't offer this. 2. Early variable/local evaluation: OpenTofu evaluates variables and locals earlier in the lifecycle, allowing them in backend configuration and module sources. 3. Provider-defined functions: Both support this (Terraform since 1.8, OpenTofu since 1.7), but implementations differ slightly. 4. Registry: OpenTofu uses its own registry (registry.opentofu.org) but supports pulling from the Terraform registry. 5. Removed features: OpenTofu removed cloud/remote backend integration with Terraform Cloud.
- State file compatibility: OpenTofu reads Terraform state files (both directions up to format version 4). However, if either tool adds new state features, forward compatibility may break. - Provider compatibility: All existing Terraform providers work with OpenTofu unchanged. - HCL compatibility: Core HCL syntax is identical. Module code requires no changes for migration. - CI/CD changes: Replace terraform binary with tofu, update pipeline scripts.
This is the hardest part. Options: - Spacelift: Full-featured, supports both Terraform and OpenTofu, strong policy engine - env0: Good for cost management, environment-as-a-service model - Scalr: Hierarchical policy management - Self-hosted: Atlantis (PR-based) + S3/GCS backend + OPA for policy - HCP Terraform (renamed Terraform Cloud): If BSL is acceptable, stay put
Code Example
# OpenTofu state encryption configuration
# backend.tf
terraform {
encryption {
key_provider "aws_kms" "main" {
kms_key_id = "alias/terraform-state-key"
region = "us-east-1"
}
method "aes_gcm" "encrypt" {
keys = key_provider.aws_kms.main
}
state {
method = method.aes_gcm.encrypt
enforced = true # Fail if state can't be encrypted
}
plan {
method = method.aes_gcm.encrypt
enforced = true
}
}
}
# OpenTofu early variable evaluation (works in backend config)
variable "state_bucket" {
default = "my-tf-state-prod"
}
terraform {
backend "s3" {
bucket = var.state_bucket # This works in OpenTofu, not in Terraform
key = "infra/terraform.tfstate"
region = "us-east-1"
}
}
# Migration script
#!/bin/bash
# Step 1: Install OpenTofu
brew install opentofu
# Step 2: Verify state compatibility
tofu init
tofu plan # Should show no changes if compatible
# Step 3: Update CI/CD (GitHub Actions example)
# Replace: uses: hashicorp/setup-terraform@v3
# With: uses: opentofu/setup-opentofu@v1
# Step 4: Update lock file
tofu providers lock -platform=linux_amd64 -platform=darwin_arm64Interview Tip
This question tests whether you follow the IaC ecosystem beyond just writing HCL. Know the specific technical differences (state encryption is the biggest). Be balanced - don't be political about the license change. The practical advice is: if you don't sell a competing product, BSL doesn't affect you. If you're evaluating, state encryption and early variable evaluation are OpenTofu's key advantages.
💬 Comments
Quick Answer
Terraform's native test framework (since v1.6) uses .tftest.hcl files with run blocks that execute real plan/apply cycles with assertions on outputs and resource attributes. Unlike Terratest (Go-based), it doesn't require programming knowledge. A comprehensive strategy combines terraform validate, terraform test, and integration tests.
Detailed Answer
The native test framework runs inside Terraform itself. Test files (.tftest.hcl) define run blocks that create temporary infrastructure, assert conditions, and automatically clean up. Each run block can use command = plan (no resources created) or command = apply (creates real resources in an ephemeral workspace).
- Terraform test: HCL-based, no Go required, runs inside terraform, limited to assertions on plan/state/outputs. Best for module authors validating contract. - Terratest: Go-based, can make HTTP calls, SSH into instances, run arbitrary validation. Best for integration testing (e.g., verify the web server actually responds on port 443). - Recommendation: Use terraform test for module contract tests, Terratest for end-to-end infrastructure validation.
1. Static analysis (fastest): terraform validate, tflint, checkov, tfsec 2. Plan-time tests: terraform test with command = plan - verify resource counts, naming, tagging 3. Apply-time tests: terraform test with command = apply - verify actual outputs, dependencies 4. Integration tests: Terratest - verify network connectivity, security group rules, routing 5. Policy tests: OPA/Sentinel against plan JSON - verify compliance
Test the module provisions the correct number of subnets, CIDR ranges don't overlap, route tables are correctly associated, NAT gateway is in public subnet, and outputs contain expected values. Use variable overrides in test to exercise different configurations (2 AZs vs 3 AZs, with/without NAT gateway).
Code Example
# tests/vpc_basic.tftest.hcl
run "validate_vpc_creation" {
command = plan
variables {
vpc_cidr = "10.0.0.0/16"
environment = "test"
azs = ["us-east-1a", "us-east-1b"]
}
assert {
condition = aws_vpc.main.cidr_block == "10.0.0.0/16"
error_message = "VPC CIDR does not match expected value"
}
assert {
condition = length(aws_subnet.private) == 2
error_message = "Expected 2 private subnets for 2 AZs"
}
assert {
condition = length(aws_subnet.public) == 2
error_message = "Expected 2 public subnets for 2 AZs"
}
}
run "verify_nat_gateway" {
command = plan
variables {
vpc_cidr = "10.0.0.0/16"
environment = "test"
azs = ["us-east-1a", "us-east-1b"]
enable_nat_gw = true
}
assert {
condition = aws_nat_gateway.main[0].subnet_id == aws_subnet.public[0].id
error_message = "NAT Gateway must be in a public subnet"
}
}
run "full_apply_test" {
command = apply
variables {
vpc_cidr = "10.99.0.0/16" # Use non-overlapping CIDR for test
environment = "tftest"
azs = ["us-east-1a"]
}
assert {
condition = output.vpc_id != ""
error_message = "VPC ID output must not be empty"
}
assert {
condition = length(output.private_subnet_ids) > 0
error_message = "Must output at least one private subnet ID"
}
}
# Run tests
terraform test -verbose
# Terratest equivalent (Go)
# func TestVPCModule(t *testing.T) {
# terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
# TerraformDir: "../",
# Vars: map[string]interface{}{"vpc_cidr": "10.99.0.0/16", "environment": "test"},
# })
# defer terraform.Destroy(t, terraformOptions)
# terraform.InitAndApply(t, terraformOptions)
# vpcID := terraform.Output(t, terraformOptions, "vpc_id")
# assert.NotEmpty(t, vpcID)
# }Interview Tip
Testing IaC is a differentiator in interviews. Most candidates only mention terraform validate. Show you know the testing pyramid: static analysis -> plan assertions -> apply tests -> integration tests -> policy checks. The native test framework is relatively new, so knowing it signals you stay current.
💬 Comments
Quick Answer
Use 'terraform state list' to inventory, 'terraform state rm' for phantom resources, 'terraform import' for untracked resources, and 'terraform state pull/push' for manual JSON editing. Always backup state before any operation.
Detailed Answer
1. Phantom resources: State references resources deleted outside Terraform (manual AWS console deletion, another tool) 2. Orphaned resources: AWS resources exist but aren't in Terraform state (failed import, partial apply crash) 3. Duplicate entries: Same resource appears twice in state (rare, usually from concurrent applies) 4. Wrong resource attributes: State has stale attributes that don't match reality
Step 1: Backup Before any state surgery, create a backup. With remote backends, download the state file. With S3 backend, versioning provides automatic backups.
Step 2: Inventory Run terraform state list to see all tracked resources. Run terraform plan to see what Terraform thinks needs to change. Resources showing 'destroy' that shouldn't be destroyed are phantom entries. Resources not in the plan but existing in AWS are orphans.
Step 3: Remove phantom resources Use terraform state rm <resource_address> for each phantom resource. This only removes from state, doesn't touch real infrastructure.
Step 4: Import orphaned resources Use terraform import <resource_address> <cloud_resource_id> to bring untracked resources into state. Since Terraform 1.5+, use import blocks in HCL for repeatable imports.
Step 5: Reconcile attributes Run terraform refresh (or terraform apply -refresh-only) to update state attributes from the real cloud state. Review the changes before approving.
Step 6: Validate Run terraform plan and verify it shows minimal or no changes. Any remaining diff should be reviewed carefully.
Code Example
# Step 1: Backup state
terraform state pull > state-backup-$(date +%Y%m%d-%H%M%S).json
# Step 2: List all resources in state
terraform state list
# Step 3: Remove phantom resources (deleted outside TF)
terraform state rm 'module.vpc.aws_subnet.private[2]'
terraform state rm 'aws_instance.old_bastion'
# Step 4: Import orphaned resources
# Method 1: CLI import
terraform import aws_instance.web i-0123456789abcdef0
terraform import 'module.rds.aws_db_instance.main' mydb-instance-id
# Method 2: Import blocks (Terraform 1.5+, repeatable)
import {
to = aws_instance.web
id = "i-0123456789abcdef0"
}
import {
to = aws_s3_bucket.data
id = "my-data-bucket-prod"
}
# Then run: terraform plan -generate-config-out=generated.tf
# Step 5: Refresh state to match reality
terraform apply -refresh-only
# Step 6: Advanced - manual state JSON editing
terraform state pull > state.json
# Edit state.json carefully (fix serial number, resource attributes)
terraform state push state.json
# Step 7: Move resources between state files (during refactoring)
terraform state mv -state-out=../new-project/terraform.tfstate \
'module.vpc.aws_vpc.main' 'aws_vpc.main'
# Verify clean state
terraform plan # Should show no changes
# S3 backend state recovery from versioning
aws s3api list-object-versions \
--bucket my-tf-state \
--prefix prod/terraform.tfstate \
--query 'Versions[0:5].{VersionId:VersionId,LastModified:LastModified,Size:Size}'Interview Tip
State surgery questions are extremely common in senior Terraform interviews. Always start with 'backup the state file' - this shows production awareness. Know the difference between state rm (remove from tracking) and destroy (delete actual resource). Mention import blocks as the modern approach over CLI import.
💬 Comments