SaveMyCert
Log in
5 of 5 free questions left today·for 10 a day
Core Terraform workflow

terraform apply, destroy, and fmt: executing and maintaining changes

14 min readTerraform 004 · Core Terraform workflowUpdated

terraform apply executes the changes proposed in an execution plan — creating, updating, or destroying real infrastructure and recording the results in state — while terraform destroy tears down everything tracked in the current state, and terraform fmt rewrites configuration into HashiCorp's canonical style. Apply is the only core-workflow command that modifies infrastructure, which is why it demands explicit approval: it shows you a plan and waits for you to type yes, unless you pass -auto-approve or hand it a saved plan file that was already reviewed. This lesson covers the approval prompt and running apply unattended (-auto-approve versus -input=false, TF_INPUT and TF_IN_AUTOMATION), applying saved plans, what happens when an apply fails partway, how apply walks the dependency graph with up to ten concurrent operations, destroying with terraform destroy and its equivalent terraform apply -destroy, what destroy does and does not delete, prevent_destroy, targeted destroys, and the fmt flags (-check, -diff, -write=false, -list, -recursive) that keep formatting enforceable in CI. It closes with a validate recap and the pipeline order: fmt, init, validate, plan, apply.

What you’ll learn
  • Execute infrastructure changes with terraform apply and explain when the approval prompt appears and when it is skipped
  • Run apply unattended correctly, distinguishing -auto-approve and saved plans from -input=false, TF_INPUT and TF_IN_AUTOMATION
  • Apply a saved plan file and explain what it fixes at plan time: actions, configuration, variable values and the state it was computed from
  • Describe what a failed or partially failed apply leaves behind, including tainted resources after a failed creation-time provisioner
  • Control concurrency with -parallelism and describe how apply and destroy walk the resource dependency graph
  • Destroy managed infrastructure with terraform destroy, terraform apply -destroy and -target, and predict exactly what is and is not deleted
  • Protect a resource with prevent_destroy and explain its limits
  • Enforce canonical formatting with terraform fmt and choose between -check, -diff, -write=false, -list and -recursive

terraform apply: executing the plan

terraform apply executes the actions in an execution plan against real infrastructure and records the results in the state file. Run without arguments, it first generates a fresh plan — exactly as terraform plan would — prints it, and then pauses for confirmation. Only after you approve does it start creating, updating, and destroying resources. As each operation completes, apply writes the resulting resource attributes (server-assigned IDs, IP addresses, ARNs) into state, which is how later plans know what already exists.

$ terraform apply

