The 60 Terraform questions interviewers actually ask, with direct answers, runnable HCL, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
Terraform is an infrastructure as code tool from HashiCorp that lets you define, provision, and manage infrastructure using a declarative configuration language called HCL (HashiCorp Configuration Language). Instead of clicking through a cloud console, you write files describing the resources you want, virtual machines, networks, databases, DNS records, and Terraform makes the real infrastructure match that description. It's cloud-agnostic through a plugin system of providers, so the same tool manages AWS, Azure, Google Cloud, Kubernetes, and hundreds of other platforms. The core loop is write, plan, apply: you author configuration, run terraform plan to preview changes, then terraform apply to make them real, with Terraform tracking everything it manages in a state file. In interviews, Terraform questions probe how well you understand that state model, the resource dependency graph, and the workflow safeguards, not memorized command flags. This page collects the 60 questions that come up most, each with a direct answer and runnable HCL. If you're still building fundamentals, the official Terraform introduction from HashiCorp is the canonical starting point; increasingly the first DevOps round is a live technical interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Terraform Course: Automate your AWS cloud infrastructure
Video: Terraform Course: Automate your AWS cloud infrastructure (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Terraform certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
Terraform is an open-source infrastructure as code tool from HashiCorp that provisions and manages infrastructure from declarative configuration files. You describe the resources you want in HCL, and Terraform creates, updates, or destroys real cloud or on-prem infrastructure to match that description, using providers to talk to each platform's API.
It solves manual, click-through infrastructure: no more untracked changes in a console, no 'how was this set up' mysteries. The config is version-controlled, reviewable, and repeatable across environments, and one tool manages many clouds through providers.
Key point: A one-line definition plus the 'infrastructure in version control' framing beats a feature list. Interviewers open with this to hear how you organize an answer.
Watch a deeper explanation
Video: Terraform in 100 Seconds (Fireship, YouTube)
Infrastructure as code means defining servers, networks, load balancers, and services in machine-readable files instead of clicking through a console by hand. Those files go in version control, get code-reviewed like any other change, and produce the same infrastructure every time you apply them, on any account or region.
It matters because it makes infrastructure repeatable, auditable, and recoverable. You can spin up an identical staging environment, review a change before it lands, and rebuild from scratch if something is lost.
Key point: Interviewers use this to check whether you grasp the 'why' behind Terraform, not just the commands. Lead with repeatability and review.
Terraform is declarative: you describe the desired end state, and Terraform figures out the steps to reach it. You don't write 'create this, then that' as a script would; you write 'these resources should exist', and Terraform reads the references between them, computes the order, and creates independent resources in parallel.
This matters because you get idempotence for free: running apply on an unchanged config does nothing, and Terraform builds a dependency graph to sequence and parallelize the work correctly.
| Declarative (Terraform) | Imperative (a script) | |
|---|---|---|
| You write | Desired end state | Step-by-step commands |
| Ordering | Terraform computes it | You write it by hand |
| Re-running | Idempotent, no-op if unchanged | May duplicate or fail |
| Drift handling | Plan shows the diff | You detect it yourself |
Key point: The word 'idempotent' plus the dependency-graph point is what separates a memorized answer from understanding here.
State is a file (terraform.tfstate) that maps your configuration to the real resources Terraform manages, storing their IDs and attributes. It's how Terraform knows what it already created, so it can plan accurate diffs instead of recreating everything, and it caches attributes so plans stay fast even against a large deployment.
Without state, Terraform couldn't tell an existing resource from a new one. State also caches attributes so plans are fast and lets Terraform detect drift by comparing recorded state against reality.
Key point: State is the single most probed Terraform concept. If you can explain what it maps and why diffs need it, you clear most freshers rounds.
Watch a deeper explanation
Video: Terraform explained in 15 mins (TechWorld with Nana, YouTube)
plan is a dry run: it compares your configuration against state and real infrastructure, then prints exactly what it would create, change, or destroy, without touching anything. apply executes those same actions, but only after showing you the plan again and waiting for a yes, so nothing changes by surprise.
The habit the key point is: always read the plan before applying, especially the destroy lines. You can also save a plan to a file and apply that exact plan for safety in CI.
terraform plan -out=tfplan # preview and save the plan
terraform apply tfplan # apply exactly what was previewedKey point: The follow-up is 'what do you look for in a plan?'. Answer: the destroy and replace lines, and the resource count summary.
init prepares a working directory: it downloads the provider plugins your config needs, installs any modules, and configures the backend where state lives. You run it first in a new directory and again after adding a provider, module, or backend.
It creates the .terraform directory and a dependency lock file that pins provider versions so everyone on the team resolves the same ones.
terraform init # download providers, set up backend
terraform init -upgrade # re-resolve providers to newer allowed versionsA provider is a plugin that teaches Terraform how to manage a specific platform: aws, azurerm, google, kubernetes, cloudflare, and hundreds more from the registry. It translates the resources and data sources you declare into that platform's API calls, so Terraform itself stays platform-agnostic and only the provider knows the API details.
You declare providers in a required_providers block with version constraints, and configure each one (region, credentials) so Terraform can authenticate. One config can use several providers at once.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}A resource block declares one piece of infrastructure. It has a type that names the provider resource (aws_instance), a local name you choose (web), and a body of arguments configuring it. The type and name together form a unique address, aws_instance.web, that you reference everywhere else in the configuration.
Terraform reads all resource blocks, builds a dependency graph from the references between them, and creates them in the right order.
resource "aws_instance" "web" {
ami = "ami-0abcd1234"
instance_type = "t3.micro"
tags = {
Name = "web-server"
}
}Key point: Being able to The three parts (type, name, arguments) and The address aws_instance matters.web signals you've actually written HCL.
Input variables parameterize a configuration so the same code works across environments without edits. You declare them with a variable block that sets a type, an optional default, and a description, reference them as var.name inside resources, and set their values from tfvars files, CLI flags, or environment variables at apply time.
They keep hardcoded values out of resource blocks. A variable with no default becomes required, which is a simple way to force callers to supply, say, a region or instance size.
variable "instance_type" {
type = string
default = "t3.micro"
description = "EC2 size for the web tier"
}
resource "aws_instance" "web" {
instance_type = var.instance_type
ami = var.ami_id
}Outputs expose values from your configuration after apply: a load balancer DNS name, an instance IP, a database endpoint. They print at the end of apply, are queryable with terraform output for scripts, and can be read by other configurations through a remote state data source, which is how separate stacks share values.
They're also the interface of a module: a module returns useful values through outputs so the caller can wire them into other resources.
output "instance_ip" {
value = aws_instance.web.public_ip
description = "Public IP of the web server"
}A .tfvars file supplies concrete values for your input variables, separating the code from environment-specific data. You keep one file per environment (dev.tfvars, prod.tfvars) and pass it with -var-file so the exact same modules deploy different sizes, counts, and regions depending on which file you point at.
terraform.tfvars and any *.auto.tfvars load automatically; named files need the flag. This is the clean way to avoid hardcoding region, sizes, or counts.
terraform apply -var-file=prod.tfvars
# prod.tfvars:
# instance_type = "m5.large"
# instance_count = 4destroy removes all infrastructure Terraform manages in the current state, walking it in reverse dependency order so dependents go before their dependencies. It shows a plan of exactly what will be deleted and waits for your approval, the same safety gate as apply, and it only touches resources recorded in that state.
It's useful for tearing down temporary or test environments. To remove a single resource, target it or, more cleanly, delete its block and apply so the plan shows only that removal.
terraform destroy # remove everything in state
terraform destroy -target=aws_instance.web # remove one resourceKey point: Mentioning that -target is a break-glass tool, not a daily habit, indicates production maturity.
HCL is HashiCorp Configuration Language, the language you write Terraform in. It's built to be both human-readable and machine-friendly: configuration is made of blocks with a type and labels, key-value arguments inside them, and expressions for references, interpolation, and simple logic like conditionals and loops over collections.
You can also write Terraform in JSON, which tools generate, but engineers author HCL because it's cleaner. HCL supports variables, functions, loops, and conditionals, enough logic to keep configs DRY without becoming a full programming language.
fmt rewrites your files to the canonical style, with consistent indentation and alignment, so diffs stay clean and code reviews focus on real changes rather than whitespace. validate checks that the configuration is syntactically valid and internally consistent, catching bad references and type errors, without contacting any cloud or reading state.
Both are cheap and run in CI before plan. validate catches typos and bad references early; fmt keeps the whole repo formatted the same way.
terraform fmt -recursive # format every file in the tree
terraform validate # check syntax and referencesYou set required_version for the Terraform CLI and version constraints for each provider inside the terraform block. Operators like ~> 5.0 allow patch and minor updates but block a major bump, so an upstream release can't silently change how apply behaves across your team.
It matters because unpinned versions make plans non-reproducible: two engineers on different provider versions can get different results from the same code. The lock file then records the exact resolved versions everyone uses.
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}Key point: The ~> pessimistic operator is the detail the key signal is: it means 'newer patches yes, next major no'.
A module is a reusable, self-contained group of Terraform files that manage a related set of resources: a VPC, a database, a whole service. It takes inputs through variables, creates the resources, and returns outputs, working just like a function so you package the pattern once and reuse it across projects and environments.
Every configuration is technically the root module. You call child modules with a module block, which lets you package best-practice infrastructure once and reuse it across projects instead of copy-pasting.
module "network" {
source = "./modules/vpc"
cidr_block = "10.0.0.0/16"
env = "prod"
}
# use an output the module returns
resource "aws_instance" "web" {
subnet_id = module.network.public_subnet_id
}Key point: the question needs the function analogy: inputs in, resources built, outputs out. That framing carries into the intermediate module questions.
Both meta-arguments create multiple instances of one resource block. count makes N copies indexed by number (0, 1, 2), so resources are addressed as aws_instance.web[0]. for_each instead iterates over a map or set, addressing each instance by a stable key like aws_instance.web["api"] rather than a position that can shift.
for_each is usually safer: removing an item from the middle of a count list re-indexes everything after it, which Terraform indicates destroy-and-recreate. Keyed for_each addresses are stable, so removing one item touches only that one.
resource "aws_instance" "web" {
for_each = toset(["api", "worker", "cron"])
ami = var.ami_id
instance_type = "t3.micro"
tags = { Name = each.key }
}Key point: The re-indexing gotcha with count is the exact follow-up. Volunteering it answers the next question before it's asked.
Workspaces let one configuration hold multiple separate state files under the same backend, each tagged with its own terraform.workspace name. The starting one is called default; you create others when you want parallel copies of the same infrastructure, and you reference the current name to vary things like resource tags or counts.
They fit lightweight cases like per-developer or per-feature environments. For strongly separated environments like dev and prod, most teams prefer separate directories or state files over workspaces, because workspaces share the same code and can hide differences.
terraform workspace new staging
terraform workspace select staging
terraform workspace listA data source reads existing information without creating or managing it: the latest AMI, an existing VPC, the current account ID, an availability zone list. A resource block, by contrast, creates and owns infrastructure, and Terraform will change or destroy it, whereas a data source is strictly read-only and never modifies anything.
Data sources let your config reference things Terraform didn't create, keeping you from hardcoding IDs that change. They're read-only and refresh on every plan.
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-*"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
}refresh updates the state file to match what's actually deployed, reconciling the attributes Terraform recorded with the current reality in the cloud. It's how Terraform notices drift caused by manual changes made outside its control, such as someone resizing an instance directly in the console, and reflects them in the next plan.
In modern Terraform the standalone command is deprecated in favor of a refresh that happens automatically at the start of every plan. You can skip it with -refresh=false when you want a faster plan against a large state.
terraform state list prints every resource address currently in state, and terraform show prints the full state or a saved plan in readable form. Together they answer 'what does Terraform think it owns?' without you opening the raw JSON state file, which is useful when a plan proposes a change you didn't expect.
For one resource, terraform state show <address> dumps its recorded attributes, handy for debugging a plan that wants to change something you didn't touch.
terraform state list # all managed addresses
terraform state show aws_instance.web # attributes of one resourceTerraform uses the provider's normal credential chain, not a Terraform-specific mechanism. For AWS that's environment variables, a shared credentials file, an assumed IAM role, or an instance profile, checked in a set order; other providers follow the same idea with their own service-account keys or tokens configured in the provider block or environment.
The rule to state: keep credentials out of .tf files. Feed them through the environment or an assumed role so nothing sensitive lands in version control or in plan output.
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_REGION="us-east-1"
terraform plan # provider picks these up automaticallyTerraform builds a dependency graph from the references between resources. When resource B uses an attribute of resource A (like a subnet id), Terraform learns B depends on A and creates A first, then B. Independent resources get created in parallel.
When there's no attribute reference but an order still matters, depends_on forces the edge explicitly. You reach for it rarely, because implicit references cover almost every case.
resource "aws_instance" "app" {
ami = var.ami_id
subnet_id = aws_subnet.private.id # implicit dependency
depends_on = [aws_iam_role_policy.app] # explicit, when no reference exists
}Never commit the local state (terraform.tfstate and its backup), the .terraform directory of downloaded plugins, or any *.tfvars holding secrets. State can contain plaintext sensitive values like database passwords, the plugin cache is large and machine-specific, and a leaked tfvars file hands an attacker your credentials in one commit.
Do commit your .tf files and the .terraform.lock.hcl dependency lock file, so everyone resolves identical provider versions. This is a common freshers screen for basic hygiene.
.terraform/
*.tfstate
*.tfstate.backup
*.tfvars
crash.logKey point: Committing the lock file but ignoring state and .terraform is the exact split interviewers look for.
For candidates with working experience: state management, modules, provisioning logic, and the questions that separate users from understanders.
A backend defines where state lives and how operations run. The default local backend keeps state on your own disk; a remote backend (S3, Azure Blob, GCS, Terraform Cloud) stores it centrally so a whole team shares one source of truth instead of passing a state file around or each holding a divergent copy.
Teams need it for three reasons: shared state so everyone plans against the same reality, locking so two applies can't corrupt it, and durability plus encryption for a file that holds sensitive attributes. Local state has none of that.
terraform {
backend "s3" {
bucket = "my-tf-state"
key = "prod/network.tfstate"
region = "us-east-1"
dynamodb_table = "tf-lock" # enables state locking
encrypt = true
}
}Key point: The three-part answer, shared state, locking, durability, is the bar. Naming the lock mechanism (DynamoDB for S3) shows hands-on experience.
Watch a deeper explanation
Video: Introduction to HashiCorp Terraform with Armon Dadgar (HashiCorp, an IBM Company, YouTube)
When Terraform starts a write operation it acquires a lock on the state so no other run can modify it at the same time. With the S3 backend that lock lives in a DynamoDB table; other backends have their own mechanism. When the operation finishes, the lock releases.
Without locking, two concurrent applies can interleave writes and corrupt state, leaving Terraform's record inconsistent with reality. If a run crashes and leaves a stale lock, terraform force-unlock releases it, carefully.
Key point: The trap is treating force-unlock as routine. Stress that it's a last resort after confirming no other apply is running.
A module is a directory with three conventional files: variables.tf for inputs, main.tf for the resources, and outputs.tf for returned values. Callers pass inputs, the module builds its resources, and outputs expose what callers need next, like an id or endpoint, so the interface stays small and the internals can change freely.
Good modules are narrow and composable: a VPC module, a database module, a service module, each with a documented interface. You avoid hardcoding provider config inside a module so callers stay in control of regions and credentials.
# modules/vpc/variables.tf
variable "cidr_block" { type = string }
# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
}
# modules/vpc/outputs.tf
output "vpc_id" { value = aws_vpc.this.id }Modules can be sourced from a local path, a Git repository, or a registry. For anything shared, pin a version: use a Git tag or ref, or a version constraint from the registry, so an upstream change doesn't silently alter your infrastructure.
Local paths are fine inside one repo. The moment a module is shared across teams, unpinned sources become a supply-chain risk, a new commit changes what apply does without a code review on your side.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0" # pinned from the registry
cidr = "10.0.0.0/16"
}Key point: The word 'pin' is what they're listening for. Unpinned shared modules are a classic 'what could go wrong' prompt.
Terraform ships functions for strings (join, split, format), collections (merge, concat, lookup, flatten), numeric work, encoding (jsonencode, base64encode), filesystem reads (file, templatefile), and IP math (cidrsubnet). There are no user-defined functions in Terraform, so you compose the built-ins together and lean on locals to The intermediate results.
They keep configs DRY: build tag maps with merge, derive subnet ranges with cidrsubnet, render user-data with templatefile. terraform console is the fastest way to test one interactively.
locals {
common_tags = { team = "platform", env = var.env }
}
resource "aws_instance" "web" {
subnet_id = aws_subnet.this.id
tags = merge(local.common_tags, { Name = "web" })
}Terraform expresses conditional logic with the ternary form condition ? true_value : false_value for choosing a value. To create a resource only sometimes, you combine count with a condition, so count = var.enabled ? 1 : 0 makes either zero or one instance, and for_each over a possibly-empty collection does the same for keyed resources.
There's no if statement; you shape logic through these expressions, for_each on a possibly-empty collection, and dynamic blocks. Keeping it simple matters, because deeply nested ternaries get unreadable fast.
resource "aws_instance" "bastion" {
count = var.create_bastion ? 1 : 0
ami = var.ami_id
instance_type = "t3.micro"
}A dynamic block generates repeated nested blocks, like security group ingress rules or tag sets, from a collection, so you don't hand-write a dozen near-identical blocks. It iterates over a variable and produces one nested block per element, using iterator.value inside the content body to fill in each generated block's arguments.
Use it when the number of nested blocks depends on input. Don't overuse it: a couple of static blocks read better than a dynamic block, which trades clarity for flexibility.
resource "aws_security_group" "web" {
name = "web"
dynamic "ingress" {
for_each = var.allowed_ports
content {
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
}A local value is a named expression computed inside the configuration, referenced as local.name. Unlike input variables, callers can't set locals; they're internal helpers for values you compute once and reuse, like a naming prefix or a merged tag map.
The rule of thumb: variables are the module's external interface, locals are internal DRY helpers. Overusing locals for things that should be inputs makes a module rigid.
locals {
name_prefix = "${var.project}-${var.env}"
}
resource "aws_s3_bucket" "logs" {
bucket = "${local.name_prefix}-logs"
}Use terraform apply -replace=<address>, which plans a destroy-and-recreate for just that one resource and leaves everything else untouched. This replaced the older terraform taint command, which separately marked a resource so the next apply would recreate it; the -replace flag folds that into a single, reviewable plan and apply step.
It's the fix when a resource is in a bad state that config alone won't correct, a corrupted instance, a manually broken service, so you rebuild it cleanly without touching anything else.
terraform apply -replace=aws_instance.web # destroy and recreate just this oneKey point: Knowing -replace superseded taint signals you keep up with current Terraform, a small but real seniority tell.
Provisioners run scripts or commands as part of a resource's creation or destruction: remote-exec runs commands on the resource itself over SSH or WinRM, and local-exec runs on the machine running Terraform. They bolt imperative steps onto a declarative tool, which is exactly why HashiCorp treats them as an escape hatch rather than a normal feature.
HashiCorp itself calls them a last resort. They break the declarative model, aren't tracked in state, and don't re-run to fix drift. Prefer cloud-init, user_data, or a dedicated config tool; reach for provisioners only when nothing else fits.
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = "t3.micro"
user_data = file("bootstrap.sh") # preferred over remote-exec
}Key point: Saying 'provisioners are a last resort, use user_data or cloud-init instead' is the answer graders reward here.
First write configuration that matches the resource, then record it in state so Terraform stops seeing it as new. The classic path is terraform import <address> <id>; modern Terraform also supports a declarative import block you can plan and apply.
The order matters: import writes state, but you still need matching config, or the next plan will want to change the resource to match your (empty) config. Run a plan after importing to confirm it's a no-op.
import {
to = aws_instance.web
id = "i-0abc123def456"
}
resource "aws_instance" "web" {
# config written to match the existing instance
}Mark a variable or output with sensitive = true and Terraform redacts it in plan and apply output, showing (sensitive value) instead of the real data so secrets don't scroll past in a terminal or a CI log. Provider attributes like generated passwords are often flagged sensitive automatically, so this propagates through expressions that use them.
The important caveat: sensitive only hides values from the CLI output. They're still stored in plaintext in the state file, so protecting the backend (encryption, access control) is the real safeguard.
variable "db_password" {
type = string
sensitive = true
}
output "db_endpoint" {
value = aws_db_instance.main.endpoint
sensitive = true
}Key point: The follow-up is always 'but where is it stored?'. The answer, plaintext in state, is what this question is really testing.
state mv renames or moves a resource's address in state, for instance when you refactor a resource into a module without wanting to destroy and recreate it. state rm removes a resource from state so Terraform forgets it, without deleting the real infrastructure.
state rm is how you hand a resource off to another config or stop managing it safely. Modern code also uses a moved block in configuration to record address changes declaratively, which is safer than manual mv.
terraform state mv aws_instance.web module.web.aws_instance.this
terraform state rm aws_instance.legacy # stop managing, don't destroyA moved block records an address change in the configuration itself, declaring that a resource moved from an old address to a new one. On the next plan, Terraform reads it and quietly updates state to the new address instead of planning a destroy-and-recreate, which is what you'd otherwise get when refactoring into a module.
It beats manual state mv because it lives in version control, gets code-reviewed, and applies for everyone automatically. Nobody has to remember to run a CLI command out of band during a refactor.
moved {
from = aws_instance.web
to = module.web.aws_instance.this
}terraform graph outputs the dependency graph in DOT format, which you pipe into Graphviz to render a diagram of how resources depend on each other. It's a debugging aid when an ordering or cycle problem isn't obvious from the config.
Most of the time you don't need it, because the graph is derived from your references. But when a plan sequences things unexpectedly or reports a cycle, the visual makes the offending edge clear.
terraform graph | dot -Tpng > graph.pngMeta-arguments are special resource arguments Terraform handles itself rather than passing to the provider. count and for_each control how many instances exist; depends_on adds an explicit ordering edge; provider selects a non-default provider alias; lifecycle tunes create, update, and destroy behavior.
They apply across resource types because they shape Terraform's own behavior, not the underlying API call. Knowing which is which is a common intermediate probe.
| Meta-argument | Controls | Common use |
|---|---|---|
| count | Number of instances | N identical copies |
| for_each | Keyed instances | Stable per-key resources |
| depends_on | Explicit ordering | When no reference exists |
| lifecycle | Create/destroy behavior | prevent_destroy, create_before_destroy |
The lifecycle block changes how Terraform treats a resource's changes. create_before_destroy builds the replacement before removing the old one to avoid downtime. prevent_destroy blocks any plan that would delete the resource. ignore_changes tells Terraform to stop tracking specific attributes that drift by design.
These solve real production problems: zero-downtime replacement, guarding a database from accidental destroy, and ignoring an attribute that an autoscaler or external system mutates.
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = "t3.micro"
lifecycle {
create_before_destroy = true
prevent_destroy = true
ignore_changes = [tags["LastScanned"]]
}
}Key point: prevent_destroy on stateful resources (databases, buckets) is the answer to 'how do you stop an accidental delete?'.
Define the provider block twice, giving the second one an alias, then point specific resources or modules at the aliased configuration with the provider argument. The default block without an alias handles one region, and each aliased block handles another, so a single configuration can create resources in several regions or accounts at once.
This is how you build multi-region setups, replicate a bucket to a second region, or manage resources across accounts, all in one configuration.
provider "aws" {
region = "us-east-1"
}
provider "aws" {
alias = "west"
region = "us-west-2"
}
resource "aws_s3_bucket" "replica" {
provider = aws.west
bucket = "my-replica-bucket"
}terraform console opens an interactive prompt where you evaluate expressions against your current state, variables, and locals. You test a function, inspect a resource attribute, or check what an interpolation resolves to before committing it to a config, which turns a slow plan-fix-plan loop into instant feedback on your HCL expressions.
It's the fastest feedback loop for HCL: try merge(...) or cidrsubnet(...) with real values, confirm the shape of a data source, and avoid a plan-fix-plan cycle.
$ terraform console
> cidrsubnet("10.0.0.0/16", 8, 2)
"10.0.2.0/24"The dependency lock file pins the exact provider versions and their checksums that init selected for your configuration. Committing it means every engineer and every CI run resolves identical providers, so a plan on your machine matches the one in the pipeline, and the checksums also guard against a tampered provider download.
Without it, a new provider release could change behavior between runs. You update it deliberately with terraform init -upgrade and The diff, treating provider bumps like any other dependency change matters.
Key point: Framing the lock file as 'reproducible provider versions, like a package lock' shows you think about supply-chain stability.
Keep secrets out of .tf files and .tfvars in the repo. Feed them at runtime from environment variables or, better, pull them from a secret manager (Vault, AWS Secrets Manager, SSM Parameter Store) via a data source so Terraform reads the current value without storing it in code.
Then protect state, because any secret that flows through a resource lands in state as plaintext: encrypt the backend, lock down access, and avoid printing sensitive outputs. Secrets in code plus an unencrypted state is the classic finding.
data "aws_secretsmanager_secret_version" "db" {
secret_id = "prod/db/password"
}
resource "aws_db_instance" "main" {
password = data.aws_secretsmanager_secret_version.db.secret_string
}advanced rounds probe state architecture, drift, blast radius, and production scars. Expect every answer here to draw a follow-up.
Split state to shrink blast radius and speed up plans. One giant state means every plan reads and locks everything, and a mistake can touch unrelated systems. Break it along boundaries that change independently: per environment, per team, per service, or per layer (network, data, compute).
The trade-off is coupling: split states have to share values, which you wire with remote state data sources or a tool like Terragrunt. Too fine a split creates a web of cross-state dependencies; too coarse a split creates a slow, dangerous monolith. The judgment is picking seams that change together.
Key point: how you reason about blast radius versus coupling, not a single 'right' split is the technical point.
Watch a deeper explanation
Video: Complete Terraform Course: From Beginner to Pro (DevOps Directive, YouTube)
Use the terraform_remote_state data source: one configuration exposes values through outputs, and another reads them straight from the shared backend. The network stack outputs a VPC id and subnet ids, and the app stack reads them at plan time without hardcoding, so the two stay in sync even as the network stack changes.
The senior caveat: this couples the consumer to the producer's outputs and backend location, so treat those outputs as a stable contract. Some teams prefer publishing values to a parameter store instead, which decouples the two but adds a dependency to manage.
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "my-tf-state"
key = "prod/network.tfstate"
region = "us-east-1"
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_id
}Detect drift by running a plan on a schedule against production, using terraform plan -detailed-exitcode in CI so a non-zero exit means reality diverged from your code and can trigger an alert. The refresh at the start of every plan surfaces attributes that changed outside Terraform, like a manually resized instance or an edited security group.
Handling depends on intent. If the drift is unwanted, apply to reconcile back to code. If it's a legitimate external change, update the code to match, or add ignore_changes for attributes owned by another system. The failure mode is letting drift accumulate until a routine apply proposes surprising destroys.
terraform plan -detailed-exitcode
# exit 0 = no changes, 2 = drift detected, 1 = error
# wire this into a scheduled CI job to alert on driftKey point: detailed-exitcode is the detail that signals you've automated drift detection, not just described it.
The pattern is plan on pull request, apply on merge, with human approval between them for anything risky. CI runs fmt and validate, produces a saved plan the reviewer reads, and applies that exact saved plan after approval so what's reviewed is what's applied.
The guardrails that matter: a remote backend with locking so pipeline runs don't race, least-privilege credentials scoped per environment, no secrets in logs, and a required approval gate on production. Auto-apply without review is where blast-radius incidents come from.
A safe Terraform CI/CD pipeline
Applying the exact saved plan is the safeguard: it removes the gap between what was reviewed and what executes.
Lean on create_before_destroy so Terraform stands up the replacement before removing the old resource, and put both behind a stable indirection like a load balancer or DNS record that shifts traffic. For fleets, roll changes gradually rather than replacing everything at once.
The subtlety is that some resources can't be replaced in place cleanly (name collisions, unique constraints), so you plan the cutover: new resource up, traffic moved, old resource drained, then destroyed. Blindly changing a forces-new attribute is how a change becomes an outage.
resource "aws_launch_template" "web" {
# ...
lifecycle {
create_before_destroy = true
}
}First, stop all applies to prevent making it worse. If you have a backend with versioning (S3 versioning, Terraform Cloud history), restore the last good state, that's the fastest path and the reason versioning is non-negotiable on a state bucket.
If there's no backup, rebuild state by importing existing resources one by one to match your configuration, then plan until it's a clean no-op. It's tedious, which is exactly why the real answer is prevention: versioned, backed-up, access-controlled remote state.
Key point: the question needs to hear 'versioning and backups' first. Import-to-rebuild is the fallback, not the plan.
Terragrunt is a wrapper that reduces repetition across many Terraform states: it keeps backend config DRY, generates provider blocks, manages dependencies between stacks, and can run commands across multiple modules at once. It grew from the pain of managing dozens of environment-by-service states.
It isn't always needed. Native Terraform now covers some of what it did (better module support, cleaner backends), so smaller setups do fine without it. The honest coverage names the specific pain it removes rather than treating it as mandatory.
Layered: fmt and validate catch syntax and basic errors; static analysis (tflint, checkov, tfsec) catches misconfigurations and security issues; and the native terraform test framework (or Terratest in Go) applies real infrastructure in a throwaway account and asserts on the results.
The plan itself is a form of test in review. For modules, integration tests that actually create and destroy resources catch what static checks can't, at the cost of time and a sandbox account. Match the depth to the module's blast radius.
# tests/vpc.tftest.hcl
run "creates_vpc" {
command = plan
assert {
condition = aws_vpc.this.cidr_block == "10.0.0.0/16"
error_message = "unexpected CIDR"
}
}Policy as code: Sentinel (Terraform Cloud/Enterprise) or Open Policy Agent with conftest evaluate a plan against rules before apply, blocking things like public S3 buckets, untagged resources, or oversized instances. The check runs in the pipeline between plan and apply.
This shifts governance left, engineers get fast feedback instead of a security team review after the fact. The design point is failing the plan, not the apply, so nothing non-compliant ever reaches production.
Measure where the time goes first. The usual culprits are a huge single state (refresh has to poll every resource) and slow provider API calls. Splitting state into smaller, independent stacks is the biggest lever, since each plan then reads and refreshes less.
Other levers: -refresh=false when you trust state and just want a fast diff, -target to scope a run during debugging (not routine), -parallelism tuning, and cutting unnecessary data sources that hit APIs on every plan. Splitting state usually beats all the flags.
terraform plan -refresh=false # skip polling for a faster diff
terraform plan -target=module.web # scope to one module while debuggingcount addresses resources by numeric index, so removing the middle item shifts every later item's index down by one. Terraform reads those shifted addresses as different resources and plans to destroy and recreate all of them, an unintended and potentially destructive churn that can take down healthy infrastructure just because one earlier item was deleted.
The fix depends on timing. Going forward, use for_each with stable keys so removals touch only the removed item. For an existing count list you must change now, use terraform state mv or moved blocks to re-map indexes to their real resources so the plan stays a no-op.
| count | for_each | |
|---|---|---|
| Address by | Numeric index | Map or set key |
| Remove middle item | Re-indexes, recreates the rest | Removes only that key |
| Best for | Truly identical copies | Named, independent instances |
| Refactor safety | Needs state mv or moved | Stable across changes |
Key point: This scenario is the count-versus-for_each question made concrete. Naming moved blocks as the safe refactor path is The production-ready answer.
Treat it as a controlled migration, not a global bump. Read the provider's upgrade guide for breaking changes, update the version constraint in one non-production state first, run init -upgrade, and read the plan carefully, because major versions can rename attributes or change defaults.
Roll it out state by state with the lock file updated and reviewed each time, and keep a rollback path (the previous lock file) ready. Bumping every state at once is how a subtle attribute change becomes a fleet-wide surprise in the next plan.
Two common shapes: separate directories per environment sharing modules (dev, staging, prod each with their own state and tfvars), or a module registry that teams consume with pinned versions. Environments stay isolated so a change to dev can never accidentally touch prod state.
The principles that decide it: isolate state per environment, share logic through versioned modules not copy-paste, and keep the promotion path (dev to staging to prod) explicit. Workspaces can work for lightweight cases but blur environment boundaries when infrastructure genuinely differs.
Add or change the backend block, then run terraform init. Terraform detects the backend change and offers to copy existing state to the new location; you confirm, and it migrates the state file over. After migrating, verify with a plan that shows no changes.
Do it during a quiet window, back up the current state first, and make sure no one else applies mid-migration. The risk is losing or splitting state, so a backup and a clean post-migration plan are the safety checks.
# after editing the backend block:
terraform init -migrate-state
terraform plan # confirm: no changesClarify scope first (how many accounts, which environments, team boundaries), then layer it: a shared set of versioned modules for common patterns, per-account and per-environment configurations each with isolated remote state and locking, and provider assume-role setups so one pipeline can target the right account with least privilege.
Add the guardrails: policy as code between plan and apply, drift detection on a schedule, and a promotion flow from lower to higher environments. Close on operational edges, blast radius per state, who can apply to production, and how secrets flow from a manager rather than code. The structure, modules plus isolated states plus guarded pipelines, is what the question scores.
Key point: This is a system-design question wearing Terraform clothes. how you reason about isolation, reuse, and guardrails, not one perfect layout is the technical point.
Terraform wins when you want one declarative tool that spans many clouds and providers with a large module ecosystem. It trades the familiarity of a general-purpose language for a purpose-built configuration language and a strong state model. Where it competes: CloudFormation locks you to AWS but needs no separate state store, Pulumi lets you write infrastructure in real programming languages, and Ansible is procedural and shines at configuration management on existing servers rather than provisioning. Knowing these trade-offs out loud is itself an interview signal: it shows you pick tools on merits, not habit.
| Tool | Approach | Scope | Watch out for |
|---|---|---|---|
| Terraform | Declarative (HCL) | Multi-cloud, provider plugins | State management, no built-in rollback |
| CloudFormation | Declarative (YAML/JSON) | AWS only | AWS lock-in, verbose templates |
| Pulumi | Imperative (real languages) | Multi-cloud | Smaller ecosystem, language runtime needed |
| Ansible | Procedural (YAML playbooks) | Config management, some provisioning | No first-class state, weaker drift model |
Prepare in layers, and practice out loud. Most Terraform rounds move from concept questions to a live scenario (design a module, debug a broken plan) to a discussion of state and team workflow, so rehearse each stage rather than only reading answers.
The typical Terraform interview flow
Earlier rounds increasingly run as live technical interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These Terraform questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works