Skip to main content
RunBook Academy

TerraformVII · Resources, Data Sources, and count/for_eachProduction Terraform

Resource Replacement and In-Place Updates

Intermediate⏱ ~14 minbash

What you'll learn

  • Distinguish in-place update from forced replacement in the plan output
  • Identify which provider schema attributes are immutable (ForceNew)
  • Use -replace and lifecycle.replace_triggered_by to control replacement
  • Configure create_before_destroy to avoid downtime during replacement

Prerequisites

None — start here.

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

Not yet marked complete on this device.

A team needs to change the instance class of an RDS database. They edit the configuration, run terraform plan, and see -/+. They expected ~. They approve the plan. Production goes down for ten minutes while RDS tears down the old instance and provisions a new one. The mistake was not a misconfiguration; it was a misreading of the plan. This lesson is the operational discipline around replacement.

Update versus replace

When a configuration attribute changes, Terraform consults the provider schema to decide between two operations:

  • In-place update (~). The provider’s Update API is called. The resource persists; only the changed attribute is mutated. There is no downtime from the provider’s perspective, though the resource may briefly enter a modifying state (e.g., an EC2 instance rebooting for an EBS volume resize).
  • Forced replacement (-/+ or +/-). The provider’s attribute is marked ForceNew: true in its schema. The provider has no Update API for that attribute; the only way to change it is to destroy the old resource and create a new one. The provider decides this, not Terraform.
Configuration change
        |
        v
+-------------------+        Yes        +-----------------+
| Attribute marked  |  -------------->  |  Replace ( -/+ )|
| ForceNew in schema|                   +-----------------+
+-------------------+
        | No
        v
+-------------------+        Yes        +-----------------+
| Provider has an   |  -------------->  |  Update ( ~ )   |
| Update API for it |                   +-----------------+
+-------------------+
        | No
        v
+-------------------+
| Replace anyway    |
| (impossible)      |
+-------------------+

Real example. An EC2 instance:

resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.small"
}
  • instance_type — mutable. Changing t3.small to t3.medium triggers Update.
  • ami — ForceNew: true. Changing the AMI triggers -/+.
  • subnet_id — ForceNew: true. Moving a subnet triggers -/+.

To see this in the schema:

terraform providers schema -json | \
  jq '.provider_schemas."registry.terraform.io/hashicorp/aws".resource_schemas."aws_instance".block.attributes.ami'
{
  "type": "string",
  "required": true,
  "force_new": true
}

The force_new key is the field that controls the decision.

The -replace flag

Terraform 0.15.2 introduced -replace (alias: -target for replacement). It forces a replacement regardless of whether the attribute is ForceNew. Use it for: state-only refreshes of a specific resource, or to recover from drift that requires a rebuild.

terraform apply -replace="aws_instance.web"
  # aws_instance.web must be replaced
-/+ resource "aws_instance" "web" {
        ~ ami           = "ami-0c55b159cbfafe1f0" -> "ami-0a1b2c3d4e5f67890" # forces replacement
        id            = "i-0abcd1234ef567890"
        # (16 unchanged attributes hidden)
    }

The CLI flag forces the action even if the configuration did not change. The provider then performs a destroy on the old instance and a create of the new one.

The lifecycle block

The lifecycle block is the right place to control replacement behaviour. Three meta-arguments matter for this lesson:

  • create_before_destroy = true — reverses the order. Create the new resource first, then destroy the old. Essential for stateful resources where the new instance must be ready before the old one is torn down.
  • prevent_destroy = true — blocks any plan that would destroy the resource. Use for production databases, S3 buckets with object lock, KMS keys.
  • replace_triggered_by = [...] — forces replacement when the referenced resource or attribute changes. Use to model cascading changes (e.g., rotate a secret and rebuild the database).
resource "aws_db_instance" "primary" {
  identifier        = "prod-db"
  engine            = "postgres"
  engine_version    = "16.3"
  instance_class    = "db.r6g.large"
  allocated_storage = 100
  storage_type      = "gp3"

  lifecycle {
    create_before_destroy = true
    prevent_destroy       = true
  }
}
resource "aws_iam_role" "app" {
  name = "app-role"

  lifecycle {
    replace_triggered_by = [
      aws_iam_policy.app.arn
    ]
  }
}

The combination create_before_destroy = true plus prevent_destroy = true is the canonical pattern for production databases. The first rule ensures the new instance is ready before the old is torn down; the second rule blocks an accidental destruction by a misread plan.

Reading replacement in the plan

Three patterns to memorise:

# In-place update — no destroy
  ~ resource "aws_instance" "web" {
      ~ instance_type = "t3.small" -> "t3.medium"
    }
# Forced replacement — destroy first, then create (the default)
-/+ resource "aws_instance" "web" {
      ~ ami = "ami-old" -> "ami-new" # forces replacement
    }