Terraform will perform the following actions:

  # google_compute_instance.app will be created
  + resource "google_compute_instance" "app" {
      + name         = "app-server"
      + machine_type = "e2-medium"
      + zone         = "europe-west1-b"
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

google_compute_instance.app: Creating...
google_compute_instance.app: Creation complete after 14s [id=projects/demo/zones/europe-west1-b/instances/app-server]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

If the refreshed state already matches the configuration, the plan is empty: apply reports No changes. Your infrastructure matches the configuration. and finishes without asking for approval, because there is nothing to approve.

While it runs, apply holds the state lock (on backends that support locking) so concurrent runs cannot corrupt state. If another run holds the lock, -lock-timeout=DURATION (for example -lock-timeout=60s) makes Terraform keep retrying the lock for that long before giving up. It only affects lock acquisition — it does nothing about provider API rate limits, which are what -parallelism is for.

The approval prompt and running apply unattended

The interactive prompt exists because apply is the one command that changes real infrastructure: Terraform shows the plan and accepts only the literal word yes before proceeding. Anything else aborts with no changes made. This is the default whenever apply generates its own plan.

$ terraform apply -auto-approve
$ terraform destroy -auto-approve

-auto-approve skips the prompt entirely — apply plans and immediately executes. It exists for automation: a scripted pipeline or an ephemeral test environment has no human to type yes. There are exactly two ways apply proceeds without an interactive approval: you passed -auto-approve, or you supplied a saved plan file (covered next), where the review already happened at plan time. In shared or production environments, prefer the saved-plan pattern over -auto-approve — auto-approving a fresh plan means executing changes nobody looked at.

-input=false, TF_INPUT and TF_IN_AUTOMATION are not approval

-input=false disables all interactive prompts. That includes the approval prompt, so a bare terraform apply -input=false (no saved plan, no -auto-approve) cannot ask for approval and Terraform conservatively assumes you do not want to apply: the run fails without changing anything. It also turns the prompt for an unset required variable (one with no default and no value from -var, a tfvars file or TF_VAR_name) into an error. That is why pipelines combine the two: -input=false makes missing inputs fail fast, while -auto-approve (or a saved plan) supplies the approval — -auto-approve approves the plan only; it never fills in a missing variable.

SettingWhat it doesApproves the apply?
-auto-approveSkips the approval promptYes
Saved plan fileExecutes an already-reviewed plan without promptingYes (approval happened at plan time)
-input=falseDisables all prompts; missing input becomes an errorNo — apply without a saved plan fails
TF_INPUT=0 (or false)Environment-variable form of -input=falseNo
TF_IN_AUTOMATION (any non-empty value)Only adjusts output, e.g. stops suggesting the next command to typeNo

Applying a saved plan file

Passing a saved plan file makes apply execute exactly the actions that were reviewed: terraform plan -out=tfplan followed by terraform apply tfplan. Because the plan was already generated and reviewed, apply does not re-plan and does not prompt — it goes straight to execution. This is the standard CI/CD shape: the plan stage produces the artifact and a human or policy check approves it; the apply stage consumes the identical artifact.

$ terraform plan -out=tfplan
$ terraform show tfplan     # review what will happen
$ terraform apply tfplan
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

A plan file is a snapshot of every planning decision. It contains the proposed actions, a copy of the configuration and the input variable values in effect at plan time. Consequences:

  • Editing a .tf or .tfvars file after the plan was saved — for example changing a variable's default — has no effect on terraform apply tfplan; it runs what was planned. To pick up the edit, plan again.
  • You do not pass -var or -var-file again, and apply rejects extra planning options with a saved plan (such as -target, -replace or -destroy), because those decisions are already baked in. The exception is ephemeral input variables (Terraform 1.10 and later), whose values are deliberately not stored and must be supplied again at apply.
  • The plan is tied to the state it was computed from. If state has changed since (another apply ran in between), Terraform rejects the plan as stale and you must generate a new one. Staleness is judged against state, not against the configuration files.
  • The file is binary and may contain sensitive values — inspect it with terraform show (or terraform show -json for tooling) and store it securely.

The same works for teardowns: terraform plan -destroy -out=teardown.tfplan saves a destroy plan, and terraform apply teardown.tfplan executes it without a prompt — the approval was the review of the saved plan.

When an apply fails partway: no rollback

Terraform apply is not transactional: if an operation fails partway through, nothing that already succeeded is undone. Resources created before the failure stay created and are recorded in state as each one completes; the failed and not-yet-attempted operations are reported as errors. The fix is to correct the cause (a missing permission, a bad argument) and run terraform apply again — the new plan compares configuration against the updated state and proposes only the remaining work. Do not destroy and start over, and do not delete the state.

One special case: a creation-time provisioner (such as remote-exec) that fails with the default on_failure = fail leaves the newly created object in state marked tainted. The object exists, but Terraform no longer trusts it, so the next plan proposes to replace it (destroy, then create). Setting on_failure = continue instead ignores the provisioner error and keeps the resource untainted.

FailureWhat state recordsWhat the next plan proposes
A resource's create/update call failsEverything that completed before itOnly the remaining changes
Creation-time provisioner fails (default on_failure = fail)The object, marked taintedReplace the tainted object

Parallelism: how apply orders and batches work

Apply walks Terraform's resource dependency graph, not your file order: resources that depend on each other run strictly in dependency order, and resources with no relationship run concurrently. By default Terraform performs at most 10 concurrent operations; -parallelism=n changes that ceiling for a single run of plan, apply or destroy.

$ terraform apply -parallelism=5    # gentler on rate-limited APIs
$ terraform apply -parallelism=30   # more concurrency for large graphs

Lowering parallelism helps when a provider's API throttles you; raising it can shorten applies with many independent resources. Parallelism is a runtime flag, not something you set in configuration, and it never overrides dependencies. A resource is only created once the values it references are known, so a chain where each resource references the previous one always runs one link at a time — a subnet that references a VPC waits for the VPC no matter how high the limit is set. Only the independent resources benefit from a higher ceiling.

terraform destroy: tearing down managed infrastructure

terraform destroy destroys every remote object tracked in the current state of the working directory. Like apply, it first shows you a plan (all lines marked -) and requires you to type yes; -auto-approve skips the prompt. Destruction runs in reverse dependency order: dependents are destroyed before the things they depend on, so an instance goes before its security group or subnet, and the subnet before its VPC. Independent deletions run concurrently, up to the same -parallelism limit (default 10) as apply.

$ terraform destroy

Terraform will perform the following actions:

  # aws_instance.web will be destroyed
  - resource "aws_instance" "web" {
      - ami           = "ami-0c101f26f147fa7fd"
      - instance_type = "t3.micro"
    }

Plan: 0 to add, 0 to change, 1 to destroy.

Do you really want to destroy all resources?

  Enter a value: yes

Destroy complete! Resources: 1 destroyed.

terraform destroy is an alias for terraform apply -destroy — the same destroy planning mode expressed as an apply flag. To preview a teardown without executing it, run terraform plan -destroy, or save it with terraform plan -destroy -out=tfplan and apply that file later.

What destroy deletes — and what it leaves

Destroy plans from state, not from the resource blocks you can see, so commenting out or deleting a block does not shield it from a destroy (and editing configuration is not a way to narrow one — use -target). Its scope:

DestroyedNot touched
Every managed resource in the current state, including resources created inside child modulesObjects only read through data blocks — Terraform never manages them
Only the selected workspace's state (with dev selected, prod's resources are untouched)Resources created outside Terraform or already removed from state; your configuration files; and the state file itself (it remains, now tracking no resources)

