Skip to main content
DevOps interview guide · Updated November 6, 2026

Terraform interview questions and answers

45 Terraform interview questions with clear answers and short HCL examples: 37 core questions in 12 topics and 8 scenario questions of the kind DevOps, platform and cloud interviews use. Written for Terraform 1.x and the AWS provider 5.x, with AWS examples throughout and 25 practice tasks at the end.

By the OphyAI team. HCL samples were checked with terraform fmt and terraform validate on Terraform 1.11 and AWS provider 5.x.

Before you start

How Terraform interviews work

Terraform shows up in DevOps engineer, site reliability, platform and cloud engineer interviews, usually as a mix of quick concept questions, a short piece of code to write or review, and one or two scenarios. The deeper the role, the more the questions move from what a command does to what you would do when something goes wrong.

LevelWhat they askWhat a strong answer shows
Junior, or new to TerraformWhat Terraform is, init, plan and apply, resources vs data sources, variables and outputs.You can read a plan and say what each command does to state.
Mid-levelRemote state and locking, modules, count vs for_each, import, a basic CI pipeline.You have run Terraform with a team and fixed real mistakes.
Senior, staff or platformModule design, refactoring without downtime, environments, secrets, policy and scenario questions.You can change shared infrastructure safely and explain the trade-offs.

Where Terraform fits in a DevOps engineer interview

Most DevOps interview questions touch infrastructure as code somewhere, even in rounds that are not about Terraform by name.

RoundTypical questionsWhere Terraform shows up
Infrastructure as codeWrite or review a module, explain state, plan a refactorThe whole round
Cloud (AWS)VPC layout, IAM least privilege, S3 securityYou write or review the answer in Terraform
CI/CDPipeline design, approvals, rollbacksPlan on every pull request, apply on merge
ContainersImages, Kubernetes deployments, registriesProvisioning the cluster and the registry
Monitoring and reliabilityAlerts, logs, incident responseAlarms, log groups and retention as code
TroubleshootingDrift, failed deploys, outagesState recovery and safe rollbacks
Cheat sheet

Terraform commands to know cold

If an interviewer names a command, you should be able to say what it does to your infrastructure and to state.

CommandWhat it does
terraform initDownload providers and modules, set up the backend and write the lock file.
terraform fmt -checkFail if any file is not in the standard format.
terraform validateCheck syntax, types and references without calling cloud APIs.
terraform plan -out=tfplanShow the changes Terraform would make and save them.
terraform apply tfplanApply exactly the saved plan.
terraform destroyDelete everything the configuration manages.
terraform plan -refresh-onlyShow what changed outside Terraform (drift).
terraform state listList every resource address in state.
terraform outputPrint output values from state.
terraform workspace select NAMESwitch to another state for the same configuration.
terraform force-unlock LOCK_IDClear a stuck lock, once you know the other run is gone.
terraform testRun the .tftest.hcl tests (Terraform 1.6+).
terraform consoleTry expressions against your configuration and state.
terraform providers lockAdd provider checksums for more platforms to the lock file.
Questions 1-4

Basics and workflow: init, plan and apply

Nearly every Terraform interview opens here. Answer in plain words, then show you know what each command actually does.

Question 1

What is Terraform, and how is it different from Ansible?

Terraform is an infrastructure as code (IaC) tool. You describe the infrastructure you want in HashiCorp Configuration Language (HCL), and Terraform works out which API calls create, change or delete things to match. It is declarative: you write the end state, not the steps.

Ansible, Chef and Puppet are mainly configuration management tools. They install and configure software on servers that already exist. Terraform provisions the resources themselves, such as networks, databases, IAM roles and DNS records, across many providers, and it keeps a state file of what it created. Many teams use both: Terraform builds the servers, and an image or a configuration tool sets them up.

A strong answer also names the cost: Terraform depends on state, and state needs care. That sets up most of the follow-up questions.

Question 2

Walk me through terraform init, plan and apply.

These commands are the core workflow:

  • terraform init prepares the working folder. It downloads providers and modules, connects to the backend where state lives, and writes the dependency lock file .terraform.lock.hcl. Run it again after you add a provider, a module or a backend change.
  • terraform plan reads state, refreshes it against the real infrastructure, compares that with your configuration and prints the difference. + means create, ~ update in place, - destroy, and -/+ destroy and then create a replacement (+/- when create_before_destroy flips the order).
  • terraform apply makes the changes and updates state. Given a saved plan file, it applies exactly that plan, which is how pipelines make sure what was reviewed is what runs.
  • terraform destroy removes everything the configuration manages. It is the same as terraform apply -destroy.
The everyday workflowShell
terraform init                # download providers and modules, set up the backend
terraform fmt -check          # fail if any file is not formatted
terraform validate            # check syntax and internal consistency
terraform plan -out=tfplan    # preview the changes and save the plan
terraform apply tfplan        # apply exactly the plan you reviewed
Question 3

What is the difference between a resource and a data source?

A resource block is something Terraform owns: it creates it, tracks it in state, updates it and destroys it. A data block only reads something that already exists, such as the newest AMI, an existing VPC or the current AWS account ID, and makes its attributes available to the rest of the configuration. Destroying the configuration never deletes what a data source read.

main.tfHCL
# A data source reads something Terraform does not manage.
data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023.*-x86_64"]
  }
}

# A resource is something Terraform creates, updates and destroys.
resource "aws_instance" "web" {
  ami           = data.aws_ami.al2023.id
  instance_type = "t3.micro"
}
Question 4

How does Terraform decide the order to create resources?

Terraform builds a dependency graph from references. When one block refers to another, such as bucket = aws_s3_bucket.logs.id, that is an implicit dependency, so the bucket is created first and destroyed last. Resources with no path between them are created in parallel, up to 10 at a time by default (-parallelism=n changes that).

Use depends_on only for dependencies Terraform cannot see from references. The meta-arguments section below covers when.

main.tfHCL
resource "aws_s3_bucket" "logs" {
  bucket = "acme-logs-dev"
}

# This reference is an implicit dependency, so the bucket is created first.
resource "aws_s3_bucket_versioning" "logs" {
  bucket = aws_s3_bucket.logs.id

  versioning_configuration {
    status = "Enabled"
  }
}
Questions 5-8

State and backends: remote state, locking and drift

State questions separate people who have run Terraform on a team from people who have only run it alone.

Question 5

What is Terraform state, and why does it exist?

State, by default the terraform.tfstate file, maps each resource address in your configuration to a real object, such as aws_s3_bucket.logs to the bucket named acme-logs-dev, along with its attributes. Terraform needs it to know what it already manages, to work out the difference on each plan, to remember dependencies for deletes, and to avoid reading every object from the cloud on every run.

