🏗️ Terraform — Staff-Level Interview Questions¶
8 questions on Terraform state, the resource graph, modules, environments, providers, CI/CD, HCL and security. Versions referenced: Terraform 1.16 (current in October 2026) and OpenTofu 1.12. Several answers changed recently: S3 has native state locking (DynamoDB locking is deprecated), secrets can stay out of state with ephemeral values and write-only arguments, and
import/moved/removedblocks replace mostterraform statesurgery.
Table of Contents¶
- State Management: Local vs Remote, Locking, Migration
- Resource Graph & Dependency Resolution
- Modules: Composition, Versioning, Registry
- Workspaces & Multi-Environment Strategy
- Providers: Architecture, CRUD, Custom Providers
- CI/CD Integration: Terraform Cloud, Atlantis
- Advanced HCL: Functions, Dynamic Blocks, Expressions
- Security: Secrets Management, IAM, Policy as Code
Licensing and the OpenTofu fork (know this cold)
In August 2023 HashiCorp moved Terraform from MPL 2.0 to the Business Source License 1.1 (from Terraform 1.6). You can use it freely, including commercially, but can't offer a competing hosted product. The community forked the last MPL version as OpenTofu, now a Linux Foundation project accepted into the CNCF in April 2025. IBM completed its acquisition of HashiCorp in February 2025, and Terraform Cloud was renamed HCP Terraform in 2024. OpenTofu stays CLI- and state-compatible for most configurations but has diverged: it shipped client-side state encryption (1.7), for_each on provider blocks (1.9), -exclude (1.9) and OCI registry support (1.10). Terraform has HCP-only features such as Stacks. Pick one per organisation and pin it.
1. State Management: Local vs Remote, Locking, Migration¶
Q: "Your team of 10 engineers is managing AWS infrastructure with Terraform. Two engineers ran terraform apply simultaneously and caused a resource conflict. Design a remote state strategy with locking. How does Terraform state work? How do you migrate state from local to S3 without downtime?"
What They're Really Testing: That you treat state as a critical, sensitive database: remote, locked, versioned, access-controlled, split by blast radius, and only modified through reviewed code.
30-second answer
State maps each resource address (aws_instance.web) to a real object ID plus the last-known attributes, so Terraform can compute diffs and order deletes. Put it in a remote backend with locking (S3 with use_lockfile = true, or HCP Terraform), turn on bucket versioning and encryption with a restricted KMS key, and split state by environment and component to limit blast radius. Concurrent applies are prevented by the lock; runs should only happen from CI. Moving local state to S3 is terraform init -migrate-state; it touches no infrastructure, so there's no downtime question at all.
Answer¶
What state contains:
{
"version": 4,
"terraform_version": "1.16.5",
"serial": 42,
"lineage": "6f1c2a9e-...",
"outputs": {},
"resources": [
{
"mode": "managed",
"type": "aws_instance",
"name": "web",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"schema_version": 1,
"attributes": { "id": "i-0abcd1234efgh5678", "instance_type": "t3.micro" },
"dependencies": ["aws_security_group.web", "aws_subnet.main"]
}
]
}
]
}
| Field | Purpose |
|---|---|
serial |
Incremented on every write; a saved plan records it, so applying a plan made against an older state fails as "stale" |
lineage |
UUID fixed at state creation; stops you pushing an unrelated state over this one |
attributes |
Every attribute the provider returned, including secrets in plain text. Terraform itself does not encrypt state (OpenTofu can) |
dependencies |
Recorded so Terraform can destroy in the right order even after the config is deleted |
Remote state with locking (current recommendation):
terraform {
required_version = ">= 1.11"
backend "s3" {
bucket = "acme-tfstate-prod"
key = "network/terraform.tfstate"
region = "us-east-1"
encrypt = true
kms_key_id = "alias/terraform-state"
use_lockfile = true # S3-native lock (conditional writes); GA since Terraform 1.11
}
}
- Locking: with
use_lockfile, Terraform createsnetwork/terraform.tfstate.tflockusing an S3 conditional write (If-None-Match), which fails if the object already exists.dynamodb_tablelocking still works but is deprecated since 1.11; migrate by enabling both for a while, then removing DynamoDB. - A crashed run keeps the lock. Nothing expires it. Check that no run is still active (CI logs), then
terraform force-unlock <LOCK_ID>. Forcing a lock that's actually in use is how state gets corrupted. - Bucket settings: versioning on (recovery), public access blocked, SSE-KMS with a key policy limited to the CI role and break-glass admins, access logging/CloudTrail data events, and ideally a separate AWS account for state.
- Split state by environment × component (
prod/network,prod/data,prod/app). Smaller states plan faster, lock less contention, and a mistake touches fewer resources. Share outputs viaterraform_remote_stateor, better, SSM parameters/data sources so consumers don't need read access to the whole state.
| Backend | Locking | Notes |
|---|---|---|
| local | Only on one machine | Never for teams |
S3 (use_lockfile) |
Yes | Most common on AWS; DynamoDB no longer needed |
| GCS / azurerm | Yes (native) | Equivalent on GCP/Azure |
| HCP Terraform / Terraform Enterprise | Yes | Plus run queue, RBAC, policy, audit; pricing is per resource under management |
| Spacelift, env0, Scalr | Yes | Commercial TACOS (Terraform automation and collaboration software) that also run OpenTofu |
| pg, consul, kubernetes | Yes | Niche |
Migrating local → S3:
# 1. Add the backend block, then:
terraform init -migrate-state # copies state to S3 after confirmation
terraform plan # must show "No changes" - proves nothing was lost
# 2. Delete local terraform.tfstate* files: they hold secrets
Recovery and refactoring without terraform state surgery:
| Need | Old way | Since Terraform 1.x |
|---|---|---|
| Rename/move a resource or into a module | terraform state mv |
moved { from = ..., to = ... } block (1.1), reviewed in a PR and applied by CI |
| Adopt existing infrastructure | terraform import per resource |
import { to = ..., id = ... } block (1.5), with -generate-config-out=generated.tf to draft the HCL; for_each on imports (1.7) |
| Stop managing without destroying | terraform state rm |
removed { from = ...; lifecycle { destroy = false } } (1.7) |
| Find unmanaged resources to import | Manual inventory | terraform query with list resources (1.14), which can generate import config |
| Restore corrupted state | — | Previous S3 object version, then terraform plan to confirm |
import {
to = aws_s3_bucket.logs
id = "acme-prod-logs"
}
moved {
from = aws_instance.web
to = module.web.aws_instance.this
}
removed {
from = aws_iam_user.legacy
lifecycle {
destroy = false
}
}
What they probe next: "Someone ran apply from a laptop with an old provider version and now CI fails. What happened?" (state was written with a newer provider schema; pin versions in required_providers, commit .terraform.lock.hcl, and block laptop applies with IAM). "Who should be able to read state?" (only the identities that run Terraform for that stack, because state is a secrets store).
🔍 Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| State mechanics | Serial, lineage, secrets in plain text, dependencies |
| Locking | S3 use_lockfile (DynamoDB deprecated), locks never expire, careful force-unlock |
| Blast radius | Splits state by environment and component; restricts state access |
| Modern refactoring | import, moved, removed blocks instead of CLI state edits |
2. Resource Graph & Dependency Resolution¶
Q: "You have 100 Terraform resources with complex interdependencies. terraform plan takes 5 minutes to compute. How does Terraform's dependency graph work? How do implicit and explicit dependencies differ? How do you optimize plan time and avoid dependency cycles?"
What They're Really Testing: The DAG walk (refresh, plan, apply), why plans are slow in practice, and how cycles arise.
30-second answer
Terraform builds a DAG from references between blocks (implicit dependencies) plus depends_on (explicit), then walks it with up to 10 concurrent operations (-parallelism). Plan time is dominated by refreshing every resource and evaluating data sources through provider API calls, so 5 minutes for 100 resources usually means API throttling, slow data sources, or a state that's simply too big. Fix by splitting state, removing unnecessary data sources, and (with care) -refresh=false for fast feedback. Cycles usually come from two resources referencing each other; break them by splitting the mutual part into a separate resource.
Answer¶
Implicit vs explicit dependencies:
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id # implicit: data source
instance_type = "t3.micro"
subnet_id = aws_subnet.main.id # implicit
vpc_security_group_ids = [aws_security_group.web.id] # implicit
}
# Explicit: a hidden dependency Terraform can't see from references.
# The function reads config.json from S3 at startup, so the object must exist first.
resource "aws_s3_object" "config" {
bucket = aws_s3_bucket.data.id
key = "config.json"
source = "config.json"
}
resource "aws_lambda_function" "processor" {
function_name = "processor"
role = aws_iam_role.lambda.arn
runtime = "python3.13"
handler = "app.handler"
filename = "build/processor.zip"
depends_on = [aws_s3_object.config]
}
Use depends_on only for real hidden dependencies (IAM policy attachments needed before a service uses the role, side effects of another resource). It's coarse: depends_on on a module makes every data source inside it wait until apply, which often turns a clean plan into "known after apply" noise.
What the graph walk does:
- Refresh (inside plan): call
ReadResourcefor every managed resource in state, and read every data source. - Plan: for each resource, the provider's
PlanResourceChangecomputes create/update/replace/delete; unknown values propagate along edges. - Apply: execute changes in dependency order; independent branches run in parallel (default 10). With
create_before_destroy, replacements create the new object before destroying the old one, which reverses some edges.
Why plans are slow and what to do:
| Cause | Fix |
|---|---|
| API rate limiting (AWS throttling, GitHub/Datadog APIs) | Lower -parallelism, not higher; provider retry settings |
| Huge state (thousands of resources) | Split into smaller states by component; this is the real fix |
| Many or slow data sources | Pass IDs between stacks via outputs or parameters instead of lookups |
| Need fast feedback while iterating | terraform plan -refresh=false (trusts state; misses drift, so never for the final apply) |
| Emergency targeted change | -target=... (Terraform warns: plans can be incomplete; don't make it routine) |
terraform plan -refresh-only is not a speed-up: it shows drift between state and reality and, with apply -refresh-only, writes the real values into state.
Dependency cycles:
# Cycle: each group's inline rule references the other group
resource "aws_security_group" "app" {
name = "app"
vpc_id = aws_vpc.main.id
}
resource "aws_security_group" "db" {
name = "db"
vpc_id = aws_vpc.main.id
}
# Fix: groups have no inline rules; rules are separate resources that reference both,
# so the graph is groups → rules with no loop.
resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
security_group_id = aws_security_group.db.id
referenced_security_group_id = aws_security_group.app.id
ip_protocol = "tcp"
from_port = 5432
to_port = 5432
}
resource "aws_vpc_security_group_egress_rule" "app_to_db" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.db.id
ip_protocol = "tcp"
from_port = 5432
to_port = 5432
}
Other common cycle sources: create_before_destroy on one resource but not its dependents, modules whose outputs feed back into their own inputs via another module, and provider configurations that depend on resources in the same state (e.g. configuring the Kubernetes provider from an EKS cluster created in the same apply). The last one is better solved by splitting into two stacks (or Terraform Stacks/Terragrunt dependencies).
Inspecting the graph: terraform graph -type=plan | dot -Tsvg > graph.svg (Graphviz). On large configs it's unreadable; terraform plan -json plus tooling, or reading the cycle error message, is usually more useful.
What they probe next: "Why does a change to a tag show the whole instance as 'known after apply'?" (an upstream value became unknown, e.g. a replaced resource or a depends_on on a module). "When does parallelism > 10 help?" (large numbers of independent, slow-to-create resources, if the API's rate limits allow it).
🔍 Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Implicit vs explicit | References build the graph; depends_on only for hidden dependencies, with its module-level cost |
| Plan cost | Refresh and data sources dominate; splitting state is the real fix |
| Flags | -refresh=false vs -refresh-only vs -target, and their risks |
| Cycles | Separate rule resources, create_before_destroy interactions, provider-from-resource cycles |
3. Modules: Composition, Versioning, Registry¶
Q: "Your team manages 50 infrastructure components using Terraform modules. Design a module strategy for reusability across 3 environments (dev, staging, prod). How do you version modules? How do you publish to a private registry? When should you NOT use a module?"
What They're Really Testing: Module API design, versioning and testing discipline, and judgement about abstraction.
30-second answer
Treat a module like a library: a small, typed, validated input interface, documented outputs, provider version constraints (but no provider configuration inside), semantic versions published as Git tags or to a private registry, and automated tests (terraform test). Environments consume pinned versions, and promote a new version dev → staging → prod. Don't wrap a single resource just to rename its arguments, and avoid deep module nesting that makes plans unreadable.
Answer¶
Module layout:
modules/network/
├── main.tf # VPC, subnets, route tables, NAT
├── variables.tf # typed inputs with validation
├── outputs.tf # documented outputs
├── versions.tf # required_version + required_providers (constraints only)
├── README.md # generated with terraform-docs
└── tests/
└── network.tftest.hcl
# variables.tf
variable "environment" {
type = string
description = "Environment name."
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging or prod."
}
}
variable "vpc_cidr" {
type = string
description = "CIDR block for the VPC."
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "vpc_cidr must be a valid CIDR block."
}
}
variable "nat" {
type = object({
enabled = optional(bool, true)
single_nat = optional(bool, false) # one NAT for all AZs: cheaper, less resilient
})
default = {}
}
# versions.tf
terraform {
required_version = ">= 1.11"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.0, < 7.0"
}
}
}
Rules that keep modules composable:
- No
providerblocks inside reusable modules. The caller configures providers and passes aliases viaproviders = { aws = aws.us_east_1 }. A module with its own provider block can't be used withfor_eachorcount, and removing it orphans resources. - Typed object inputs with
optional()defaults instead of 40 loose string variables. - Outputs are the contract. Removing or renaming an output, or changing a resource address without a
movedblock, is a breaking change → major version.
Versioning and distribution:
module "network_git" {
source = "git::https://github.com/acme/terraform-aws-network.git?ref=v1.4.2"
environment = "prod"
vpc_cidr = "10.0.0.0/16"
}
module "network_registry" {
source = "app.terraform.io/acme/network/aws" # private registry
version = "~> 1.4" # >= 1.4.0, < 2.0.0
environment = "prod"
vpc_cidr = "10.0.0.0/16"
}
| Constraint | Allows |
|---|---|
= 1.4.2 |
Exactly that version (prod, if you want explicit upgrades) |
~> 1.4.2 |
≥ 1.4.2, < 1.5.0 (patches only) |
~> 1.4 |
≥ 1.4.0, < 2.0.0 (minor and patch) |
>= 1.4, < 2.0 |
Same as above, explicit |
- Git sources ignore
version; therefis the version. Pin to tags or commit SHAs, never branches. - Private registries (HCP Terraform, Terraform Enterprise, Artifactory, GitLab, Spacelift; OCI registries in OpenTofu 1.10+) need repos named
terraform-<PROVIDER>-<NAME>and semver tags. - Since Terraform 1.15,
sourceandversioncan reference variables and locals, which helps centralise version pins. - Automate upgrades with Renovate or Dependabot, which open PRs for new module and provider versions.
Testing modules (terraform test, 1.6+):
# tests/network.tftest.hcl
variables {
environment = "dev"
vpc_cidr = "10.10.0.0/16"
}
run "rejects_bad_environment" {
command = plan
variables {
environment = "qa"
}
expect_failures = [var.environment]
}
run "creates_vpc_with_requested_cidr" {
command = plan
assert {
condition = aws_vpc.this.cidr_block == "10.10.0.0/16"
error_message = "VPC CIDR does not match input."
}
}
command = plan tests are fast and need no real infrastructure when combined with mock_provider (1.7+); command = apply runs create real resources in a sandbox account and destroy them afterwards, which catches provider/API behaviour that mocks can't.
When NOT to use a module:
- Passthrough wrappers: a module that exposes every argument of one
aws_s3_bucketadds indirection and a release cycle without encoding any decision. Either encode the decision (encryption, public-access block, lifecycle, logging always on) or use the resource directly. - Deep nesting (module → module → module): plan output and debugging become painful, and
movedrefactors get hard. Two levels is usually enough. - Before you have three real uses. Premature modules freeze the wrong interface.
Good candidates encode organisation policy and related resources that always travel together: a VPC with its subnets, routes and NAT; a service with its IAM role, log group, alarms and DNS record.
What they probe next: "How do you roll a breaking module change across 60 consumers?" (major version, moved blocks inside the module, upgrade guide, Renovate PRs, deprecate old versions with a date). "How do you test modules against real AWS cheaply?" (command = apply tests in an ephemeral account, nightly rather than per commit).
🔍 Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Interface design | Typed objects, optional() defaults, validation, no provider blocks inside |
| Versioning | Semver, pinned refs, constraint semantics, automated upgrades |
| Testing | terraform test, mock_provider, plan vs apply tests |
| Judgement | Avoids passthrough and deep nesting; modules encode decisions |
4. Workspaces & Multi-Environment Strategy¶
Q: "Design a Terraform directory structure for 3 environments (dev, staging, prod) with shared modules. Compare workspaces vs directory layouts. How do you manage environment-specific variables? How do you prevent a staging change from affecting production?"
What They're Really Testing: Isolation of credentials and state between environments, and a promotion flow that makes prod changes boring.
30-second answer
Use one root configuration per environment and component (directories, or HCP Terraform workspaces/Stacks deployments), each with its own state, its own backend location and, crucially, its own cloud account and credentials. CLI workspaces share one backend and one set of credentials, so they're fine for short-lived copies (feature branches, test stacks) but not for separating prod from dev. Promote the same module version through environments, and let only CI apply, using OIDC-issued, environment-scoped roles.
Answer¶
CLI workspaces vs separate roots:
| CLI workspaces | Directory per environment | |
|---|---|---|
| State | Same backend, path env:/<workspace>/<key> |
Separate keys or buckets, can be separate accounts |
| Credentials | Same backend credentials for all | Per environment |
| Visibility | Current workspace is invisible in the code | The path says prod/ |
| Divergence between envs | Only via variables and terraform.workspace conditionals |
Explicit, reviewable |
| Good for | Ephemeral copies of one stack | Long-lived environments |
HashiCorp's own docs say CLI workspaces are not suitable for strong separation between environments, because they share the backend and its credentials.
Layout:
modules/
network/ database/ service/
live/
dev/
network/ main.tf backend.tf terraform.tfvars
database/ ...
staging/
network/ ...
prod/
network/ main.tf backend.tf terraform.tfvars
database/ ...
# live/prod/network/main.tf
provider "aws" {
region = "us-east-1"
assume_role {
role_arn = "arn:aws:iam::111122223333:role/terraform-prod-network"
}
default_tags {
tags = {
Environment = "prod"
ManagedBy = "terraform"
Stack = "network"
}
}
}
module "network" {
source = "app.terraform.io/acme/network/aws"
version = "1.4.2" # promoted here only after dev and staging ran it
environment = "prod"
vpc_cidr = var.vpc_cidr
}
- Duplication of the small root files is the price of explicitness. Tools reduce it: Terragrunt (DRY backend/provider config, dependencies between stacks,
run --all), or Terraform Stacks in HCP Terraform (GA since September 2025), which define components once and deploy them to many "deployments" (environments, regions) with orchestrated dependencies. - Environment-specific values go in
terraform.tfvarsper root (non-secret), or HCP Terraform variable sets. Secrets never go in tfvars (Q8). default_tagson the provider tags everything consistently without per-resource code.
Preventing staging changes from reaching prod:
- Separate AWS accounts per environment (AWS Organizations). A staging role physically can't touch prod.
- Only CI applies, with short-lived credentials via OIDC (GitHub Actions → AWS role, or HCP Terraform dynamic provider credentials), and the role's trust policy restricted to the right repo, branch and GitHub environment. Humans get read-only in prod.
- Promotion by version: a module change is a new version; prod's root bumps the pin in its own PR after staging has been running it.
- Plan visible in the PR for each affected root, and apply the reviewed plan file, not a fresh plan (Q6).
- Drift detection (scheduled
plan -detailed-exitcode, or HCP Terraform health assessments) so console changes don't surprise the next deploy.
What they probe next: "Your prod and staging configs have drifted apart in 40 small ways. How do you converge?" (move differences into explicit variables, upgrade both to the same module version, diff the plans). "How do you handle a resource shared across environments, such as a Route 53 zone?" (a separate shared/ stack owned by the platform team, exposing IDs to others).
🔍 Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Workspace vs directory | Knows CLI workspaces share credentials and backend, and where they're still useful |
| Isolation | Separate accounts, state and OIDC-scoped CI roles per environment |
| Promotion | Same module version moves dev → staging → prod |
| Tooling awareness | Terragrunt, Terraform Stacks, drift detection |
5. Providers: Architecture, CRUD, Custom Providers¶
Q: "You need to manage a third-party SaaS API (e.g., Datadog, PagerDuty) with Terraform. How does a Terraform provider work? Walk through the CRUD lifecycle. How would you build a custom provider for an internal API?"
What They're Really Testing: The plugin protocol, the plan/apply split for providers, and good provider design (correct Read, import support, computed attributes).
30-second answer
A provider is a separate Go binary that Terraform launches and talks to over gRPC (plugin protocol v5/v6). Core asks it for schemas, validates config, and during plan calls ReadResource (refresh) and PlanResourceChange; during apply it calls ApplyResourceChange, which the SDK maps to your Create/Update/Delete. Build new providers with the Terraform Plugin Framework. The most important method is Read: it must reflect reality and remove the resource from state on 404, or drift detection is broken.
Answer¶
Protocol flow:
sequenceDiagram
participant Core as Terraform Core
participant P as Provider plugin
participant API as Remote API
Core->>P: GetProviderSchema
Core->>P: ValidateProviderConfig / ConfigureProvider
Core->>P: ReadResource (refresh each resource in state)
P->>API: GET /services/{id}
Core->>P: PlanResourceChange (prior state + config → proposed new state)
Note over Core: user reviews plan
Core->>P: ApplyResourceChange
P->>API: POST / PUT / DELETE
P-->>Core: new state (all computed values known)
- Providers are downloaded by
terraform initfrom a registry, checksum-verified against.terraform.lock.hcl, and run as child processes. - Plan vs apply contract: values unknown at plan time are "(known after apply)"; after apply, the provider must return a state consistent with the plan, or Core reports "Provider produced inconsistent result" (a provider bug).
- Other RPCs worth knowing:
ImportResourceState,UpgradeResourceState(schema version migrations),MoveResourceState(moving between resource types), ephemeral resources (OpenEphemeralResource, 1.10+), and list resources forterraform query(1.14+).
A resource with the Plugin Framework:
package provider
import (
"context"
"errors"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
"example.com/internal/api" // your API client
)
var (
_ resource.Resource = (*serviceResource)(nil)
_ resource.ResourceWithImportState = (*serviceResource)(nil)
)
type serviceResource struct{ client *api.Client }
type serviceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Team types.String `tfsdk:"team"`
CreatedAt types.String `tfsdk:"created_at"`
}
func (r *serviceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_service"
}
func (r *serviceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
"name": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, // API can't rename
},
"team": schema.StringAttribute{Required: true},
"created_at": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
},
}
}
func (r *serviceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan serviceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
out, err := r.client.CreateService(ctx, api.Service{Name: plan.Name.ValueString(), Team: plan.Team.ValueString()})
if err != nil {
resp.Diagnostics.AddError("Creating service failed", err.Error())
return
}
plan.ID = types.StringValue(out.ID)
plan.CreatedAt = types.StringValue(out.CreatedAt)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *serviceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state serviceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
out, err := r.client.GetService(ctx, state.ID.ValueString())
if errors.Is(err, api.ErrNotFound) {
resp.State.RemoveResource(ctx) // deleted outside Terraform: plan will recreate it
return
}
if err != nil {
resp.Diagnostics.AddError("Reading service failed", err.Error())
return
}
state.Name = types.StringValue(out.Name) // write what the API says, so drift shows up in plan
state.Team = types.StringValue(out.Team)
state.CreatedAt = types.StringValue(out.CreatedAt)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *serviceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan, state serviceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
// Core only calls Update when the plan differs from state.
if err := r.client.UpdateServiceTeam(ctx, state.ID.ValueString(), plan.Team.ValueString()); err != nil {
resp.Diagnostics.AddError("Updating service failed", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *serviceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state serviceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
err := r.client.DeleteService(ctx, state.ID.ValueString())
if err != nil && !errors.Is(err, api.ErrNotFound) { // already gone is success
resp.Diagnostics.AddError("Deleting service failed", err.Error())
}
// On success the framework removes the resource from state.
}
func (r *serviceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
Design points that interviewers look for:
- Config vs computed:
name/teamcome from the user;id/created_atcome from the API.UseStateForUnknownkeeps stable computed values from showing as "(known after apply)" on every update. RequiresReplacefor fields the API can't change in place, so the plan says "must be replaced" up front instead of failing at apply.- Idempotent delete and 404 handling in Read make the provider robust to out-of-band changes.
- Eventual consistency: many APIs return before an object is readable; retry reads after create, or Terraform reports inconsistent results.
Build vs alternatives:
| Option | When |
|---|---|
| Existing provider (official, partner or community, e.g. Datadog, PagerDuty) | Almost always for SaaS |
terraform_data + local-exec provisioner calling a script |
One-off side effects (successor to null_resource since 1.4); no drift detection, no real Read, last resort |
Generic REST provider (e.g. community restapi) |
Simple JSON CRUD APIs, prototypes |
| Custom provider with Plugin Framework | An internal platform API many teams will manage declaratively; you need plan diffs, import and drift detection |
Workflow for a custom provider: scaffold from terraform-provider-scaffolding-framework, generate schemas from OpenAPI with the provider code generator if available, test with terraform-plugin-testing acceptance tests against a real or fake API, use dev_overrides in ~/.terraformrc for local development, and publish signed releases (GoReleaser + GPG) to a private or public registry.
What they probe next: "A user changes a setting in the SaaS UI. What does Terraform do?" (Read picks it up, plan proposes to revert it). "How do you evolve a resource's schema without breaking existing state?" (schema Version + UpgradeState).
🔍 Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Protocol | Separate process over gRPC; plan-time vs apply-time RPCs |
| CRUD semantics | Read reflects reality, 404 removes from state, idempotent delete |
| Schema design | Computed vs configured, UseStateForUnknown, RequiresReplace, import |
| Judgement | When a custom provider is worth it vs existing or generic options |
6. CI/CD Integration: Terraform Cloud, Atlantis¶
Q: "Design a CI/CD pipeline for Terraform infrastructure changes. How does Atlantis automate terraform plan/apply on pull requests? Compare Terraform Cloud vs Atlantis vs GitHub Actions. How do you handle concurrent PRs that modify the same resources?"
What They're Really Testing: Plan/apply integrity (apply exactly what was reviewed), credential handling (OIDC, no static keys), and concurrency control across PRs.
30-second answer
On every PR: fmt, validate, lint/policy checks, and a plan per affected root posted to the PR. After approval, apply the saved plan file that was reviewed, from CI only, with short-lived OIDC credentials scoped to that environment. Concurrency is handled at two levels: the state lock stops simultaneous applies, and a per-project PR lock (Atlantis) or a run queue (HCP Terraform) stops two PRs from planning against the same state. A saved plan becomes stale if state changes after it was made, and Terraform refuses to apply it, so the second PR re-plans.
Answer¶
Atlantis (self-hosted, PR-native):
PR opened ─▶ webhook ─▶ Atlantis: plan each affected project ─▶ plan posted as PR comment
(project is now LOCKED to this PR)
reviewer approves ─▶ comment "atlantis apply" ─▶ apply saved plan ─▶ merge PR ─▶ lock released
Atlantis applies before merge, so main always reflects what's deployed only if you require "mergeable" and keep branches up to date.
# atlantis.yaml (repo level)
version: 3
automerge: true # merge the PR after all applies succeed
parallel_plan: true
projects:
- name: dev-network
dir: live/dev/network
autoplan:
when_modified: ["*.tf", "*.tfvars", "../../../modules/network/**/*.tf"]
- name: prod-network
dir: live/prod/network
workflow: prod
autoplan:
when_modified: ["*.tf", "*.tfvars", "../../../modules/network/**/*.tf"]
apply_requirements: [approved, mergeable, undiverged]
workflows:
prod:
plan:
steps:
- init
- plan:
extra_args: ["-lock-timeout=5m"]
apply:
steps:
- apply
# repos.yaml (server side: what repos may override)
repos:
- id: github.com/acme/infra
apply_requirements: [approved, mergeable]
allowed_overrides: [apply_requirements, workflow]
allow_custom_workflows: false
GitHub Actions (plan on PR, apply the reviewed plan after merge):
name: terraform-prod-network
on:
pull_request:
paths: ["live/prod/network/**", "modules/network/**"]
push:
branches: [main]
paths: ["live/prod/network/**", "modules/network/**"]
permissions:
contents: read
id-token: write # OIDC token for AWS
pull-requests: write # plan comment
concurrency:
group: tf-prod-network # one run at a time for this root
cancel-in-progress: false
env:
TF_IN_AUTOMATION: "true"
WORKDIR: live/prod/network
jobs:
plan:
runs-on: ubuntu-latest
environment: prod-plan # role trust policy: plan-only role
steps:
- uses: actions/checkout@v7
- uses: hashicorp/setup-terraform@v4
with:
terraform_version: 1.16.5
terraform_wrapper: false
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::111122223333:role/gha-terraform-plan
aws-region: us-east-1
- run: terraform init -input=false
working-directory: ${{ env.WORKDIR }}
- run: terraform plan -input=false -lock-timeout=5m -out=tfplan
working-directory: ${{ env.WORKDIR }}
- run: terraform show -no-color tfplan > plan.txt
working-directory: ${{ env.WORKDIR }}
- uses: actions/upload-artifact@v7
with:
name: tfplan-${{ github.sha }}
path: ${{ env.WORKDIR }}/tfplan
# (post plan.txt to the PR with a comment action; truncate to the 65k comment limit)
apply:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: plan
runs-on: ubuntu-latest
environment: prod # required reviewers gate here
steps:
- uses: actions/checkout@v7
- uses: hashicorp/setup-terraform@v4
with:
terraform_version: 1.16.5
terraform_wrapper: false
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::111122223333:role/gha-terraform-apply
aws-region: us-east-1
- uses: actions/download-artifact@v8
with:
name: tfplan-${{ github.sha }}
path: ${{ env.WORKDIR }}
- run: terraform init -input=false
working-directory: ${{ env.WORKDIR }}
- run: terraform apply -input=false tfplan # exactly the plan the approver saw
working-directory: ${{ env.WORKDIR }}
- Approve the plan, not just the diff. In this design the reviewer approves at the
prodenvironment gate after seeing the plan generated frommain. Applying a fresh plan with-auto-approveapplies something nobody reviewed. - OIDC, no static keys: the AWS role trust policy should require
token.actions.githubusercontent.com:sub=repo:acme/infra:environment:prodso only that repo and environment can assume the apply role. - Plan files contain secrets (they include state values); keep artifact retention short and access restricted.
Comparison:
| HCP Terraform / TFE | Atlantis | GitHub Actions (DIY) | Spacelift / env0 / Scalr | |
|---|---|---|---|---|
| Hosting | SaaS or self-hosted (TFE) | Self-hosted | Your CI | SaaS |
| Plan/apply UX | Runs UI + VCS checks | PR comments | Whatever you build | Runs UI + PR checks |
| Concurrency | Run queue per workspace | Per-project PR lock | concurrency groups + state lock |
Run queue, stack locks |
| Policy | Sentinel and OPA | Conftest via custom steps | Any tool you add | OPA |
| Credentials | Dynamic provider credentials (OIDC) | Server's credentials (big blast radius) | OIDC per job | OIDC |
| OpenTofu | No | Yes | Yes | Yes |
| Cost model | Resources under management | Free (ops cost) | Runner minutes | Per seat/run |
Concurrent PRs on the same root:
- PR A and PR B both plan against state serial 42.
- Atlantis: PR B can't plan at all while PR A holds the project lock ("locked by PR A"). Without Atlantis, both plans exist.
- PR A applies → state serial 43.
- PR B tries to apply its saved plan → Terraform refuses: "Saved plan is stale", because the plan recorded serial 42. B must rebase and re-plan, and the reviewer sees the new plan.
- If B's pipeline doesn't use saved plans (
apply -auto-approvewithout a plan file), Terraform silently re-plans and applies whatever is now needed. That's the failure mode to design out.
The state lock (-lock-timeout=5m) only serialises simultaneous operations; it doesn't detect that a plan was made against old state. Saved plans and PR-level locks do.
What they probe next: "Atlantis runs with admin credentials for every account. How do you limit blast radius?" (one Atlantis per environment or account, per-project roles assumed from the server, or move to OIDC-based CI). "How do you detect drift between deploys?" (scheduled plans with -detailed-exitcode: exit 2 means changes).
🔍 Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Plan integrity | Applies the reviewed saved plan; knows about stale-plan rejection |
| Credentials | OIDC roles per environment; no static keys; plan vs apply roles |
| Concurrency | State lock vs PR/project locks vs CI concurrency groups |
| Tool comparison | HCP Terraform vs Atlantis vs DIY vs other TACOS, including OpenTofu support |
7. Advanced HCL: Functions, Dynamic Blocks, Expressions¶
Q: "You need to create 50 security group rules from a list of ports and protocols. Using HCL functions and dynamic blocks, write a Terraform configuration that creates these rules without repeating code. How do for_each, count, and dynamic blocks differ?"
What They're Really Testing: Stable resource addressing, data reshaping with for expressions, and knowing when the "clever" HCL is the wrong answer.
30-second answer
count creates N copies addressed by index, so removing an item from the middle shifts and recreates everything after it; use it only for "0 or 1" toggles or truly identical copies. for_each addresses instances by stable keys from a map or set, so adds and removals touch only that key. dynamic blocks generate repeated nested blocks inside one resource. For 50 security group rules, prefer a separate aws_vpc_security_group_ingress_rule resource per rule with for_each (AWS's recommended pattern) over 50 inline ingress blocks.
Answer¶
Rules as individual resources (recommended):
variable "ingress_rules" {
type = map(object({
port = number
protocol = optional(string, "tcp")
cidr = string
description = optional(string, "Managed by Terraform")
}))
default = {
https = { port = 443, cidr = "10.0.0.0/8" }
ssh = { port = 22, cidr = "10.10.0.0/16", description = "Bastion SSH" }
postgres = { port = 5432, cidr = "10.20.0.0/16" }
}
}
resource "aws_security_group" "app" {
name = "app"
vpc_id = aws_vpc.main.id
}
resource "aws_vpc_security_group_ingress_rule" "app" {
for_each = var.ingress_rules
security_group_id = aws_security_group.app.id
ip_protocol = each.value.protocol
from_port = each.value.port
to_port = each.value.port
cidr_ipv4 = each.value.cidr
description = each.value.description
}
Each rule is aws_vpc_security_group_ingress_rule.app["ssh"]: removing ssh deletes one rule; nothing else moves. Inline rules, by contrast, are one big attribute set on the group: a change rewrites the set, and mixing inline and standalone rule resources on the same group makes them fight.
Dynamic blocks (when the API only offers nested blocks):
resource "aws_security_group" "legacy" {
name = "legacy"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = [ingress.value.cidr]
description = ingress.value.description
}
}
}
The iterator is named after the block (ingress.key, ingress.value), or set iterator = rule. Overusing dynamic blocks hides the shape of a resource; use them for genuinely variable nested blocks (listener rules, IAM statements, settings lists).
count vs for_each:
# Index-addressed: aws_iam_user.by_index[0], [1], [2]
resource "aws_iam_user" "by_index" {
count = length(var.user_names)
name = var.user_names[count.index]
}
# Removing the first name shifts every index: [1] becomes [0], and Terraform
# plans changes to every user after the removed one.
# Key-addressed: aws_iam_user.by_name["alice"]
resource "aws_iam_user" "by_name" {
for_each = toset(var.user_names)
name = each.key
}
# count is still right for an on/off toggle
resource "aws_cloudwatch_log_group" "debug" {
count = var.enable_debug_logs ? 1 : 0
name = "/app/debug"
}
for_eachkeys must be known at plan time: you can't key on an attribute that's only known after apply (e.g. a generated ID). Key on input values or names.- Migrate from
counttofor_eachwithout recreation usingmovedblocks (from = aws_iam_user.by_index[0],to = aws_iam_user.by_name["alice"]). - Sets of objects can't be
for_eachkeys; build a map with aforexpression.
Reshaping data:
locals {
# Flatten nested input into one map with stable composite keys
sg_rules = merge([
for sg_name, sg in var.security_groups : {
for rule in sg.rules :
"${sg_name}-${rule.port}" => merge(rule, { sg_name = sg_name })
}
]...)
# Subnet CIDRs: /16 + 8 bits = /24 per AZ
subnet_cidrs = [for i in range(3) : cidrsubnet("10.0.0.0/16", 8, i)]
# ["10.0.0.0/24", "10.0.1.0/24", "10.0.2.0/24"]
instance_names = formatlist("%s-instance-%02d", "${var.environment}-web", range(3))
# ["prod-web-instance-00", "prod-web-instance-01", "prod-web-instance-02"]
tags = merge(var.default_tags, { Service = "web" }, var.extra_tags) # later maps win
endpoint = try(var.config.endpoint, "https://default.internal") # safe lookup
}
The merge([...]...) idiom (expanding a list of maps into arguments) is how you build a single map for for_each from nested structures; the older flatten() returns a list, which you then still have to convert into a map with unique keys.
Guardrails in HCL:
resource "aws_db_instance" "main" {
identifier = "orders"
engine = "postgres"
instance_class = var.db_instance_class
allocated_storage = 100
username = "app"
storage_encrypted = true
skip_final_snapshot = false
manage_master_user_password = true # AWS stores and rotates it in Secrets Manager
lifecycle {
prevent_destroy = true
precondition {
condition = var.environment != "prod" || startswith(var.db_instance_class, "db.r")
error_message = "Production databases must use a memory-optimised (db.r*) class."
}
}
}
check "orders_api_healthy" {
data "http" "health" {
url = "https://orders.internal.example.com/health"
}
assert {
condition = data.http.health.status_code == 200
error_message = "Orders API health check failed after apply."
}
}
precondition/postcondition fail the plan or apply; check blocks (1.5+) only warn, which suits post-deploy health checks and drift signals. Use terraform console to try expressions interactively.
What they probe next: "Why does for_each = toset(aws_instance.web[*].id) fail on first apply?" (keys unknown until apply). "When would you move logic out of HCL?" (complex transformations belong in a typed input file or a small program generating JSON; HCL has no tests for local functions, though Terraform 1.15 added convert() and provider-defined functions help).
🔍 Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Addressing | count index shifting vs for_each stable keys; keys known at plan time |
| Resource design | Separate rule resources over inline blocks; dynamic blocks only where needed |
| Data reshaping | for expressions, merge(...) expansion, cidrsubnet, try, optional() |
| Guardrails | moved for migrations, prevent_destroy, pre/postconditions, check blocks |
8. Security: Secrets Management, IAM, Policy as Code¶
Q: "Design a secure Terraform workflow for managing secrets across environments. How do you avoid storing secrets in state files? Compare Vault, AWS Secrets Manager, and SOPS for secrets management. How do you enforce policies like 'no public S3 buckets' using Sentinel or OPA?"
What They're Really Testing: Whether you know what actually ends up in state (and what newer Terraform features keep out of it), how to scope Terraform's own credentials, and where policy checks belong.
30-second answer
Anything Terraform reads through a data source or sets on a normal resource argument is written to state in plain text, and sensitive = true only hides it from CLI output. Keep secrets out of state by (1) letting the service generate and own them (manage_master_user_password on RDS), (2) using ephemeral resources (1.10+) to fetch secrets and write-only arguments (1.11+, e.g. password_wo) to pass them on without persisting them, and (3) treating state as a secret anyway (KMS, tight IAM; OpenTofu can encrypt state client-side). Terraform runs with OIDC-issued, least-privilege roles, and policy-as-code (OPA/Conftest, Sentinel, Checkov/Trivy) runs on the plan JSON in CI before apply.
Answer¶
What leaks into state, and the fixes:
| Pattern | Secret in state? | Notes |
|---|---|---|
password = var.db_password |
Yes | Also lives wherever the variable came from |
data "aws_secretsmanager_secret_version" |
Yes | Data source results are stored in state |
sensitive = true on a variable or output |
Yes | Only redacts CLI/plan output |
random_password resource |
Yes | Generated value is a resource attribute |
manage_master_user_password = true (RDS) |
No | AWS generates, stores and rotates the password in Secrets Manager |
ephemeral resource + write-only argument |
No | Value exists only during the run |
Ephemeral values and write-only arguments:
ephemeral "random_password" "db" {
length = 32
special = true
}
resource "aws_secretsmanager_secret" "db" {
name = "prod/orders/db-password"
}
resource "aws_secretsmanager_secret_version" "db" {
secret_id = aws_secretsmanager_secret.db.id
secret_string_wo = ephemeral.random_password.db.result
secret_string_wo_version = 1 # bump to rotate: write-only values can't be diffed
}
resource "aws_db_instance" "orders" {
identifier = "orders"
engine = "postgres"
instance_class = "db.r7g.large"
allocated_storage = 100
username = "app"
password_wo = ephemeral.random_password.db.result
password_wo_version = 1
storage_encrypted = true
skip_final_snapshot = false
}
- Ephemeral resources (Terraform 1.10, OpenTofu 1.11) are opened during the run and never stored in state or plan files. Providers offer them for things like Secrets Manager secret versions, Vault secrets and generated passwords.
- Write-only arguments (
*_wo, Terraform 1.11) accept ephemeral values and are sent to the API but not stored. Terraform can't see their value to detect changes, so a companion*_wo_versionargument triggers updates. - Only use them where the provider supports them; check the resource docs.
Secret stores compared:
| AWS Secrets Manager / SSM Parameter Store | HashiCorp Vault / OpenBao | SOPS (age/KMS-encrypted files in Git) | |
|---|---|---|---|
| Best for | AWS-native apps, rotation of AWS credentials | Multi-cloud, dynamic short-lived credentials (DB, cloud) | GitOps; secrets versioned with code |
| How Terraform reads it | Ephemeral resource or data source | vault provider, ephemeral resources |
sops provider (ephemeral support) or decrypt in CI |
| Rotation | Built-in for RDS/Redshift/DocumentDB, Lambda for others | Native leases and revocation | Manual re-encrypt and commit |
| Main risk | IAM policy sprawl | Operating Vault itself (or its licence: Vault is also BSL; OpenBao is the open fork) | Key management; plaintext copies on disk |
Often the best answer is that Terraform shouldn't handle the secret at all: it creates the secret container and IAM permissions, and the application or a rotation function populates and reads the value.
Terraform's own credentials:
- CI only, via OIDC (GitHub Actions, GitLab, HCP Terraform dynamic credentials): no long-lived access keys anywhere.
- Separate plan and apply roles: plan needs read access (and state read); apply needs write. PR plans from forks must not get the apply role.
- Per-stack roles scoped to the services that stack manages, rather than one admin role for everything.
- Privilege escalation guard: a role that can
iam:CreateRoleandiam:AttachRolePolicycan grant itself admin. Require a permissions boundary on everything it creates:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CreateRolesOnlyWithBoundary",
"Effect": "Allow",
"Action": ["iam:CreateRole", "iam:PutRolePermissionsBoundary"],
"Resource": "arn:aws:iam::111122223333:role/app/*",
"Condition": {
"StringEquals": {
"iam:PermissionsBoundary": "arn:aws:iam::111122223333:policy/AppWorkloadBoundary"
}
}
},
{
"Sid": "StateAccess",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::acme-tfstate-prod/network/*"
},
{
"Sid": "StateList",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::acme-tfstate-prod"
}
]
}
A permissions boundary on the Terraform role itself limits only that role; it's the condition on role creation that stops it minting more powerful roles. Service control policies (SCPs) in AWS Organizations add a further ceiling per account.
Policy as code on the plan:
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
conftest test plan.json --policy policy/ # OPA/Rego via Conftest
# policy/s3.rego (OPA 1.0 syntax: "contains" and "if" are required)
package main
import rego.v1
deny contains msg if {
rc := input.resource_changes[_]
rc.type == "aws_s3_bucket_public_access_block"
some setting in ["block_public_acls", "block_public_policy", "ignore_public_acls", "restrict_public_buckets"]
rc.change.after[setting] == false
msg := sprintf("%s: %s must be true", [rc.address, setting])
}
deny contains msg if {
rc := input.resource_changes[_]
rc.type == "aws_db_instance"
rc.change.after.storage_encrypted == false
msg := sprintf("%s: RDS storage must be encrypted", [rc.address])
}
Sentinel (HCP Terraform / Terraform Enterprise only):
import "tfplan/v2" as tfplan
allowed_types = ["t3.micro", "t3.small", "t3.medium", "m7g.large"]
instances = filter tfplan.resource_changes as _, rc {
rc.type is "aws_instance" and
(rc.change.actions contains "create" or rc.change.actions contains "update")
}
main = rule {
all instances as _, rc {
rc.change.after.instance_type in allowed_types
}
}
| OPA / Conftest | Sentinel | Static scanners (Checkov, Trivy (formerly tfsec), KICS) | |
|---|---|---|---|
| Input | Plan JSON (or HCL) | Plan, config, state, run data | HCL source |
| Runs in | Any CI; HCP Terraform supports OPA policy sets too | HCP Terraform / TFE | Any CI, pre-commit |
| Strength | One language for Terraform, Kubernetes admission, APIs | Enforcement levels (advisory / soft-mandatory / hard-mandatory), tight TFC integration | Hundreds of ready-made rules |
| Weakness | Rego learning curve | Proprietary | Can't see computed values; source only |
Layer them: static scanning in pre-commit and PRs for fast feedback, plan-based policy as the merge gate, and cloud-side guardrails (SCPs, S3 Block Public Access at the account level, AWS Config) as the backstop for anything created outside Terraform.
What they probe next: "A plan file was uploaded as a CI artifact. Is that a problem?" (yes, plan files contain prior state values; restrict access and retention). "How do you rotate a DB password managed by Terraform with zero downtime?" (prefer service-managed rotation; otherwise dual-user rotation, then bump password_wo_version).
🔍 Staff-Level Evaluation¶
| Criterion | What I'm Looking For |
|---|---|
| Secrets in state | Knows data sources and sensitive still persist values; ephemeral + write-only; service-managed secrets |
| Credentials | OIDC, plan vs apply roles, privilege-escalation guard via boundary conditions |
| Policy as code | Plan JSON + OPA (1.0 syntax) or Sentinel as a merge gate, layered with scanners and SCPs |
| State hygiene | State and plan files treated as secrets; OpenTofu state encryption as an option |
If you remember one thing per question: state is a secret-bearing database, locked natively in S3 now; plans are slow because of refresh, so split state; modules are versioned, tested libraries; environments are separate accounts, not CLI workspaces; providers live or die by
Read; apply the plan you reviewed; key onfor_each, notcount; and keep secrets out of state with ephemeral values and write-only arguments.