Destroy is routine for ephemeral environments — spin up a test stack, run the tests, destroy it — and rare, deliberate, and heavily reviewed for production.

Protecting resources with prevent_destroy

prevent_destroy is a lifecycle meta-argument that makes Terraform reject, with an error, any plan that would destroy the resource's object. It defaults to false; set it to true on objects that are costly to recreate, such as a production database.

resource "aws_db_instance" "main" {
  # ...
  lifecycle {
    prevent_destroy = true
  }
}

Two behaviours matter:

  • It fails the plan rather than skipping the resource. A full terraform destroy in a configuration containing a protected resource errors at plan time and destroys nothing — not even the unprotected resources beside it. The same applies to an apply whose plan would replace the protected resource.
  • It only protects while it is in the configuration. If someone deletes the whole resource block, the prevent_destroy setting goes with it, and the next normal terraform apply plans to destroy the object. Code review on removed blocks is the real safeguard.

The common ways a single managed object ends up destroyed outside a full teardown are therefore: removing its block and applying (the normal, permanent way), a targeted destroy, a plan that replaces it, or a count/for_each change that drops that instance. Each of these is still blocked while prevent_destroy = true remains on the block.

Targeted operations with -target

-target restricts a plan, apply, or destroy to specific resource addresses. To remove a single resource while leaving the rest of the stack running, target it in a destroy:

$ terraform destroy -target=aws_instance.web
$ terraform apply -target=module.network    # apply just one module subtree

The flag accepts resource addresses (aws_instance.web), module paths (module.network), and can be repeated to name several targets. Terraform automatically widens the selection where the graph requires it: a targeted apply also includes the target's dependencies, and a targeted destroy also includes everything that depends on the target. Targeting a VPC that four subnets reference therefore plans to destroy the VPC and the four subnets — you cannot destroy a network and leave its subnets orphaned in state. Read the destroy plan before confirming.

-target is for exceptional situations — recovering from errors, working around bugs, surgically removing one resource — not for routine use. Routinely applying subsets leaves infrastructure drifting from what a full plan would produce, and Terraform prints a warning on every targeted run for that reason. Related tools do different jobs:

ToolEffect on the real objectEffect on state
terraform destroy -target=ADDRDestroyed (with its dependents)Removed
terraform apply -replace=ADDRDestroyed and recreatedUpdated with the new object
terraform state rm ADDRLeft running, unmanagedRemoved
Delete the block, then terraform applyDestroyedRemoved — the permanent, recommended way

terraform fmt: canonical formatting

terraform fmt rewrites Terraform configuration files (.tf and .tfvars) in place into HashiCorp's canonical style and prints the names of the files it changed. It needs no init, no providers and no credentials, and it performs no validity checking beyond parsing the files: fmt does not validate, and validate does not format.