State often contains secrets in plain text, such as database passwords and private keys, even when values are marked sensitive. Treat it like a secret: encrypt it, limit who can read it and never commit it to Git.

Question 6

Why use a remote backend, and how does state locking work?

Local state on one laptop breaks down for a team: nobody else can see it, it is easy to lose, and two people can apply at once. A remote backend, such as S3, HCP Terraform, Azure Storage or Google Cloud Storage, keeps one shared copy, and locking makes sure only one run can write it at a time.

With locking on, a second terraform apply fails with an error that it could not acquire the state lock. Add -lock-timeout=5m to wait instead. Without locking, two applies can overwrite each other and leave state wrong.

On AWS, the S3 backend can lock with a lock file stored next to the state (use_lockfile = true, added as experimental in Terraform 1.10 and standard from 1.11). Older setups use a DynamoDB table, which is deprecated in recent versions but still very common, so know both. The AWS section below shows the backend block.

Question 7

What is drift, and how do you detect it?

Drift is when real infrastructure no longer matches what Terraform expects, usually because someone changed it in the console or another tool did. A normal terraform plan refreshes state first, so drift shows up as changes Terraform wants to make to put things back.

terraform plan -refresh-only shows only what changed outside Terraform, without proposing fixes, and terraform apply -refresh-only writes those changes into state without touching the infrastructure. The older terraform refresh command does the same as the second one and is deprecated. To catch drift early, run a scheduled plan with -detailed-exitcode in CI and alert when it exits with 2.

Finding driftShell
terraform plan -refresh-only         # show only what changed outside Terraform
terraform apply -refresh-only        # accept those changes into state, change nothing in AWS
terraform plan -detailed-exitcode    # exit code 0 = no changes, 2 = changes, 1 = error
Question 8

When would you use terraform state mv, state rm and state pull?

These commands read or edit state directly, so save a backup first with terraform state pull > backup.tfstate.

  • state list and state show inspect what Terraform tracks.
  • state mv changes the address of a resource in state, for a rename or a move into a module. A moved block is now the better way, because it goes through code review and works for everyone who uses the code.
  • state rm makes Terraform forget a resource without destroying it. In Terraform 1.7 and later, a removed block does the same through code review.
  • state pull prints the remote state, and state push overwrites it. Push is the risky one: Terraform refuses a state with an older serial or a different lineage unless you add -force.
Inspecting and repairing stateShell
terraform state list                         # every address Terraform tracks
terraform state show aws_s3_bucket.logs      # stored attributes of one resource
terraform state mv aws_s3_bucket.logs aws_s3_bucket.app_logs
terraform state rm aws_s3_bucket.legacy      # forget it, do not destroy it
terraform state pull > backup.tfstate        # save a copy of the remote state
Questions 9-11

Modules

Interviewers want to know you can design a module other teams can use, and upgrade it without breaking them.

Question 9

What is a module, and when should you write one?

Every folder of .tf files is a module. The folder where you run Terraform is the root module, and it can call child modules with a module block from a local path, a Git repository or a registry. A module has inputs (variables), outputs and resources, so it works like a function for infrastructure.

Write one when the same group of resources repeats, such as a service with a load balancer, a container service and alarms, and you want one tested interface for it. Avoid thin wrappers around a single resource: they add a layer without hiding any decisions.

main.tfHCL
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name            = "app"
  cidr            = "10.0.0.0/16"
  azs             = ["us-east-1a", "us-east-1b"]
  public_subnets  = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnets = ["10.0.11.0/24", "10.0.12.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = true
}

# Read the module's outputs like any other attribute.
resource "aws_security_group" "app" {
  name   = "app"
  vpc_id = module.vpc.vpc_id
}
Question 10

How do you pass values between modules, and between stacks?

Inside one configuration, the parent passes values in as module arguments and reads values out as module.<name>.<output>. A module only sees what you pass it.

Between separate stacks with their own state, the terraform_remote_state data source reads another state's outputs. It is simple, but it couples the stacks tightly and the reader needs access to the whole state snapshot. Many teams publish shared values to SSM Parameter Store instead, or look them up with data sources, such as finding a VPC by its tags.

main.tfHCL
data "terraform_remote_state" "network" {
  backend = "s3"

  config = {
    bucket = "acme-tf-state"
    key    = "network/terraform.tfstate"
    region = "us-east-1"
  }
}

resource "aws_security_group" "app" {
  name   = "app"
  vpc_id = data.terraform_remote_state.network.outputs.vpc_id
}
Question 11

How do you version and pin modules?

Registry modules take a version argument. ~> 5.1 allows any 5.x release from 5.1 up but never 6.0, and ~> 5.1.0 allows only 5.1.x patches. Git modules pin with ?ref= and a tag. Upgrade on purpose with terraform init -upgrade, then read the plan.

For your own modules, tag releases with semantic versions and keep a changelog. A breaking change to inputs is a new major version, and moved blocks inside the module help callers upgrade without replacing resources.

main.tfHCL
# Registry module: pin it with version.
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.1" # any 5.x from 5.1 up, never 6.0
}

# Git module: pin it to a tag with ref.
module "service" {
  source = "git::https://github.com/acme/terraform-modules.git//service?ref=v1.4.0"
}
Questions 12-14

Variables and outputs

Short questions, but easy to get slightly wrong. Precedence and sensitive values come up often.

Question 12

What variable types are there, and which value wins?

The types are string, number and bool, plus list(), set(), map(), object({...}) and tuple([...]). any skips the type check. Since Terraform 1.3, object types can mark attributes optional() and give them defaults.

When a value is set in several places, the later source in this list wins: the default, environment variables (TF_VAR_name), terraform.tfvars, terraform.tfvars.json, any *.auto.tfvars files in lexical order, then -var and -var-file flags in the order you give them.

Where variable values come fromShell
# From lowest to highest priority. A later source wins.
#   1. default in the variable block
#   2. environment variables, such as TF_VAR_environment=prod
#   3. terraform.tfvars, then terraform.tfvars.json
#   4. *.auto.tfvars and *.auto.tfvars.json, in lexical order
#   5. -var and -var-file on the command line, in the order given
terraform plan -var-file=prod.tfvars -var="environment=prod"
Question 13

What is the difference between variables, locals and outputs?

Variables are inputs: the caller sets them. Locals are named values inside a module, used to avoid repeating an expression such as a name prefix or a tag map, and nobody outside can set them. Outputs are what a module returns to its caller, and what the root module prints and stores for terraform_remote_state.

Give every output a description. Mark an output sensitive = true to hide it in plan and apply output. The value is still saved in state.

