Definition
- Safe upgrade workflow means advancing module, provider, and Terraform core versions in controlled, reviewed steps while reading changelogs for breaking changes
- -/+ (must be replaced) in a plan means Terraform will destroy the existing resource and create a new one because an immutable attribute changed
- ~ (update in place) means Terraform will modify the resource without recreating it, which is almost always safe
- Immutable attributes are ones the cloud API cannot change on an existing object, so Terraform must replace the whole resource
- Provider version bumps are especially sneaky because a new provider may change how an attribute is computed, silently turning a no-op into a forced replacement
The National Bank of India has an old ATM controller that has not been patched in three years. You cannot just rip it out and bolt in the newest model during business hours: Rajesh and Priya are withdrawing cash right now. You read the manufacturer's release notes to learn what changed, you test the new controller in a closed training branch first, and above all you read the installation plan. If the plan says "remove old controller and install new one" (replace) rather than "update firmware in place," that is a red alert, because removing the controller empties the cash cassette and interrupts service.
Understanding the Concepts
What do the plan symbols mean?
A Terraform plan prints a symbol for each resource it will change. Understanding these symbols is the first skill in safe upgrades.
| Symbol | Meaning | Risk Level | |---|---|---| | `+` | Create a new resource | Low (new resource) | | `-` | Destroy an existing resource | High (data loss) | | `~` | Update in place | Low (no recreation) | | `-/+` | Destroy then recreate | High (data loss, downtime) | | `<=` | Read (data source) | None |
# Example plan output
# aws_db_instance.ledger must be replaced
-/+ resource "aws_db_instance" "ledger" {
~ engine = "postgres" -> "postgres" # forces replacement
~ engine_version = "13.4" -> "15.2" # forces replacement
~ id = "nbi-ledger-prod" -> (known after apply)
}
# aws_instance.transfer_service will be updated in-place
~ resource "aws_instance" "transfer_service" {
~ instance_type = "m5.large" -> "m5.xlarge" # safe in-place change
}
The ~ on the instance type is safe: AWS can resize an instance in place. The -/+ on the database is dangerous: it will destroy the database and create a new, empty one.
Why does a -/+ line deserve special attention?
Replacement happens because some attributes are immutable in the cloud API. The cloud provider has no API to mutate them on an existing resource, so Terraform's only option is destroy-and-recreate. For stateful resources like databases, EBS volumes, or Elastic IPs, the old data or address is gone after destruction.
# These attribute changes FORCE replacement (immutable):
# - aws_db_instance: engine change
# - aws_instance: availability_zone change
# - aws_subnet: cidr_block change
# - aws_s3_bucket: bucket name change
# These attribute changes are SAFE in-place updates (mutable):
# - aws_instance: instance_type, tags
# - aws_db_instance: instance_class, allocated_storage (increase)
# - aws_security_group: description, tags
What is the safe upgrade workflow?
The workflow has clear, sequential steps that prevent surprises in production.
# Step 1: Pin everything before you start
# Know your current baseline
cat versions.tf
# versions.tf -- pin current versions
terraform {
required_version = ">= 1.3.0, < 1.4.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.67.0" # Current provider
}
}
}
module "rds" {
source = "terraform-aws-modules/rds/aws"
version = "5.2.0" # Current module version
}
# Step 2: Read changelogs between current and target versions
# Look for: "breaking", "removed", "renamed", "forces replacement"
# Step 3: Bump ONE thing at a time
# Bump provider first, plan, then bump module, plan again
# Step 4: Test in non-production with production-like state
terraform workspace select staging
terraform plan
# Step 5: Read the plan -- every -/+ line is a red alert
terraform plan -out=upgrade.plan
# Review, then apply the saved plan
terraform apply upgrade.plan
How do you protect stateful resources from accidental replacement?
The lifecycle block in Terraform provides three protection mechanisms for resources that must not be accidentally destroyed or recreated.
resource "aws_db_instance" "ledger" {
identifier = "nbi-ledger-prod"
engine = "postgres"
engine_version = "15.2"
instance_class = "db.r6g.xlarge"
lifecycle {
# Terraform will error out rather than destroy this resource
prevent_destroy = true
# If replacement is unavoidable, create the new one first
create_before_destroy = true
# Stop Terraform fighting over attributes mutated externally
ignore_changes = [tags["LastBackup"]]
}
}
# With prevent_destroy, attempting to destroy produces:
Error: Instance cannot be destroyed
Resource aws_db_instance.ledger has lifecycle.prevent_destroy
set, but the plan calls for this resource to be destroyed.
What are common mistakes when upgrading?
The most dangerous mistake is bumping everything at once, because when something breaks you cannot tell which version caused it.
# WRONG: Bump provider, module, AND Terraform core in one go
terraform {
required_version = ">= 1.6.0" # was 1.3.0
required_providers {
aws = { version = "~> 5.30.0" } # was 4.67.0
}
}
module "rds" {
source = "terraform-aws-modules/rds/aws"
version = "6.3.0" # was 5.2.0
}
# If the plan shows a -/+, which version caused it?
# CORRECT: Bump one thing at a time
# First: provider 4.67 -> 5.30 alone, plan and test
# Then: module 5.2 -> 6.3 alone, plan and test
# Finally: Terraform core 1.3 -> 1.6, plan and test
How do provider version bumps silently force replacement?
A new provider version may change how an attribute is computed or add a default that differs from the old one, turning a no-op into a forced replacement even though you changed nothing in your own HCL.
# Before provider upgrade: plan shows no changes
No changes. Your infrastructure matches the configuration.
# After provider upgrade (same HCL, same state):
# aws_db_instance.ledger must be replaced
-/+ resource "aws_db_instance" "ledger" {
~ ca_cert_identifier = "rds-ca-2019" -> "rds-ca-rsa2048-g1"
# forces replacement (new provider default changed)
}
This is why you never upgrade providers blindly. You always run a plan after bumping and look specifically for -/+ lines.
What about terraform state mv for avoiding replacement?
When a replacement is being forced only because of a cosmetic rename or a provider recompute, you often do not want to replace at all. terraform state mv renames a resource in state without recreating it.
# A module renames aws_instance.web to aws_instance.app internally
# Without state mv, Terraform sees: destroy web, create app
# With state mv:
terraform state mv \
module.compute.aws_instance.web \
module.compute.aws_instance.app
# Now plan shows no changes -- the resource was just renamed in state
What should a senior engineer know?
A senior engineer saves the plan to a file with -out and applies that exact file, so the reviewed plan is exactly what runs with no drift between review and apply. They use -target sparingly to apply one resource at a time during a risky upgrade. They classify every -/+ by blast radius: a stateless security group rule is fine to replace, but a database or an Elastic IP is a data-loss event.
# Save and apply the exact reviewed plan
terraform plan -out=upgrade.plan
# Review the plan file
terraform show upgrade.plan
# Apply only after review
terraform apply upgrade.plan
What are common interview follow-up questions?
- What is the difference between
~and-/+? Tilde is update in place (safe), minus-plus is destroy and recreate (dangerous for stateful resources). - Can you prevent replacement entirely? Not if the attribute is truly immutable, but
prevent_destroyblocks the destroy step, andcreate_before_destroyensures the new resource exists before the old one is removed. - How do you know which attributes are immutable? The provider documentation marks them as "forces replacement" or "ForceNew."
Visual Overview
Safe Upgrade Workflow
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 1. Pin current│───▶│ 2. Read │───▶│ 3. Bump ONE │
│ versions │ │ changelogs │ │ at a time │
└───────────────┘ └───────────────┘ └───────┬───────┘
│
▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 6. Apply saved│◀───│ 5. Review plan│◀───│ 4. Plan in │
│ plan file │ │ for -/+ │ │ non-prod │
└───────────────┘ └───────────────┘ └───────────────┘
Plan Symbol Risk Matrix
┌──────────┬───────────────────┬────────────┐
│ Symbol │ Action │ Risk │
├──────────┼───────────────────┼────────────┤
│ + │ Create │ Low │
│ ~ │ Update in place │ Low │
│ -/+ │ Replace │ HIGH │
│ - │ Destroy │ HIGH │
└──────────┴───────────────────┴────────────┘
Key Takeaway
Upgrading a Terraform module that has sat untouched for a long time is a controlled change process, not a one-shot command. You start by pinning your current baseline: the Terraform core version with required_version, the provider with a required_providers constraint, and the module with an exact version tag on its source. Then you read the changelogs between your current and target versions, searching specifically for the words breaking, removed, renamed, and forces replacement. You bump one thing at a time, test in a non-production environment, and above everything else you read the terraform plan.
The reason the plan matters so much is the difference between the symbols it prints. A tilde means update in place and is almost always safe. A minus-plus, which Terraform also prints as "must be replaced," means Terraform will destroy the existing resource and create a new one, because some immutable attribute changed that the cloud API cannot mutate in place. Provider version bumps are especially sneaky here: a new provider may change how an attribute is computed, silently forcing a replacement even though you changed nothing in your HCL.
In the National Bank of India project, the team upgraded a two-year-old RDS module and the plan showed a minus-plus on the core ledger database, driven by nothing more than the new provider recomputing the certificate authority identifier. If they had applied that blindly, the database holding every transaction for Rajesh's SAV2001 and Priya's CUR3001 would have been destroyed and recreated empty. They classified every replacement by blast radius, protected the database with prevent_destroy, and used create_before_destroy on the stateless resources where replacement was acceptable.
Remember one sentence: a clean-looking version bump is never automatically safe; the plan, read line by line, is the only truth.
---