# Forced replacement with create_before_destroy — create first, then destroy
+/- resource "aws_instance" "web" {
      ~ ami = "ami-old" -> "ami-new" # forces replacement
    }

The difference between -/+ and +/- is the order of operations. For stateful resources, +/- is almost always what you want, because it avoids the gap where neither the old nor the new resource exists.

Production failure modes

  1. Misreading ~ for -/+. The plan symbol is -/+, but the operator reads it as ~ (in-place). Production is destroyed. Symptom: the apply succeeds with no errors, but the resource is a new instance with a new ID. References that depended on the old ID are stale. Recovery: restore from state backup taken before the apply; if the new resource is acceptable, run terraform state show and update the dependents.

  2. No create_before_destroy on a stateful resource. An RDS instance is replaced with -/+. There is a ten-minute gap where the database is unavailable. Symptom: application health checks fail during the apply window. Recovery: add create_before_destroy = true to the lifecycle block; verify with terraform plan that the symbol is now +/-.

  3. Replacing a resource with instance_store data. The provider marks instance_type as ForceNew. The replacement destroys the instance, losing all instance-store data. Symptom: application data missing on the new instance. Recovery: ensure data is on EBS, not instance store, before changing instance class.

  4. replace_triggered_by pointing at the wrong resource. The lifecycle references a resource that changes on every plan. Every plan now shows -/+. Symptom: apply times grow because every dependent resource rebuilds. Recovery: narrow the trigger to the specific attribute that should cause a rebuild.

  5. prevent_destroy = true blocking a legitimate teardown. A team needs to destroy an environment as part of a decommission. The lifecycle block prevents the destroy. Symptom: Error: Instance cannot be destroyed. Recovery: remove the lifecycle block (or set prevent_destroy = false) and apply; restore the block afterwards if the resource is still needed.

  6. Concurrent applies during a replacement. Two operators approve a replacement at the same time. The state lock blocks the second, but the first runs to completion. The second, when unblocked, attempts to replace again. Symptom: the resource is replaced twice; the state file references an instance that no longer exists. Recovery: terraform plan -refresh-only to reconcile; terraform state rm followed by terraform import if the live resource needs to be re-adopted.

Security and performance

Security. Replacement destroys the old resource. If the resource holds data (database, EBS, S3, KMS key), the destroy step must be audited. For S3 buckets with object lock, the destroy fails; for databases without a final snapshot, the data is lost. Always configure skip_final_snapshot = false for RDS.

Performance. A replacement is at minimum one Create + one Delete. For large resources (RDS, ElastiCache, EFS), this is expensive. Use create_before_destroy = true so the new resource is provisioned before the old is torn down, but accept that the provisioning time is doubled.

What to do in production

  • Read the plan symbol. -/+ and +/- are destructive. ~ is not. There is no ambiguity.
  • For stateful resources (databases, caches, queues), always set create_before_destroy = true. For objects that must not be destroyed (data buckets, compliance logs), always set prevent_destroy = true.
  • Test replacement in a non-production environment first. Watch the apply. Confirm the new resource comes up before the old is torn down.
  • Run terraform plan -replace=... before terraform apply -replace=.... The plan output is the same as the apply output.
  • For databases, configure automated snapshots before any replacement.

Verification

# 1. Inspect which attributes are ForceNew for a resource
terraform providers schema -json | \
  jq '.provider_schemas."registry.terraform.io/hashicorp/aws".resource_schemas."aws_db_instance".block.attributes | to_entries[] | select(.value.force_new == true) | .key'

# 2. Plan a change and confirm the replacement symbol
terraform plan -out=tfplan
terraform show -json tfplan | jq '.resource_changes[] | select(.change.actions | length > 1) | {address: .address, actions: .change.actions}'

# 3. Confirm lifecycle is configured as expected
grep -A3 'lifecycle' main.tf

# 4. Verify the resource is healthy after a replacement
terraform state show aws_db_instance.primary
aws rds describe-db-instances --db-instance-identifier prod-db

# 5. Confirm the new resource is the one referenced in state
terraform refresh
terraform plan  # should be empty

A clean verification:

$ terraform plan
No changes. Your infrastructure matches the configuration.

The plan is empty. The resource that was just replaced is the same resource in state that the configuration describes.

Knowledge check · 7 questions

  1. Q1. What does `ForceNew: true` in a provider schema attribute mean?

  2. Q2. What does `create_before_destroy = true` do?

  3. Q3. Terraform decides whether a change requires replacement.

  4. Q4. Which of the following are valid lifecycle meta-arguments in Terraform 1.9.x? (Select all that apply.)

  5. Q5. What is the difference between the plan symbols `-/+` and `+/-`?

  6. Q6. What does `terraform apply -replace="aws_instance.web"` do?

  7. Q7. A production RDS instance must be resized from db.r6g.large to db.r6g.xlarge. The team runs `terraform plan` and sees `-/+`. They want no downtime. What is the minimum change required?

Passing score: 75%. Answers are checked in this browser.