main.tfHCL
variable "project" {
  type = string
}

variable "environment" {
  type = string
}

locals {
  name_prefix = "${var.project}-${var.environment}"
  common_tags = {
    Project     = var.project
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_s3_bucket" "assets" {
  bucket = "${local.name_prefix}-assets"
  tags   = merge(local.common_tags, { Name = "${local.name_prefix}-assets" })
}

output "assets_bucket_arn" {
  description = "ARN of the assets bucket"
  value       = aws_s3_bucket.assets.arn
}
Question 14

How do you validate input variables?

Add a validation block with a condition and an error_message. Terraform checks it during plan, before it calls any cloud API, so a typo such as prdo fails fast. Use contains() for allowed values, can(regex(...)) for formats and simple comparisons for number ranges. Set nullable = false when null should never be accepted.

Since Terraform 1.9, a validation condition can also refer to other variables, not only the one being validated.

variables.tfHCL
variable "environment" {
  type        = string
  description = "Deployment environment"
  default     = "dev"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "The environment must be dev, staging or prod."
  }
}

variable "instance_types" {
  type = map(string)
  default = {
    dev  = "t3.micro"
    prod = "m5.large"
  }
}

variable "alarm" {
  type = object({
    enabled   = bool
    threshold = optional(number, 80) # optional() with a default: Terraform 1.3+
  })
  default = { enabled = false }
}
Questions 15-17

Providers and versions

Version pinning sounds dull until a provider upgrade replaces a database. Show you have thought about it.

Question 15

What is a provider, and how do you pin its version?

A provider is a plugin that talks to one API, such as AWS, Kubernetes, GitHub or Datadog. Declare it in required_providers with a source and a version constraint, then configure it with a provider block. required_version pins the Terraform CLI version itself.

~> 5.0 means any 5.x and never 6.0, the usual way to take fixes without surprise major upgrades. In the AWS provider, default_tags adds tags to every resource that supports them.

versions.tfHCL
terraform {
  required_version = ">= 1.5"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"

  default_tags {
    tags = {
      ManagedBy = "terraform"
    }
  }
}
Question 16

What is .terraform.lock.hcl, and should you commit it?

Yes, commit it. terraform init writes the exact provider versions it chose, and their checksums, to this dependency lock file. With it in Git, every teammate and every CI run uses the same provider builds, and a tampered download fails the checksum.

The lock file tracks providers only, not modules. To move to newer provider versions, run terraform init -upgrade and commit the new file. If your team uses different operating systems, record hashes for each platform with terraform providers lock.

Working with the lock fileShell
terraform init -upgrade        # move to the newest versions your constraints allow
terraform providers lock \
  -platform=linux_amd64 \
  -platform=darwin_arm64       # record hashes for Linux CI runners and Apple silicon Macs
Question 17

How do you deploy to two AWS regions or accounts from one configuration?

Declare extra provider configurations with alias, then pick one per resource with provider = aws.west, or pass one into a module with providers = { aws = aws.prod }. For another account, use assume_role in the provider block so Terraform runs as a role in that account.

AWS provider 6.0, released in June 2025, adds a region argument to most resources, so one provider block can manage several regions. In 5.x, aliases are the way to do it.

main.tfHCL
provider "aws" {
  region = "us-east-1"
}

provider "aws" {
  alias  = "west"
  region = "us-west-2"
}

provider "aws" {
  alias  = "prod"
  region = "us-east-1"

  assume_role {
    role_arn = "arn:aws:iam::111122223333:role/terraform-deploy"
  }
}

resource "aws_s3_bucket" "replica" {
  provider = aws.west
  bucket   = "acme-logs-replica"
}

module "prod_network" {
  source    = "./modules/network"
  providers = { aws = aws.prod }
}
Questions 18-21

Meta-arguments: count, for_each, depends_on and lifecycle

These are where small choices cause big plans. Expect at least one follow-up on count vs for_each.

Question 18

When do you use count, and when for_each?

count makes N copies addressed by number, such as aws_iam_user.dev[0]. If you remove an item from the middle of a list, every later index shifts, and Terraform plans to destroy and recreate those resources. for_each makes one copy per key of a map or set, such as aws_s3_bucket.data["raw"], so adding or removing one key touches only that resource.

Use for_each for collections of distinct things. Use count for identical copies, or as an on and off switch with count = var.enabled ? 1 : 0. The keys of a for_each must be known at plan time, and one block cannot use both.

main.tfHCL
variable "enable_alerts" {
  type    = bool
  default = false
}

# count: N copies addressed by number, such as aws_iam_user.dev[0]
resource "aws_iam_user" "dev" {
  count = 3
  name  = "dev-${count.index}"
}

# for_each: one copy per key, such as aws_s3_bucket.data["raw"]
resource "aws_s3_bucket" "data" {
  for_each = toset(["raw", "clean", "reports"])
  bucket   = "acme-${each.key}"
}

# count as an on and off switch
resource "aws_sns_topic" "alerts" {
  count = var.enable_alerts ? 1 : 0
  name  = "alerts"
}

output "alerts_topic_arn" {
  value = one(aws_sns_topic.alerts[*].arn) # null when alerts are off
}
Question 19

When do you need depends_on?

Only when one resource needs another but no argument refers to it, so Terraform cannot see the link. Common cases are a NAT gateway that needs the internet gateway attached first, or a Lambda function that needs an IAM policy attached before its first run.

Do not add it everywhere. Extra depends_on makes plans more conservative, for example by pushing a data source read to apply time, and it hides the real relationship. A reference is always better when one exists.

main.tfHCL
resource "aws_eip" "nat" {
  domain = "vpc"
}

resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public.id

  # The NAT gateway needs the internet gateway, but no argument refers to it.
  depends_on = [aws_internet_gateway.main]
}
Question 20

What can you do with the lifecycle block?

Five settings come up in interviews:

  • create_before_destroy: build the replacement first, then delete the old one. Use it for things that must not go down, and avoid name clashes, for example with name_prefix.
  • prevent_destroy: fail any plan that would destroy the resource. It does not help if someone deletes the resource block itself, so also turn on protection in the provider, such as deletion_protection on RDS.
  • ignore_changes: stop planning changes to attributes something else manages, such as an Auto Scaling group's desired capacity.
  • replace_triggered_by (Terraform 1.2+): replace this resource when another one changes.
  • precondition and postcondition (Terraform 1.2+): covered in the testing section.
main.tfHCL
resource "aws_db_instance" "orders" {
  identifier                  = "orders"
  engine                      = "postgres"
  instance_class              = "db.t3.micro"
  allocated_storage           = 20
  username                    = "app"
  manage_master_user_password = true
  deletion_protection         = true

  lifecycle {
    prevent_destroy = true # any plan that would destroy this fails
  }
}

