TerraformII · Terraform ArchitectureProduction Terraform
How Apply Executes a Plan
What you'll learn
- Describe how terraform apply walks the resource graph and dispatches per-resource operations
- Explain state lock acquisition, hold time, and release semantics
- Distinguish terraform apply, terraform apply -refresh-only, and terraform apply -target
- Recognise the structure of a partial-apply failure and the recovery procedure
- Apply parallelism and lock-timeout tuning to production applies
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
Apply is the moment the plan touches real infrastructure. Every operation that previously existed only in configuration, state, and plan output now makes an API call. Apply is also the only command that holds the state lock for an extended period and is the only command that can leave state half-written when it fails. This lesson covers how apply executes a plan: what the graph walker does per resource, how the state lock is acquired and released, and what happens when an apply fails halfway through.
What apply does, step by step
terraform apply (or terraform apply saved.tfplan)
|
|---> acquire state lock on backend
|
|---> (if no saved plan) compute plan from current state + config
|
|---> if plan empty -> release lock, exit 0
|
|---> (interactive) prompt: "Do you want to perform these actions?"
|
|---> walk the resource graph in topological order
| with up to -parallelism concurrent resource operations
|
|---> for each resource:
| pre-apply hook
| provider refresh (Read)
| provider plan (PlanResourceChange against refreshed state)
| provider apply (ApplyResourceChange)
| post-apply hook
|
|---> persist state to backend
|
|---> release state lock
The apply is the only Terraform command that holds the state lock for an extended period. Plan operations acquire the lock briefly, release it, and exit. Apply holds the lock from the moment it begins until the final state write succeeds.
The per-resource lifecycle
For each resource in the graph walk, Terraform Core runs a fixed sequence against the provider plugin:
+------------------------+
| Pre-apply hook (HCL | optional, runs the local-exec/provisioner
| lifecycle or external) | before the provider API call
+------------------------+
v
+------------------------+
| Provider Refresh | ReadResource -> get current real-world attrs
| (PlanResourceChange |
| precondition) |
+------------------------+
v
+------------------------+
| Provider Plan | PlanResourceChange:
| (re-plan for this | given prior state + desired config,
| resource against the | return the diff for this resource
| refreshed state) |
+------------------------+
v
+------------------------+
| Provider Apply | ApplyResourceChange:
| | make the API call(s)
| | return new attributes
+------------------------+
v
+------------------------+
| Post-apply hook | optional, runs after the API call returns
+------------------------+
The re-plan per resource is not the same as the top-level
terraform plan step. The top-level plan computed the diff against
state. During apply, the provider may have read refreshed attributes
that differ from the state at plan time. The per-resource re-plan
recomputes the diff against the freshly-read state to catch any
drift that happened between the top-level plan and this resource’s
apply.
State lock acquisition and release
Apply acquires the state lock at the start and holds it until the final state write succeeds.
# Lock info on local backend
.terraform/terraform.tfstate.lock.info
# Lock info on remote backend (S3 + DynamoDB example)
{
"ID": "a1b2c3d4-...",
"Operation": "OperationTypeApply",
"Info": "",
"Who": "alice@build-host-01",
"Version": "1.9.8",
"Created": "2026-08-13T14:00:00Z",
"Path": "terraform.tfstate"
}
Three behaviours matter in production:
-
Lock acquire timeout. By default, apply retries the lock for 200 ms ×
200 + backoff. The-lock-timeout=10sflag forces a hard wait. If a different process holds the lock and never releases, apply fails after the timeout. -
Lock release is conditional on state write. If apply fails before the state write, the lock is still released (because the next apply needs to read the state). If the process is killed between the lock acquire and the state write, the lock may be held indefinitely until the backend’s lock TTL expires (typically 30-60 seconds for remote backends, immediate for local backends).
-
Force unlock is a separate operation.
terraform force-unlock <ID>removes the lock. The ID is the value in the.lock.infofile. Force-unlock should be used only after verifying no other apply is running.
-parallelism: how many resources apply at once
terraform apply -parallelism=20
By default, Terraform runs up to 10 resource operations concurrently during apply. The number is a soft ceiling on the number of concurrent provider API calls per resource type. The graph walker topologically sorts resources and starts each one as soon as its dependencies finish.
Increasing -parallelism:
- Does not change the plan. The plan is independent of parallelism.
- Reduces wall-clock time when the graph has many independent resources.
- Can exhaust provider API rate limits, especially on cloud providers with per-second quotas.
- Can cause rate-limit errors mid-apply that did not occur during plan.
Decreasing -parallelism:
- Slows the apply.
- Useful when the provider is rate-limited.
- Useful when the apply is causing observable problems in the target environment (DNS resolution, API throttling).
A production default of 10 is reasonable. Tune downward when the provider is rate-limited, never tune upward beyond what the provider will accept.
-target: what it does and what it does not do
terraform apply -target=aws_instance.web
terraform apply -target=module.database
-target selects a subset of resources to apply. The selection
follows dependencies: if you target aws_instance.web and it
references aws_subnet.public.id, the subnet is included. If
other resources depend on aws_instance.web (for example, a DNS
record that uses the instance’s IP), they are not included
unless you target them too.
Two failure patterns that recur in production:
-
Target cuts a dependency. Targeting
aws_instance.webbut not the security group that was just modified leaves the instance attached to the old group. The apply succeeds but the real-world state is now wrong. -
Target cuts a dependent. Targeting
aws_security_group.webbut not the instance leaves the instance referencing the old group’s ID. The next non-targeted apply will see drift and re-propose the change.
-target is for emergency surgery, not for normal workflow. A
production apply should target nothing or target a complete
resource set.
-refresh-only: an apply that does not apply
terraform apply -refresh-only
A special apply mode added in Terraform 0.15.4. The command:
- Refreshes every resource’s state from the real world.
- Updates the state file with the refreshed attributes.
- Does not propose any changes to the configuration.
The output is a plan that shows only updates to state, not updates to infrastructure:
Terraform will perform the following actions:
# aws_instance.web will be updated in-place
~ resource "aws_instance" "web" {
id = "i-0abc123"
~ tags = {
+ "Environment" = "production"
}
# (4 unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.
The use case: after an out-of-band change (an operator modified a
tag in the cloud console, an auto-scaling event changed an
attribute), the next terraform plan shows the drift. Running
terraform apply -refresh-only updates state to match reality
without forcing you to either add the attribute to configuration
or ignore the drift.
terraform apply -refresh-only is the correct response to “I made
a change outside Terraform and I want Terraform to know about it
without making it part of the configuration.”
Production failure modes
| # | Failure mode | Observable symptom | Recovery |
|---|---|---|---|
| 1 | Lock not released after process kill | Error: Error acquiring the state lock | Find the process holding the lock; if none, wait for backend TTL or force-unlock with explicit justification |
| 2 | Partial apply — provider API error mid-apply | Apply complete! Resources: 4 added, 0 changed, 0 destroyed. after a 10-resource plan | Investigate the failed resource; fix the cause; re-plan; the next plan proposes only the remaining 6 resources |
| 3 | -target cuts a dependency graph edge | Apply succeeds; the next non-targeted plan shows drift | Re-plan; apply without -target; investigate why the targeted subset diverged from the rest |
| 4 | Pre-apply hook script fails | Apply aborts before any provider API call; state unchanged | Fix the hook script; re-plan; re-apply |
| 5 | Provider API rate limit hit during high-parallelism apply | Error: 429 Too Many Requests mid-apply | Re-plan; re-apply with -parallelism=5; request quota increase if the apply is expected to be larger |
| 6 | Saved plan file invalidated (config changed, state changed, wrong workspace) | Error: Saved plan is stale | Discard the saved plan; re-plan from scratch; archive the stale plan for audit |
How to recover from a partial apply
The recovery procedure for any partial-apply failure (provider error, network error, process kill):
- Identify the cause. Read the apply log; find the first error.
- Fix the cause. If it was a permissions error, grant the permission. If it was a quota error, request a quota increase. If it was a network error, verify connectivity.
- Re-plan without changing the configuration.
terraform planshould now propose only the resources that were not yet applied. The plan output should be a subset of the original plan. - Compare the new plan to the original plan. Confirm that the missing resources match what was supposed to apply. If the new plan includes unexpected resources, investigate state for corruption.
- Apply. Use
-parallelism=5if the failure was rate-limit-related.
A partial apply is recoverable, but only if state is intact. State corruption requires restoring from backup before any recovery.
Security implications
- The state lock metadata is visible to anyone who can read the
backend. For remote backends, this means the IAM policy that
allows
dynamodb:GetItemon the lock table. The lock metadata exposes the hostname and username of the apply operator. In a multi-tenant AWS account, that may be more than you want to leak. - The lock ID is sensitive. Anyone with the lock ID can call
terraform force-unlock. Treat the lock ID as a short-lived secret. - Saved plan files contain sensitive values. The plan
contains the full resource attributes after the change. If a
resource has a
passwordargument, the plan file has it. Save plan files to artifact storage with the same access controls as state. - Apply runs hooks as the user running terraform. If a pre-apply or post-apply hook is misconfigured to run a shell command from configuration, the command runs as the apply operator.
Performance implications
- Lock acquisition is not free. A remote backend lock
acquisition is one HTTP request and one DynamoDB read. In a
CI/CD pipeline that runs thousands of applies per day, this
adds up. Use
-lock-timeout=0if you can guarantee no other apply is running (e.g. in a per-merge-request ephemeral workspace). - Per-resource refresh is sequential per resource. The apply
refreshes each resource before applying it. For a 1,000-resource
estate, the refresh is 1,000 read API calls. The
-refresh=falseflag skips the per-resource refresh; the top-level plan refresh still runs. - Parallelism beyond provider rate limits wastes time. A 50 way concurrent apply against a rate-limited provider returns errors and retries. The wall-clock time is worse than a 5-way apply that never retries.
Production guidance
- Always use saved-plan apply in CI/CD.
terraform plan -out=tfplanthenterraform apply tfplanis the only way to guarantee the apply matches what was reviewed. - Tune
-parallelismper provider. AWS, GCP, Azure, and Proxmox all have different rate limits. Default to 10; lower it if you see 429s. - Set
-lock-timeoutexplicitly. Do not rely on the default retry behaviour. A short lock timeout (10s) is appropriate when CI runs applies; a longer one (2m) is appropriate when operators run applies interactively. - Audit saved plan files. They contain sensitive data. Treat them as production artefacts.
- Never use
-targetin normal workflow. Reserve it for incident response.
Verification
- Why does apply hold the state lock for the duration of the apply, but plan only holds it briefly?
- What does the per-resource re-plan during apply catch that the top-level plan does not?
- When is
terraform apply -refresh-onlythe correct response to drift? - What is the difference between targeting a resource with
-targetand not applying its dependencies? - How do you recover from a partial apply that left state half-written?
Knowledge check · 7 questions
Q1. What does terraform apply hold the state lock for?
Q2. What is the purpose of the per-resource re-plan during apply?
Q3. terraform apply -refresh-only updates state to match reality and leaves the real-world infrastructure untouched.
Q4. What happens when you run apply -target=aws_instance.web and the instance depends on aws_security_group.web, but you do not target the security group?
Q5. Which of the following are part of the per-resource lifecycle during apply? (Select all that apply.)
Q6. What is the default parallelism for terraform apply in 1.9.x?
Q7. A team runs terraform apply with a 20-resource plan. After 12 resources succeed, the 13th returns a 429 Too Many Requests from the provider. The apply exits with an error. What should the team investigate first?
Passing score: 75%. Answers are checked in this browser.