Terraform interviews test your understanding of state management and the plan/apply workflow that makes infrastructure as code safe to run repeatedly.
Terraform is HashiCorp's open-source IaC tool that lets you define infrastructure — VPCs, EC2 instances, databases, IAM roles, whatever — as declarative configuration files written in HCL, rather than clicking through a console or running one-off scripts. The core idea behind infrastructure as code is that your infrastructure becomes versioned, reviewable, and repeatable, just like application code: you can diff changes in a pull request, roll back to a previous commit, and spin up an identical environment for staging or disaster recovery without relying on tribal knowledge. Terraform specifically is declarative, meaning you describe the desired end state and it figures out how to get there, and it's cloud-agnostic in that the same workflow works across AWS, Azure, GCP, and hundreds of other providers. That consistency is a big part of why it's become the de facto standard for provisioning cloud infrastructure.
The state file, terraform.tfstate, is a JSON document that maps every resource in your configuration to the real-world object Terraform created for it, tracking things like resource IDs, attributes, and metadata. It's critical because Terraform is declarative — without state, it would have no way to know that the aws_instance block in your config corresponds to a specific instance ID in AWS, or to compute a diff between what exists and what you've declared. On every plan or apply, Terraform reads the state, compares it against your configuration and the real infrastructure, and calculates the minimal set of changes needed. Lose the state file or let it get out of sync, and Terraform effectively goes blind, which is why teams treat it as sensitive, versioned infrastructure in its own right rather than a throwaway artifact.
Hand-editing tfstate is risky because the file's structure is an internal implementation detail, not a stable API, and a typo or malformed JSON can corrupt it so badly that Terraform refuses to run at all. Even a syntactically valid edit can desynchronize state from reality — say you remove a resource entry thinking it'll leave the infrastructure alone, but now Terraform has no record of that resource and will try to create a duplicate on the next apply, potentially causing naming collisions or orphaned billing resources. It also undermines state locking guarantees if you're editing a remote state file directly while someone else is mid-apply, which can lead to two processes clobbering each other's changes. For any legitimate need to fix or move state, Terraform gives you safe, purpose-built commands like `terraform state mv`, `terraform state rm`, or `terraform import` instead of touching the raw file.
Remote state means storing tfstate in a shared backend, like an S3 bucket, instead of a local file on one engineer's laptop, so the whole team and any CI pipeline are always reading and writing the same source of truth. On its own, though, a shared file has a race condition problem: if two people run apply at the same time, they can both read the same state, make conflicting changes, and corrupt it. That's what DynamoDB solves in the classic AWS setup — it provides a locking mechanism where Terraform writes a lock entry before an apply and releases it after, so a second run has to wait rather than stomp on the first. S3 also gives you versioning and encryption at rest for free, which matters since state can contain sensitive values like database passwords in plaintext. Newer Terraform versions have started supporting S3-native locking without a separate DynamoDB table, but the S3-plus-DynamoDB pattern is still what most existing production setups use.
A provider is a plugin that translates the resource blocks in your HCL into actual API calls against a specific platform, so the aws provider knows how to call the EC2, S3, and IAM APIs, while the google or azurerm providers do the equivalent for their respective clouds. When you run `terraform init`, Terraform downloads the provider binaries your configuration references, and they act as the translation layer between Terraform's generic resource graph and each service's specific SDK or REST API. Under the hood a provider is really just a gRPC server that Terraform core talks to, implementing a standard interface for create, read, update, and delete operations plus configuration validation. This plugin architecture is also why Terraform can support hundreds of providers, including community and internal ones, without HashiCorp having to build cloud-specific logic into Terraform core itself.
`terraform plan` is a dry run: Terraform refreshes its view of real infrastructure, compares it against your configuration and the current state file, and prints a diff showing exactly what it would add, change, or destroy, without touching anything. That's the safety net that lets you review changes before they happen, which is especially important in a team setting where a plan output often gets pasted into a pull request for review. `terraform apply` performs the same comparison but then actually executes the changes, and by default it will show you that same plan and prompt for confirmation before proceeding, or you can pass a saved plan file to apply exactly what was reviewed with no surprises in between. In CI/CD pipelines you'll usually run plan on every pull request and only run apply after a merge or manual approval, so nothing hits production infrastructure without a human or a gate having seen the diff first.
A resource block tells Terraform to create, manage, and eventually destroy something — it's infrastructure that Terraform owns the full lifecycle of, like an aws_instance or an aws_s3_bucket. A data source, declared with a `data` block, is read-only: it looks up information about something that already exists, whether that's infrastructure managed outside Terraform entirely or a resource managed by a different Terraform configuration, and exposes its attributes for you to reference. A common example is using an aws_ami data source to look up the latest Amazon Linux AMI ID rather than hardcoding it, or referencing an existing VPC by ID so a new subnet can be created inside it. The key distinction to remember in an interview is that resources show up in the plan as create, update, or destroy actions, while data sources just get read and never get modified or deleted by Terraform.
Input variables, defined in `variable` blocks, are how you parameterize a configuration so the same module or root config can be reused across environments, think instance_type or environment_name, and they can be set via .tfvars files, CLI flags, environment variables, or defaults. Outputs are the flip side — they expose values from a configuration after apply, like a load balancer's DNS name or a generated resource ID, so that value can be displayed to a user, consumed by another Terraform configuration via remote state, or passed as input to a child module. Together they're what make Terraform code composable instead of a pile of hardcoded values: you write a module once and feed it different variables per environment, then pull outputs back out to wire things together. Variables can also have type constraints and validation blocks, which catch bad input at plan time rather than failing halfway through an apply.
A module is just a reusable, self-contained package of Terraform configuration, typically a folder with its own resources, variables, and outputs, that you can call from a root configuration or from other modules. Instead of copy-pasting the same twenty resource blocks every time you need a standard three-tier VPC or an RDS instance with the right backup and encryption settings, you write it once as a module and instantiate it with different variables per environment or team. This gives you consistency and enforces best practices organization-wide, since a well-designed module can bake in security defaults that individual teams can't accidentally skip. Modules can be sourced locally, from a Git repo, or from the public or private Terraform Registry, and versioning them with semantic version constraints lets you upgrade shared infrastructure patterns deliberately rather than having every consumer break at once.
CloudFormation is AWS-native and only manages AWS resources, whereas Terraform is cloud-agnostic and can manage AWS, Azure, GCP, Kubernetes, and even SaaS tools like Datadog or GitHub all from the same workflow and state model, which matters a lot for multi-cloud or hybrid shops. Both Terraform and CloudFormation are declarative and use a fairly static configuration language, HCL versus YAML or JSON, while Pulumi takes a different approach entirely by letting you write infrastructure in general-purpose languages like TypeScript, Python, or Go, which gives you real loops, conditionals, and test frameworks instead of HCL's more limited expression syntax. Terraform's state file is also explicit and something you manage yourself, or via a backend, whereas CloudFormation manages its own state internally as stack metadata that you don't directly touch. In practice the choice often comes down to whether you're AWS-only and want tight native integration, want to stay in a mainstream language for complex logic, or want the broadest ecosystem and provider support, which is where Terraform tends to win.
`terraform import` lets you bring existing infrastructure that was created outside Terraform, say manually through the console or by another tool, under Terraform's management by adding it to the state file. You still have to write the matching resource block in your configuration yourself, import only populates state, it doesn't generate HCL for you, though newer Terraform versions have an import block plus generate-config-out that can scaffold configuration automatically. It's the classic tool for the situation where a company adopts Terraform after already having a bunch of hand-built infrastructure and doesn't want to tear it down and recreate it just to get it under IaC. The catch is your configuration needs to match the real resource's attributes closely, or the next plan will show a pile of unexpected diffs as Terraform tries to reconcile the difference.
Workspaces let you maintain multiple, isolated state files for the same configuration, so you can run `terraform workspace new staging` and `terraform workspace new production` and apply the identical code against each while Terraform tracks separate state per workspace behind the scenes. It's a lightweight way to avoid duplicating your whole configuration for dev, staging, and prod, you just reference terraform.workspace in your code to vary things like instance sizing or naming per environment. That said, workspaces aren't a complete environment-isolation story on their own since they share the same backend configuration and provider credentials, so a lot of teams instead prefer separate root modules or separate state files per environment, especially when prod needs different access controls or a different cloud account entirely. Workspaces tend to work best for smaller-scale variation, like ephemeral feature-branch environments, rather than as the sole mechanism separating prod from everything else.
Drift is what happens when the real infrastructure diverges from what's recorded in the state file, typically because someone changed something manually in the console, an auto-scaling event modified a resource, or another automation tool touched the same infrastructure outside of Terraform. Terraform detects it during the refresh step that runs as part of plan or apply, where it queries the actual cloud APIs for the current state of each managed resource and compares those live attributes against what's stored in state. If it finds a mismatch, the plan output will show that Terraform intends to change the resource back to match your configuration, even though you didn't touch the config yourself, which is often a signal that drift happened rather than that you made a real change. You can also run `terraform plan -refresh-only`, or `terraform apply -refresh-only`, to update state to reflect reality without proposing any actual infrastructure changes, which is the safer way to reconcile drift when the manual change was actually intentional.
Before running plan or apply, Terraform builds a dependency graph of every resource in your configuration so it knows the correct order to create, update, or destroy things, and it can parallelize anything that doesn't depend on anything else. Most dependencies are implicit, meaning Terraform infers them automatically because one resource references another's attribute, like a subnet's vpc_id referencing a VPC resource's id, which tells Terraform the VPC must exist first. Sometimes, though, two resources depend on each other in a way that isn't visible through attribute references, for instance an IAM policy that needs to exist before an application starts writing logs even though no attribute is actually shared between them, and that's what the `depends_on` meta-argument is for — it lets you declare an explicit ordering Terraform couldn't otherwise infer. The general rule of thumb is to rely on implicit dependencies wherever possible since they self-document the relationship, and reach for depends_on only when there's a real ordering requirement that isn't expressed through data flow.
`terraform init` is the first command you run in any new or cloned Terraform configuration, and it does a handful of setup tasks: it downloads and installs the provider plugins referenced in your configuration, initializes any modules you've called by downloading them from their source, and configures the backend for state storage, whether that's local disk or a remote backend like S3. It also creates a .terraform.lock.hcl file that pins the exact provider versions and checksums used, so everyone on the team, and CI, resolves the same provider versions instead of silently drifting to a newer release. You need to rerun init whenever you add a new provider or module, change backend configuration, or upgrade a provider version constraint, since those all require Terraform to re-resolve and re-download dependencies. Skipping it isn't really an option, plan and apply will simply fail immediately if the working directory hasn't been initialized.