resource "aws_autoscaling_group" "web" {
  name                = "web"
  min_size            = 2
  max_size            = 6
  desired_capacity    = 2
  vpc_zone_identifier = var.subnet_ids

  launch_template {
    id      = aws_launch_template.web.id
    version = aws_launch_template.web.latest_version
  }

  lifecycle {
    ignore_changes = [desired_capacity] # scaling policies change this at run time
  }
}

resource "aws_launch_template" "web" {
  name_prefix   = "web-"
  image_id      = var.ami_id
  instance_type = "t3.small"

  lifecycle {
    create_before_destroy = true # build the replacement before removing the old one
  }
}
Question 21

What is a dynamic block, and when should you avoid it?

A dynamic block generates repeated nested blocks, such as one ingress block per port, from a list or a map. Inside content, the iterator is named after the block, so you write ingress.value.

Use it sparingly, because it makes the code harder to read. For security groups, the AWS provider now recommends separate aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule resources, which you can drive with for_each instead.

main.tfHCL
variable "ingress_ports" {
  type    = list(number)
  default = [80, 443]
}

resource "aws_security_group" "web" {
  name   = "web"
  vpc_id = var.vpc_id

  dynamic "ingress" {
    for_each = var.ingress_ports
    content {
      from_port   = ingress.value
      to_port     = ingress.value
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
  }
}
Questions 22-24

Import and refactoring: import, moved and removed blocks

Real jobs are mostly existing infrastructure. These questions check that you can adopt and reshape it without downtime.

Question 22

How do you bring existing infrastructure under Terraform?

Since Terraform 1.5, write an import block with the resource address (to) and the cloud ID (id), plus a matching resource block, then plan. The plan shows the import and any difference between the real object and your code, and apply records it in state. Because it is code, the import goes through review like any other change.

To skip writing the resource block by hand, run terraform plan -generate-config-out=generated.tf and Terraform drafts it from the real object. Tidy what it writes. The older terraform import command still works, but it changes state right away and writes no configuration.

Terraform 1.7 added for_each to import blocks, so you can import many objects with one block.

imports.tfHCL
import {
  to = aws_s3_bucket.legacy
  id = "acme-legacy-reports"
}

resource "aws_s3_bucket" "legacy" {
  bucket = "acme-legacy-reports"
}
Let Terraform draft the resource blockShell
# Write only the import block, then:
terraform plan -generate-config-out=generated.tf
# Review and tidy generated.tf, then plan again until it shows no changes.
imports.tfHCL
# Terraform 1.7+: import many objects with one block.
import {
  for_each = toset(["acme-raw", "acme-clean"])
  to       = aws_s3_bucket.data[each.key]
  id       = each.key
}

resource "aws_s3_bucket" "data" {
  for_each = toset(["acme-raw", "acme-clean"])
  bucket   = each.key
}
Question 23

How do you rename a resource, or move it into a module, without destroying it?

Add a moved block (Terraform 1.1+) with the old address in from and the new one in to. The next plan shows the resource as moved instead of destroyed and created. It also handles switching from count to for_each and moving resources into or out of modules.

Keep moved blocks in shared modules for a while after a release, so everyone who calls the module gets the move on their next plan.

moved.tfHCL
# Renamed a resource
moved {
  from = aws_s3_bucket.logs
  to   = aws_s3_bucket.app_logs
}

# Switched from count to for_each
moved {
  from = aws_s3_bucket.data[0]
  to   = aws_s3_bucket.data["raw"]
}

# Moved a resource into a module
moved {
  from = aws_s3_bucket.assets
  to   = module.storage.aws_s3_bucket.assets
}
Question 24

How do you stop managing a resource without deleting it?

In Terraform 1.7 and later, delete the resource block and add a removed block with lifecycle { destroy = false }. Terraform drops the resource from state and leaves the real object alone. On older versions, terraform state rm does the same from the command line, without the review step.

This is also how you hand a resource to another team or another stack: remove it here, then import it there.

removed.tfHCL
# Terraform 1.7+: stop managing the bucket without deleting it.
removed {
  from = aws_s3_bucket.legacy

  lifecycle {
    destroy = false
  }
}
Questions 25-26

Workspaces and environments

There is no single right answer here, so explain the trade-off and say what you would choose.

Question 25

What are Terraform workspaces?

CLI workspaces give one configuration several separate state files in the same backend. terraform workspace new staging creates an empty state, and terraform.workspace in an expression returns the current name. With the S3 backend, non-default workspaces are stored under an env:/ prefix in the bucket.

They suit short-lived copies of the same stack, such as one per feature branch. HCP Terraform also uses the word workspace for something different: a full unit with its own state, variables and permissions.

CLI workspacesShell
terraform workspace new staging      # a new, empty state for this config
terraform workspace select staging
terraform workspace list
main.tfHCL
resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = terraform.workspace == "prod" ? "m5.large" : "t3.micro"
}
Question 26

Workspaces or separate folders for dev, staging and prod?

Most teams use a separate root module, and often a separate AWS account, for each long-lived environment, with shared modules for the real logic. Each environment then has its own backend key, its own credentials and its own pull requests, and nobody changes prod by forgetting which workspace is selected. HashiCorp's docs also say CLI workspaces are not a good fit when environments need separate credentials and access controls.

Keep differences in variables, not copied code: a .tfvars file per environment, maps keyed by environment with lookup(), and count for features only some environments need. Wrappers such as Terragrunt help when you have many environments and regions.

One root module per environmentText
infra/
  modules/
    network/
    service/
  envs/
    dev/        # calls the modules; backend key dev/terraform.tfstate
    staging/
    prod/       # its own backend key and its own AWS account or role
Questions 27-29

Testing and validation: validate, terraform test, conditions and checks

Newer features live here, so this is where you can stand out. Name the version when you mention one.

Question 27

What is the difference between terraform fmt, validate and plan?

terraform fmt rewrites files into the standard style, and -check makes it fail instead, which is what CI runs. terraform validate checks syntax, types and references inside the configuration without calling any cloud API. It needs an initialized folder, so CI runs terraform init -backend=false first. terraform plan is the first step that talks to the provider APIs and reads state.

Add a linter such as TFLint for provider-specific mistakes, like an instance type that does not exist, and a security scanner such as Checkov or Trivy (which took over the checks from tfsec) for things like public buckets.

Checks to run on every pull requestShell
terraform fmt -check -recursive
terraform init -backend=false -input=false
terraform validate
tflint
trivy config .
Question 28

How does terraform test work?