What it changes: indentation, spacing, and alignment of = signs within a block. It keeps blocks and arguments in the order you wrote them — it aligns, it never sorts. It also makes a few minor readability adjustments to the expressions themselves, such as unwrapping an interpolation-only string like "${var.region}" to var.region and unquoting a legacy quoted type constraint like type = "string". So "cosmetic" means the configuration's behaviour never changes, not that only whitespace is touched. Because the canonical style can shift slightly between Terraform versions, run fmt after upgrading.

Scope. The usage is terraform fmt [options] [target...]. With no target it processes only the current directory; -recursive descends into subdirectories. A target can be a directory or file — terraform fmt modules/network formats just that directory (add -recursive to include its subdirectories).

$ terraform fmt
main.tf

$ terraform fmt -recursive              # format the whole tree
$ terraform fmt -check -recursive       # CI gate: list unformatted files, exit non-zero
$ terraform fmt -check modules/network  # check one directory
$ terraform fmt -check -diff            # show what would change, modify nothing
FlagRewrites files?OutputExit status when files need formatting
(none)YesNames of changed files0
-checkNo (implies -write=false)Names of unformatted filesNon-zero — fails a CI build
-diffYes, unless combined with -check or -write=falseLine-level diff of the changes0 on its own
-write=falseNoNames of unformatted files0
-list=falseYesDoes not list file names0
-recursiveYes (subdirectories too)Names of reformatted files, as by default0

The near-twins to keep apart: -check and -write=false both leave files unchanged and list the offenders, but only -check exits non-zero — use it to fail a build, and -write=false when a script must continue. -diff alone still rewrites the files; to only see the changes, pair it with -check or -write=false.

Validate recap and the full pipeline order

terraform validate is the correctness counterpart to fmt's style: it verifies that the configuration is syntactically valid and internally consistent — legal argument names, resolvable references, matching types — without contacting any cloud API or reading state. It requires an initialized directory (provider schemas come from init), so CI jobs without backend credentials run terraform init -backend=false first. Use -json for machine-readable diagnostics.

Put together, the canonical automated pipeline the exam expects is:

OrderCommandPurposeKey flags
1terraform fmtEnforce canonical style-check, -recursive, -diff, -write=false
2terraform initInstall providers/modules, configure backend-backend=false, -upgrade
3terraform validateSyntax and consistency check, offline-json
4terraform planProduce and review the execution plan-out, -detailed-exitcode
5terraform applyExecute the reviewed plansaved plan file, -auto-approve, -parallelism
—terraform destroyTear down managed infrastructure-auto-approve, -target, -parallelism

Memorize the flag-to-command pairings in that table — a classic question format offers -check or -auto-approve against the wrong command and asks which invocation is valid.

Tip. Expect questions on when the apply approval prompt appears and the two ways to bypass it (-auto-approve or a saved plan file), contrasted with -input=false, TF_INPUT and TF_IN_AUTOMATION, which do not approve anything. Saved plans: what they fix at plan time (configuration and variable values) and when they are stale. Partial failures (no rollback, tainted resources after a failed provisioner), the default parallelism of 10 and what -parallelism cannot change. For destroy: equivalence with apply -destroy and plan -destroy, exactly what is and is not deleted (workspace, child modules, data sources), prevent_destroy behaviour and its caveat, and -target as an exceptional-use flag that pulls in dependents. For fmt: -check versus -write=false versus -diff, -recursive or a path for scope, and that fmt formats but never validates.

