Advanced Lifecycle and Providers
Scattered through the series we've already touched the lifecycle block (prevent_destroy in Article 9, ignore_changes mentioned in passing), providers, and terraform_data. Part VI gathers them into a single article for tidiness, because these are the behavior-tuning tools you'll need as your infrastructure grows complex. Each feature comes with a real demo to show what it does.
Goal
Grasp the four lifecycle options, how to run multi-region with provider alias, terraform_data replacing null_resource, the final role of provisioners, and the check block for soft checks.
The four lifecycle options
The lifecycle block goes inside a resource and controls how Terraform creates/changes/destroys it.
prevent_destroy blocks any plan that intends to destroy the resource. Useful for things that must never be deleted by accident (a prod database, a state bucket):
resource "aws_s3_bucket" "important" {
lifecycle {
prevent_destroy = true
}
}
Try to destroy:
$ terraform destroy
Error: Instance cannot be destroyed
Resource aws_s3_bucket.important has lifecycle.prevent_destroy set, but the
plan calls for this resource to be destroyed.
Terraform refuses outright. To actually delete it, you have to edit the code and turn the flag off first — exactly the intent, turning deletion into a deliberate action rather than an accident.
ignore_changes tells Terraform to ignore changes to certain attributes when planning. Use it when an external process (autoscaling, another tool) modifies that attribute and you don't want Terraform pulling it back:
lifecycle {
ignore_changes = [tags]
}
After this line, a tag someone changes manually will no longer be reported as drift by Terraform (the opposite of the default behavior in Article 4). You can use ignore_changes = all to ignore everything.
create_before_destroy reverses the order when a resource must be replaced: create the new one first, then destroy the old one. By default Terraform destroys first and creates after, causing disruption; this flag avoids downtime for resources that can be replaced in parallel:
lifecycle {
create_before_destroy = true
}
replace_triggered_by forces a resource to be replaced when a referenced resource or attribute changes:
lifecycle {
replace_triggered_by = [aws_ecs_service.svc.id]
}
Provider alias: multi-region in one configuration
By default a configuration has one AWS provider for one region. To stand up resources in multiple regions at once, declare multiple providers distinguished by alias:
provider "aws" {
region = "ap-southeast-1" # default
}
provider "aws" {
alias = "us"
region = "us-east-1"
}
resource "aws_s3_bucket" "sg" {
bucket_prefix = "tf-series-bai17-sg-"
}
resource "aws_s3_bucket" "us" {
provider = aws.us # use the provider alias -> created in us-east-1
bucket_prefix = "tf-series-bai17-us-"
}
A resource that doesn't write provider uses the default provider; a resource that writes provider = aws.us uses the provider alias. Apply:
$ terraform output
sg_region = "ap-southeast-1"
us_region = "us-east-1"
Two buckets created in two different regions from a single apply. This is how you stand up multi-region infrastructure (cross-region backup, CloudFront needing a certificate in us-east-1) without splitting into multiple configurations. AWS provider v6 also supports multi-region at a finer level, but provider alias is the foundational approach and enough for most needs.
terraform_data replacing null_resource
When you need a "dummy" resource to attach triggers to or run a provisioner, people used to use null_resource (from the null provider). Since Terraform 1.4 there's the built-in terraform_data, no external provider needed:
resource "terraform_data" "deploy_marker" {
triggers_replace = [var.app_version]
input = "deployed ${var.app_version}"
}
triggers_replace takes a list; when a value in it changes, the resource is recreated. Change app_version from v1 to v2:
$ terraform plan -var app_version=v2
# terraform_data.deploy_marker must be replaced
-/+ resource "terraform_data" "deploy_marker" {
Plan: 1 to add, 0 to change, 1 to destroy.
terraform_data can also hold data through the input/output field, more convenient than null_resource. Rule: when you need this kind of resource, use terraform_data, don't pull in the null provider anymore.
Provisioner: a last resort
Provisioners (local-exec, remote-exec) run scripts when a resource is created/destroyed. They exist, but the documentation and the community both advise treating them as a last resort. The reason: provisioners sit outside the declarative model — Terraform doesn't know what the script does, can't plan for it, it's not idempotent, and a failure halfway through leaves the resource in a half-baked state. Prefer the alternatives: use user_data for EC2, cloud-init, a prebuilt AMI (Packer), or a configuration tool (Ansible) after Terraform stands up the infrastructure. Only drop down to provisioners when there's genuinely no other way.
check block: soft checks
Article 9 mentioned the check block (1.5) and promised it here. Unlike validation/precondition (which block apply), check only warns — it checks a condition at the end of the operation and doesn't break the deploy:
check "bucket_naming" {
assert {
condition = startswith(aws_s3_bucket.important.id, "tf-series")
error_message = "Bucket name should start with 'tf-series'."
}
}
When the condition is false:
$ terraform plan
Warning: Check block assertion failed
on main.tf line 33, in check "bucket_naming":
33: condition = startswith(aws_s3_bucket.important.id, "prod-")
Bucket name should start with 'tf-series'.
It's a Warning, not an Error — apply still runs. This is the core difference: a precondition is "if it fails, stop", while a check is "if it fails, let you know but keep going". Use check for conditions worth monitoring but not worth blocking a deploy over (an endpoint returns 200, a naming convention), so the warning surfaces without jamming the pipeline.
🧹 Cleanup
prevent_destroy blocks destroy, so you have to edit the code to turn the flag off before you can clean up:
# change prevent_destroy = true -> false, apply, then:
$ terraform destroy -auto-approve
Destroy complete! Resources: 2 destroyed.
This is also proof that prevent_destroy does its job right: to delete, you have to go through a deliberate step.
Wrap-up
The lifecycle block fine-tunes the lifecycle: prevent_destroy blocks accidental deletion, ignore_changes ignores external changes, create_before_destroy avoids downtime on replacement, replace_triggered_by forces replacement based on another resource. The provider alias enables multi-region in one configuration (provider = aws.us). terraform_data replaces null_resource with no external provider. Provisioners are a last resort, prefer user_data/cloud-init/Ansible. The check block warns instead of blocking, fitting for soft monitoring.
By now we have all the language and tools. Part VII turns to operations: how to run Terraform safely in CI/CD. Article 18 builds a GitHub Actions pipeline — automatic plan on pull requests, apply on merge, authentication via OIDC with no stored keys, and bolting the fmt/validate/tflint/Trivy/Checkov quality tools into the pipeline.