Terraform 1.6 added a built-in test framework. Test files end in .tftest.hcl and hold run blocks. Each run does a plan or an apply and then checks its assert blocks, each with a condition and an error_message. Runs that apply create real infrastructure, and Terraform destroys it at the end of the test file.

Use command = plan for quick checks of names, tags and counts. Since Terraform 1.7, mock providers let you test module logic without cloud credentials. Keep a few apply tests for what only real infrastructure can prove.

tests/logs_bucket.tftest.hclHCL
# Terraform 1.6+. Run with: terraform test
variables {
  environment = "dev"
}

run "bucket_name_includes_environment" {
  command = plan

  assert {
    condition     = aws_s3_bucket.logs.bucket == "acme-logs-dev"
    error_message = "The bucket name must include the environment."
  }
}

run "versioning_is_on" {
  command = plan

  assert {
    condition     = aws_s3_bucket_versioning.logs.versioning_configuration[0].status == "Enabled"
    error_message = "Versioning must be enabled."
  }
}
Question 29

What are preconditions, postconditions and check blocks?

Preconditions and postconditions (Terraform 1.2+) live in the lifecycle block of a resource or data source, and outputs can have preconditions too. A precondition checks an assumption before Terraform acts, such as the AMI being x86_64. A postcondition checks the result, using self. If either fails, the run stops with your error message.

A check block (Terraform 1.5+) is a softer, ongoing assertion. It runs on every plan and apply and reports a warning when it fails, without blocking anything. It can hold its own data source, so it suits checks such as "does the health endpoint return 200?" after a change.

main.tfHCL
resource "aws_instance" "web" {
  ami           = data.aws_ami.al2023.id
  instance_type = var.instance_type

  lifecycle {
    precondition {
      condition     = data.aws_ami.al2023.architecture == "x86_64"
      error_message = "The AMI must be x86_64."
    }

    postcondition {
      condition     = self.public_dns != ""
      error_message = "The instance must get a public DNS name."
    }
  }
}

# Uses the hashicorp/http provider. A failed check is a warning, not an error.
check "health" {
  data "http" "app" {
    url = "https://${aws_lb.web.dns_name}/health"
  }

  assert {
    condition     = data.http.app.status_code == 200
    error_message = "The health check did not return 200."
  }
}
Questions 30-31

CI/CD

For DevOps engineer interviews, this is often the main event. Talk about review, credentials and safety, not just commands.

Question 30

How would you run Terraform in a CI/CD pipeline?

On every pull request: fmt -check, init, validate, a linter and a security scan, then plan -out=tfplan, with the plan posted to the pull request for review. On merge to main: apply that saved plan, or plan again and apply after an approval step. Run one pipeline per state at a time, and pass -input=false so a missing variable fails instead of waiting for input.

A saved plan is safe to apply because Terraform refuses it if the state changed after the plan was made. Plan files can hold sensitive values, so keep them as short-lived, access-controlled artifacts. Policy tools read the plan as JSON from terraform show -json tfplan.

.github/workflows/terraform.ymlYAML
name: terraform
on:
  pull_request:
  push:
    branches: [main]

permissions:
  id-token: write # lets the job ask GitHub for an OIDC token
  contents: read

jobs:
  terraform:
    runs-on: ubuntu-latest
    concurrency: terraform-app # one run per state at a time
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/github-deploy
          aws-region: us-east-1
      - run: terraform fmt -check -recursive
      - run: terraform init -input=false
      - run: terraform validate
      - run: terraform plan -input=false -out=tfplan
      - if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        run: terraform apply -input=false tfplan
Question 31

How do you give CI access to AWS without long-lived keys?

Use OpenID Connect (OIDC). The CI system, such as GitHub Actions, issues a short-lived signed token that says which repository and branch is running. AWS trusts that identity provider and hands out temporary credentials for one role, but only when the token's audience and subject match your conditions.

Pin the subject to exactly one repository and branch, with no wildcards, and give the role only the permissions the stack needs. Many teams add a read-only role for pull request plans, whose subject is repo:owner/name:pull_request, and keep the apply role for main.

main.tfHCL
variable "github_repo" {
  type        = string
  description = "owner/name of the repository allowed to deploy"
}

resource "aws_iam_openid_connect_provider" "github" {
  url            = "https://token.actions.githubusercontent.com"
  client_id_list = ["sts.amazonaws.com"]
}

data "aws_iam_policy_document" "github_trust" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }

    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }

    # Only the main branch of one repository. No wildcards.
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:${var.github_repo}:ref:refs/heads/main"]
    }
  }
}

resource "aws_iam_role" "deploy" {
  name               = "github-deploy"
  assume_role_policy = data.aws_iam_policy_document.github_trust.json
}
Questions 32-33

Secrets and security

Interviewers listen for one thing above all: do you know that state holds secrets?

Question 32

How do you keep secrets out of Terraform code and state?

Never put secrets in .tf files or committed .tfvars files. Pass them at run time, for example from your CI secret store as TF_VAR_ environment variables, or better, let the cloud service own them. With manage_master_user_password = true, RDS creates the database password in Secrets Manager itself, so Terraform never sees it.

Know what sensitive = true does and does not do: it hides a value in plan and apply output, but the value is still saved in state. Terraform 1.10 added ephemeral values, such as ephemeral variables and ephemeral resources, that are never saved in state or plan files, and 1.11 added write-only arguments, so a provider can receive a secret without storing it. Check that your provider version supports the argument you need.

main.tfHCL
variable "api_token" {
  type      = string
  sensitive = true # hidden in plan and apply output, but still saved in state
}

# Let RDS create and keep the password in Secrets Manager,
# so it never appears in your code, your state or your outputs.
resource "aws_db_instance" "orders" {
  identifier                  = "orders"
  engine                      = "postgres"
  instance_class              = "db.t3.micro"
  allocated_storage           = 20
  username                    = "app"
  manage_master_user_password = true
}

output "db_secret_arn" {
  description = "Where applications read the database credentials"
  value       = aws_db_instance.orders.master_user_secret[0].secret_arn
}
Question 33

How do you secure state, and enforce rules across teams?

Keep state in a private, versioned, encrypted bucket with public access blocked, and let only the CI roles and a few admins read it. Use one state per environment so a mistake cannot reach prod, and log access to the bucket with CloudTrail.

For rules such as "no public buckets" or "every resource has a cost tag", add policy as code: Sentinel or OPA policies in HCP Terraform, OPA with Conftest on the plan JSON, or Checkov and Trivy in CI. Back them up with guardrails in AWS itself, such as account-level S3 Block Public Access and service control policies.

state-bucket.tfHCL
resource "aws_s3_bucket" "state" {
  bucket = "acme-tf-state"
}