Key takeaways
  • terraform apply is the only core-workflow command that changes infrastructure: it plans, waits for the literal word yes, executes in dependency order, and records results in state while holding the state lock. An empty plan finishes without a prompt.
  • Apply skips the approval prompt in exactly two cases: -auto-approve, or applying a saved plan file. -input=false (or TF_INPUT=0) disables prompts, so a bare apply fails; TF_IN_AUTOMATION only adjusts output.
  • A saved plan embeds the actions, a configuration snapshot and variable values: later .tf/.tfvars edits do not affect it, extra planning options are rejected, and it is refused as stale if state changed since it was created.
  • Apply is not transactional: completed operations stay in state and you re-run apply after fixing the cause. A failed creation-time provisioner leaves the resource tainted, so the next plan replaces it.
  • Up to 10 operations run concurrently by default; -parallelism=n changes the ceiling for plan, apply or destroy but never overrides dependencies. -lock-timeout only governs retrying the state lock.
  • terraform destroy (= terraform apply -destroy) deletes everything in the selected workspace's state, including child-module resources, in reverse dependency order; data-source objects, unmanaged resources, the configuration and the state file remain.
  • prevent_destroy = true makes any destroying plan error (a full destroy deletes nothing), but it protects only while the block is in configuration.
  • -target is exceptional-use; a targeted destroy also removes the target's dependents. terraform fmt rewrites in place (current directory unless -recursive or a path is given); -check exits non-zero, -write=false exits 0, -diff alone still writes, and fmt never validates or reorders.
  • Pipeline order: fmt -check, init, validate, plan -out, then apply the saved plan.

Frequently asked questions

When does terraform apply skip the approval prompt?

In two cases only: when you pass -auto-approve, or when you apply a saved plan file (terraform apply tfplan), where the review already happened when the plan was generated. -input=false does not count — it disables all prompts, so an apply without a saved plan or -auto-approve fails instead of proceeding. If the plan has no changes, apply simply finishes with nothing to approve.

What is the difference between -auto-approve and -input=false?

-auto-approve approves the plan, so apply proceeds without a prompt. -input=false disables every interactive prompt, including approval, so Terraform assumes you do not want to apply and the run fails; it also turns a prompt for an unset required variable into an error. Pipelines often use both: -input=false so missing inputs fail fast, and -auto-approve (or a saved plan) for approval. TF_INPUT=0 is the environment-variable form of -input=false, and TF_IN_AUTOMATION only adjusts output.

Is using -target a normal way to work with Terraform?

No. -target is meant for exceptional situations, such as recovering from an error or surgically removing one resource. Routinely applying subsets of the configuration leaves infrastructure out of step with what a full plan would produce, which is why Terraform prints a warning on every targeted run. Terraform also widens a target where the dependency graph requires it: a targeted apply includes the target's dependencies, and a targeted destroy includes everything that depends on the target.

What does terraform destroy leave behind?

terraform destroy (an alias for terraform apply -destroy) deletes every managed resource in the selected workspace's state, including resources created by child modules. It leaves objects that are only read through data blocks, resources created outside Terraform, other workspaces' resources, your configuration files and the (now empty) state file. Because it plans from state, commenting out a resource block does not exclude it from a destroy.

What does prevent_destroy do during terraform destroy?

prevent_destroy = true in a resource's lifecycle block makes Terraform reject any plan that would destroy that object with an error. A full terraform destroy therefore fails at plan time and deletes nothing, rather than skipping the protected resource. The protection lasts only while the setting is in configuration: if the whole resource block is deleted, a normal apply will destroy the object.

Does terraform apply roll back if something fails midway?

No. Terraform has no rollback: resources created before the failure remain and are recorded in state, and the failed and unattempted operations are reported. You fix the cause and run apply again, and the new plan proposes only the remaining work. If a creation-time provisioner failed with the default on_failure = fail, the created resource is marked tainted and the next apply replaces it.

How many resources does Terraform change at once, and can I control it?

By default, at most 10 resource operations run concurrently. Pass -parallelism=n to terraform plan, apply or destroy to change the ceiling for that run — lower it when a provider API rate-limits you, raise it to speed up large graphs of independent resources. It is a CLI flag, not a configuration setting, and dependencies always take precedence: a chain of resources that reference each other still runs one at a time.

How do terraform fmt -check, -diff and -write=false differ?

-check leaves files unchanged, lists files that are not canonically formatted, and exits non-zero if any exist, which fails a CI build. -write=false also leaves files unchanged and lists them, but exits 0. -diff shows line-level changes but, on its own, still rewrites the files; combine it with -check or -write=false to only see them. By default fmt covers only the current directory; add -recursive or pass a directory path.

Source

This lesson covers the "Core Terraform workflow" domain of the official Terraform 004 exam guide. Vendors revise their guides — check the source for the current version.

Test yourself on this topic
Practice now

Sign up free to mark lessons complete, bookmark topics and track your exam readiness.

Spotted a mistake in this lesson?