resource "aws_s3_bucket_versioning" "state" {
  bucket = aws_s3_bucket.state.id

  versioning_configuration {
    status = "Enabled" # lets you roll back a bad state write
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
  bucket = aws_s3_bucket.state.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "aws:kms"
    }
  }
}

resource "aws_s3_bucket_public_access_block" "state" {
  bucket                  = aws_s3_bucket.state.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}
Questions 34-37

AWS specifics: S3 backend, IAM and VPC

Most Terraform jobs in the US run on AWS. These Terraform AWS examples are the ones interviewers ask you to write or review.

Question 34

Show an S3 backend with state locking.

Set the bucket, key and region, turn on encrypt, and add a lock. In Terraform 1.11 and later, use_lockfile = true stores a .tflock object next to the state, so no DynamoDB table is needed. Older setups use dynamodb_table, which is deprecated in recent versions.

Backend blocks cannot use variables, because the backend is set up before variables are read. Keep the values literal, or leave some out and pass them with terraform init -backend-config=prod.hcl (partial configuration). Turn on versioning for the state bucket so you can roll back.

backend.tfHCL
terraform {
  required_version = ">= 1.11"

  backend "s3" {
    bucket       = "acme-tf-state"
    key          = "app/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    use_lockfile = true # lock with a .tflock object next to the state
  }
}
backend.tf (older setups)HCL
terraform {
  backend "s3" {
    bucket         = "acme-tf-state"
    key            = "app/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "acme-tf-locks" # partition key LockID, type String
  }
}
Question 35

How do you give an EC2 instance AWS permissions?

Never put access keys on the instance. Create an IAM role whose trust policy lets ec2.amazonaws.com assume it, attach a least-privilege policy, wrap the role in an instance profile and set iam_instance_profile on the instance. The instance then gets short-lived credentials from the metadata service, which should require IMDSv2.

A common least-privilege trap: s3:ListBucket applies to the bucket ARN, while s3:GetObject applies to the objects, arn:aws:s3:::bucket/*. Put each action on the right resource, and do not use *. Build policies with the aws_iam_policy_document data source rather than hand-written JSON.

iam.tfHCL
data "aws_iam_policy_document" "ec2_trust" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "app" {
  name               = "app-instance"
  assume_role_policy = data.aws_iam_policy_document.ec2_trust.json
}

data "aws_iam_policy_document" "read_reports" {
  statement {
    actions   = ["s3:ListBucket"]
    resources = ["arn:aws:s3:::acme-reports"] # the bucket itself
  }

  statement {
    actions   = ["s3:GetObject"]
    resources = ["arn:aws:s3:::acme-reports/*"] # the objects in it
  }
}

resource "aws_iam_role_policy" "read_reports" {
  name   = "read-reports"
  role   = aws_iam_role.app.id
  policy = data.aws_iam_policy_document.read_reports.json
}

resource "aws_iam_instance_profile" "app" {
  name = "app-instance"
  role = aws_iam_role.app.name
}

resource "aws_instance" "app" {
  ami                  = var.ami_id
  instance_type        = "t3.micro"
  iam_instance_profile = aws_iam_instance_profile.app.name

  metadata_options {
    http_tokens = "required" # IMDSv2 only
  }
}
Question 36

How do you build a basic AWS VPC in Terraform?

Create a VPC with a range such as 10.0.0.0/16, then public and private subnets in at least two Availability Zones. Take zone names from the aws_availability_zones data source and carve subnet ranges with cidrsubnet() so none overlap.

Public subnets get a route table whose default route (0.0.0.0/0) points at an internet gateway. Private subnets reach the internet, for updates and API calls, through a NAT gateway that sits in a public subnet, and nothing on the internet can open a connection to them. One NAT gateway is cheaper. One per zone keeps traffic flowing when a zone fails.

network.tfHCL
data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  tags                 = { Name = "main" }
}

resource "aws_subnet" "public" {
  count                   = 2
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index) # 10.0.0.0/24, 10.0.1.0/24
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = true
}

resource "aws_subnet" "private" {
  count             = 2
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index + 10) # 10.0.10.0/24, 10.0.11.0/24
  availability_zone = data.aws_availability_zones.available.names[count.index]
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }
}

resource "aws_route_table_association" "public" {
  count          = 2
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}
Question 37

What changed in recent AWS provider versions that interviewers ask about?

Three changes explain most of the AWS code you will read:

  • Provider 4.0 split S3 bucket settings into their own resources, such as aws_s3_bucket_versioning, aws_s3_bucket_server_side_encryption_configuration, aws_s3_bucket_lifecycle_configuration and aws_s3_bucket_public_access_block. The old inline blocks on aws_s3_bucket are deprecated.
  • Provider 5.0 deprecated vpc = true on aws_eip in favor of domain = "vpc". Security group rules are now best written as aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule resources, one per rule.
  • Provider 6.0 (June 2025) adds a region argument to most resources. Check which major version a codebase uses before you answer, and read the upgrade guide before any major bump.
main.tfHCL
resource "aws_eip" "nat" {
  domain = "vpc" # provider 5.x: replaces the deprecated vpc = true
}

resource "aws_security_group" "web" {
  name   = "web"
  vpc_id = var.vpc_id
}

# One resource per rule, instead of inline ingress and egress blocks
resource "aws_vpc_security_group_ingress_rule" "https" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 443
  to_port           = 443
  ip_protocol       = "tcp"
}

resource "aws_vpc_security_group_egress_rule" "all" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1" # all protocols and ports
}
Questions 38-45

Terraform scenario based interview questions

In a scenario question, the interviewer describes a situation and asks what you would do. There is rarely one right answer. They listen for order and judgment: look before you act, protect production, fix the cause in code, then prevent the next one.

Question 38 · Scenario 1

The state file is corrupted, or someone deleted it. What do you do?

What a strong answer covers, in order:

  1. Stop all applies first, so nothing else writes to the broken state. Pause the pipeline.
  2. If the bucket has versioning, find the last good version, download it, read it and restore it. Terraform needs -force to push a state with an older serial.
  3. Run terraform plan. It should show no changes, or only changes made after that version, which you then review one by one.
  4. If no good copy exists, rebuild state with import blocks, starting with resources that would be dangerous to recreate, such as databases. Plan until it shows no changes.
  5. Afterward, make sure the bucket has versioning, locking, tight write access and an alert on deletes.
Roll back to an earlier state versionShell
# Bucket versioning must already be on.
aws s3api list-object-versions --bucket acme-tf-state --prefix app/terraform.tfstate

aws s3api get-object --bucket acme-tf-state --key app/terraform.tfstate \
  --version-id <version-id> restored.tfstate

# Read restored.tfstate first. -force is needed because it has an older serial.
terraform state push -force restored.tfstate
terraform plan    # expect no changes, or only changes you can explain
Question 39 · Scenario 2

Someone changed a security group by hand in the console. How do you handle it?

What a strong answer covers, in order:

  1. Run terraform plan -refresh-only to see exactly what changed, then find out who changed it and why. A manual fix during an incident may have been the right call.
  2. If the change was wrong, run a normal apply and Terraform puts the rules back the way the code says.
  3. If the change was right, update the code to match and apply, so the plan is clean again. Do not leave it as drift.
  4. Prevent the next one: remove console write access for those resources, alert on changes with CloudTrail or AWS Config, and run a scheduled drift plan.
  5. Use ignore_changes only for attributes another system is meant to own, never to hide drift.
Question 40 · Scenario 3

The plan wants to destroy and recreate the production database. What now?

What a strong answer covers, in order:

  1. Do not apply. Find the attribute marked # forces replacement in the plan, such as the identifier, the engine or the encryption setting.
  2. If a rename caused it, the address changed, not the database. Add a moved block and plan again.
  3. If an attribute really changed, revert it, or find a way to make the change in place. Some changes, such as encrypting an unencrypted database, need a planned migration from a snapshot.
  4. Take a fresh snapshot before any change you are unsure about.
  5. Protect it for next time: prevent_destroy in lifecycle, deletion_protection = true on the database, and a CI rule that blocks any plan that destroys a database.
Question 41 · Scenario 4

A CI job was cancelled, and now every run fails on the state lock.

What a strong answer covers, in order:

  1. Read the lock error. It shows the lock ID, who holds it and when it was taken.
  2. Make sure that run is really gone: check the CI system and ask the team. Unlocking while a run is still applying can corrupt state.
  3. Then run terraform force-unlock with the lock ID. With S3 lock files the lock is a .tflock object, and with DynamoDB it is an item in the table. Use the command rather than deleting either by hand.
  4. Run a plan to see whether the cancelled run left half-made changes.
Clearing a stuck lockShell
# Only after you are sure no other run is still going:
terraform force-unlock <LOCK_ID>    # the ID is printed in the lock error
Question 42 · Scenario 5

One huge state takes 15 minutes to plan. How do you split it?

What a strong answer covers, in order:

  1. Pick the seams. The network, shared data stores and each service usually become separate stacks with their own state, linked by outputs or data source lookups.
  2. Within one state, use moved blocks for renames and moves into modules.
  3. Across states, use a removed block with destroy = false in the old stack and an import block in the new one, for the same real objects. In one change window, apply the import in the new stack, then the removal in the old one.
  4. Both plans should show no creates or destroys for the moved resources. Move one group at a time, and keep a state backup.
old-stack/removed.tfHCL
# Old stack: delete the resource block and stop managing the bucket, but keep it.
removed {
  from = aws_s3_bucket.reports

  lifecycle {
    destroy = false
  }
}
new-stack/reports.tfHCL
# New stack: adopt the same bucket.
import {
  to = aws_s3_bucket.reports
  id = "acme-reports"
}

resource "aws_s3_bucket" "reports" {
  bucket = "acme-reports"
}
Question 43 · Scenario 6

An apply failed halfway through. What state are you in?

What a strong answer covers, in order:

  1. Terraform saves state for everything it finished, so the resources it created are tracked. Nothing is rolled back for you.
  2. Read the error, fix the cause, such as a missing permission, a quota or a bad value, and plan again. Terraform carries on from where it stopped.
  3. If a resource was created but a provisioner failed, Terraform marks it tainted, and the next apply replaces it.
  4. If an API call timed out, the object may exist in AWS but not in state, and the next apply fails because it already exists. Import it instead of deleting it.
Question 44 · Scenario 7

You need to move from AWS provider 5.x to 6.x. How do you plan it?

What a strong answer covers, in order:

  1. First move to the latest 5.x and fix every deprecation warning there, since most removals in a major version were deprecated in the one before.
  2. Read the version 6 upgrade guide and search your code for the resources it lists.
  3. Change the constraint to ~> 6.0 on a branch, run terraform init -upgrade and plan every environment.
  4. Aim for a plan with no changes. Read any change line by line before you apply, starting in dev.
  5. Commit the updated .terraform.lock.hcl and move one stack at a time. Shared modules may need a new major version too.
Question 45 · Scenario 8

A stack still keeps its state on one engineer's laptop. How do you move it to S3?

What a strong answer covers, in order:

  1. Create the state bucket first, with versioning, encryption and public access blocked, ideally from a small separate stack.
  2. Add a backend "s3" block to the terraform block, with literal values.
  3. Run terraform init -migrate-state and confirm. Terraform copies the local state into the bucket.
  4. Plan from a clean checkout on another machine to prove the remote state works. Then delete the local state files and make sure *.tfstate is in .gitignore.
Move local state into the S3 backendShell
# 1. Add the backend "s3" block to the terraform block.
# 2. Move the existing state into it:
terraform init -migrate-state
# 3. Confirm, then check that nothing changed:
terraform plan
OphyAI Coding Practice

Practice with 25 Terraform tasks

Reading answers is not the same as writing HCL under time pressure. Coding Practice has 25 Terraform tasks, from Easy to Hard, built around the same AWS patterns as the questions above.

Written requirements

Each task is a short brief with numbered requirements and a starter main.tf for the AWS provider 5.x.

Checked against the brief

Your code is checked against the task's written requirements. It is not run with terraform plan or apply, and nothing is created in AWS, so you need no cloud account.

Hints and an AI coach

Ask for hints one at a time. The AI coach checks your plan and reviews your code.

Easy tasks (7)

TaskAreaWhat it practices
A private S3 bucket for app logsStorageA name and tag built from a variable, versioning, all four public access blocks and an ARN output, in the split-resource style.
Outputs for a generated API keyVariables, outputs and localsA random key stored as an SSM SecureString, outputs with descriptions, and one output marked sensitive.
Input variables that reject bad valuesVariables, outputs and localsValidation blocks for an allowed list, a regex on a project name and a set of supported retention days.
One naming scheme and one tag mapVariables, outputs and localsLocals for a name prefix and shared tags, merged into the tags of a bucket and a queue.
A security group for a web serverNetworkingHTTPS from anywhere, SSH only from an office range, written as separate ingress and egress rule resources.
An EC2 instance on the newest Amazon Linux imageComputeAn AMI data source instead of a pasted ID, user data that starts nginx, tags and IMDSv2.
AWS access for an EC2 instance without keysIAM and securityA role EC2 can assume, a managed policy attachment and an instance profile.

Medium tasks (16)

TaskAreaWhat it practices
A different instance size per environmentVariables, outputs and localsA map of sizes keyed by environment and lookup() with a safe fallback.
An alerts topic that can be turned offVariables, outputs and localscount as an on and off switch, and an output that returns the ARN or null without an error.
Several buckets from one mapModules and reusefor_each over a map of objects, versioning only where asked, per-bucket expiry and a map of ARNs as output.
Using a community VPC moduleModules and reuseA pinned registry module version, a single shared NAT gateway, and wiring module outputs into other resources.
Shared, locked and encrypted remote stateModules and reuseAn S3 backend with locking and encryption, literal backend values, required_version and terraform_remote_state.
Bucket encryption with your own KMS keyStorageA customer managed key with rotation, an alias, default SSE-KMS encryption and S3 Bucket Keys.
Lifecycle rules for aging logsStorageA prefix filter, a move to Infrequent Access, expiry after a year and cleanup of stalled multipart uploads.
A DynamoDB table for ordersStoragePartition and sort keys, on-demand billing, a global secondary index, TTL and point-in-time recovery.
A VPC with public and private subnetsNetworkingZones from a data source, cidrsubnet() for ranges that never overlap, and lists of subnet IDs as outputs.
Internet access for public subnetsNetworkingAn internet gateway, a public route table with a default route, and subnet associations.
Outbound internet for private subnetsNetworkingAn Elastic IP in the provider 5.x style, a NAT gateway with depends_on and a private route table.
A web tier that scales and heals itselfComputeA launch template, an Auto Scaling group with capacity limits, and a rolling instance refresh.
A Lambda function with logs you controlComputeAn execution role, a managed log group with 14-day retention, a code hash for redeploys and creation order.
A container registry that cleans up after itselfComputeImmutable tags, scanning on push and a lifecycle policy that keeps only recent images.
A database password nobody types or seesIAM and securityA generated password stored in Secrets Manager, used by RDS Postgres and never printed.
Read access to one bucket and nothing moreIAM and securityA policy document with ListBucket on the bucket and GetObject on its objects, with no wildcards.

Hard tasks (2)

TaskAreaWhat it practices
An HTTPS load balancer with a redirectNetworkingAn ALB, a health-checked target group, a TLS 1.2+ listener using a certificate variable and a permanent HTTP redirect.
A CI deploy role without stored keysIAM and securityA GitHub OIDC provider and a role that trusts only one repository's main branch, with no wildcard subjects.

What it costs and where it runs

The full library and AI coach come with Pro and Premium with no credits per problem. A mock coding round uses 20 credits. Free accounts get 4 sample problems.

Runs in the browser at app.ophyai.com; the desktop app links to it. Not in the phone apps.

Terraform is a trademark of HashiCorp. OphyAI is not affiliated with or endorsed by HashiCorp. Questions, answers and tasks are written by OphyAI. Also preparing for the coding round? See the 18 coding interview patterns.

FAQ

Terraform interview FAQ

What are the most common Terraform interview questions?

Most interviews cover the same core: what Terraform is and how it differs from configuration tools, the init, plan and apply workflow, what state is and why remote state needs locking, count vs for_each, modules and version pinning, variables and outputs, and how to handle drift. Senior and DevOps interviews add CI/CD, secrets, import and refactoring with moved blocks, testing, and scenario questions about broken state or risky plans.

What are Terraform scenario based interview questions?

Scenario questions describe a situation and ask what you would do: the state file is corrupted, someone changed a resource by hand, the plan wants to replace a production database, a lock is stuck after a cancelled job, or a huge state needs splitting. Interviewers want a calm, ordered answer: stop and look first, protect production, fix the cause with code, and prevent it from happening again.

How should I prepare for a Terraform interview?

Learn the core commands and what each one does to state. Write small stacks by hand: an S3 bucket with the split resources, a VPC with public and private subnets, an IAM role, a remote backend. Then practice explaining trade-offs out loud, such as count vs for_each or workspaces vs separate folders. Finish with scenario questions, since they decide most senior interviews.

Which Terraform version should I study?

Study Terraform 1.x and know which features are recent: moved blocks (1.1), preconditions and postconditions (1.2), import blocks and check blocks (1.5), terraform test (1.6), removed blocks and test mocks (1.7), ephemeral values (1.10), and write-only arguments plus S3 lock files without DynamoDB (1.11). For AWS, most production code still uses provider 5.x, and 6.x adds a region argument to most resources. Check developer.hashicorp.com for the latest release.

What is the difference between Terraform and CloudFormation?

CloudFormation is the AWS infrastructure as code service: it covers AWS only, uses JSON or YAML templates, and AWS stores the state of each stack for you. Terraform works with many providers, including AWS, other clouds and SaaS tools, uses HCL, and you manage the state yourself, usually in S3. Teams that run on more than AWS, or want one tool for cloud, DNS and monitoring, often pick Terraform.

What is OpenTofu, and will interviewers ask about it?

In August 2023, HashiCorp moved Terraform to the Business Source License. OpenTofu is a community fork managed under the Linux Foundation that stays under the open source MPL 2.0 license. It forked from the last open source Terraform code in 2023 and works with most Terraform configurations, though newer features differ between the two. Some interviewers ask about it, mostly to see whether you follow the ecosystem.

Do I need a Terraform certification?

Not usually. HashiCorp offers a Terraform Associate certification, and it can help early in a career or when you are moving into DevOps from another role. Most interviewers care more about whether you can explain state, write a clean module and handle the scenarios on this page.

Can I practice Terraform without an AWS account?

Yes. In OphyAI Coding Practice, the 25 Terraform tasks use the AWS provider 5.x and are checked against each task's written requirements, so you do not need an AWS account and nothing is created in the cloud. On your own machine, terraform fmt and terraform validate also work without cloud credentials after terraform init -backend=false.

What does OphyAI Coding Practice include for Terraform?

Coding Practice has 25 Terraform tasks in Easy, Medium and Hard, covering variables and outputs, storage, networking, compute, IAM and modules, next to 153 algorithm problems in Python and JavaScript. You get hints one at a time and an AI coach that checks your plan and reviews your code. The full library comes with Pro and Premium with no credits per problem, and free accounts get 4 sample problems.

Is OphyAI affiliated with HashiCorp?

No. Terraform is a trademark of HashiCorp, and OphyAI is not affiliated with or endorsed by HashiCorp. The questions, answers and practice tasks here are written by OphyAI.

Know the answers. Then write the HCL.

Work through 25 Terraform tasks with hints one at a time and an AI coach that reviews your code. No AWS account needed.