Terraform Resource Dependencies and Lifecycle Rules

Terraform dependency graph showing protected production resources and controlled replacement paths

What You’ll Learn

Terraform builds a dependency graph to decide which resources must be created, updated, or destroyed first. You can also use lifecycle meta-arguments to make production changes safer.

  • Understand implicit and explicit resource dependencies.
  • Use prevent_destroy to protect critical infrastructure.
  • Use create_before_destroy when replacement downtime is unacceptable.
  • Use ignore_changes when an external system intentionally manages part of a resource.
  • Recognize the limitations of lifecycle rules during refactoring and resource removal.

The Concept

Terraform normally determines resource creation order from references between resources. If one resource uses an attribute from another resource, Terraform creates the referenced resource first.

For example, this reference creates an implicit dependency:

bucket = aws_s3_bucket.production.id

Terraform understands that the S3 bucket must exist before it can configure the resource using the bucket ID.

Sometimes a dependency is real but not visible in an attribute reference. In that situation, use depends_on:

depends_on = [aws_iam_role_policy.app_permissions]

Use explicit dependencies sparingly. An unnecessary depends_on can make Terraform wait longer than necessary and can cause unrelated changes to be treated as dependent.

Lifecycle rules are meta-arguments placed inside a resource’s lifecycle block. They change how Terraform handles that resource:

  • prevent_destroy = true stops Terraform from destroying the resource through a normal plan.
  • create_before_destroy = true asks Terraform to create a replacement before destroying the old object.
  • ignore_changes tells Terraform not to react to changes made to selected attributes outside Terraform.
  • replace_triggered_by makes a resource replace when another resource or resource attribute changes.

These rules are useful for production infrastructure, but they do not override provider limitations. For example, a resource with a globally unique name might not support create-before-destroy because the old and new objects cannot exist simultaneously with the same name.

Basic Example

This configuration creates a production S3 bucket, enables versioning, and blocks public access. The bucket is protected against accidental destruction, while the other resources depend on it through attribute references.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

variable "aws_region" {
  type        = string
  description = "AWS region for production resources"
  default     = "us-east-1"
}

variable "bucket_name" {
  type        = string
  description = "Globally unique name for the production bucket"
}

resource "aws_s3_bucket" "production" {
  bucket = var.bucket_name

  lifecycle {
    prevent_destroy = true
  }
}

resource "aws_s3_bucket_versioning" "production" {
  bucket = aws_s3_bucket.production.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_public_access_block" "production" {
  bucket = aws_s3_bucket.production.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

Expected Output

The exact plan depends on the bucket name and the current AWS account. On the first apply, Terraform creates the bucket, then configures versioning and public-access protection. The dependency graph ensures both configuration resources wait for the bucket.

If a future plan would destroy the bucket, Terraform stops with an error instead of proceeding. A normal terraform apply cannot bypass prevent_destroy.

How the Code Works

Terraform configuration feeds a dependency graph. The graph creates the protected production bucket before its versioning and public-access settings, and creates the launch template before the Auto Scaling Group. Lifecycle controls protect the bucket from destruction, create a replacement launch template before removing the old one, and ignore externally managed Auto Scaling Group capacity changes.
Terraform orders resources from dependency references, while lifecycle rules control destruction, replacement, and externally managed changes.

The aws_s3_bucket.production resource is the protected production object. Its prevent_destroy rule is useful for data-bearing infrastructure where deletion would cause unacceptable loss.

Both supporting resources reference aws_s3_bucket.production.id. These references are more than value lookups: they tell Terraform how to order operations. Terraform creates the bucket first and only then configures versioning and public-access blocking.

The lifecycle rule does not prevent every possible deletion. If someone removes the bucket from the Terraform configuration, Terraform still detects that the object is no longer declared, but the plan is blocked by prevent_destroy. An operator must deliberately remove or change the protection before Terraform can destroy it.

The bucket name also matters. S3 bucket names are globally unique, so the value supplied for bucket_name must not already be used by another account. A lifecycle rule cannot solve naming conflicts.

After saving the configuration, a typical workflow is:

terraform init
terraform plan -var="bucket_name=replace-with-a-unique-production-name"
terraform apply -var="bucket_name=replace-with-a-unique-production-name"

Another Example

For a production application, replacing a launch template should not require a gap in the infrastructure definition. The following configuration uses create_before_destroy for the launch template and ignore_changes for the Auto Scaling Group’s desired capacity.

The desired capacity is ignored because an autoscaling policy or an operator may change it outside the Terraform configuration. Terraform still manages the minimum and maximum limits.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

variable "aws_region" {
  type    = string
  default = "us-east-1"
}

variable "ami_id" {
  type        = string
  description = "AMI used by the application instances"
}

variable "instance_type" {
  type        = string
  description = "EC2 instance type for the application"
  default     = "t3.micro"
}

variable "subnet_ids" {
  type        = list(string)
  description = "Subnets where the Auto Scaling Group can place instances"
}

resource "aws_launch_template" "application" {
  name_prefix   = "production-application-"
  image_id      = var.ami_id
  instance_type = var.instance_type

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_autoscaling_group" "application" {
  name                = "production-application"
  min_size            = 2
  max_size            = 6
  desired_capacity    = 2
  vpc_zone_identifier = var.subnet_ids

  launch_template {
    id      = aws_launch_template.application.id
    version = "$Latest"
  }

  lifecycle {
    ignore_changes = [desired_capacity]
  }

  tag {
    key                 = "Environment"
    value               = "production"
    propagate_at_launch = true
  }
}

The Auto Scaling Group references the launch template ID, so Terraform automatically knows that the group must use the replacement template. The launch template’s lifecycle rule allows Terraform to create the new template before removing the old one.

create_before_destroy reduces replacement downtime, but it does not guarantee that running application instances will be replaced instantly. The Auto Scaling Group still needs time to launch healthy instances and terminate old ones according to its deployment behavior.

Common Mistakes

  • Using depends_on for every resource: Prefer normal attribute references whenever possible. They communicate the exact dependency and allow Terraform to build a more efficient graph.
  • Assuming prevent_destroy is a backup: It prevents a Terraform destroy operation, but it does not protect against deletion through the cloud provider console, another account, or an unrelated tool.
  • Applying create_before_destroy to every resource: Some providers or APIs require unique names, fixed identifiers, or exclusive attachments. The old and new resources may not be able to coexist.
  • Ignoring changes without an ownership decision: ignore_changes can hide configuration drift. Use it only when another system intentionally owns that attribute.
  • Expecting lifecycle rules to prevent all replacements: prevent_destroy blocks destruction, but a provider may still require replacement for an immutable attribute. The resulting plan will fail rather than silently update the object.

Try It Yourself

Take the S3 configuration from the basic example and make these changes:

  • Add an aws_s3_bucket_logging resource for access logging.
  • Make it reference aws_s3_bucket.production.id as its target_bucket.
  • Identify the implicit dependency Terraform creates.
  • Run terraform plan and confirm that the logging resource is scheduled after the bucket.

Before applying, check the AWS provider documentation for the required logging arguments and make sure the target logging bucket already exists or is declared in the same configuration.

Challenge

Design a protected production order-processing queue system with a dead-letter queue.

Your configuration must:

  • Declare an AWS provider and an aws_region variable.
  • Create a dead-letter SQS queue named production-orders-dlq.
  • Create a main SQS queue named production-orders.
  • Configure the main queue to send messages to the dead-letter queue after five receive attempts.
  • Protect both queues with prevent_destroy.
  • Use a resource reference so Terraform can infer that the dead-letter queue must exist before the main queue.

Solution

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

variable "aws_region" {
  type        = string
  description = "AWS region for the order-processing queues"
  default     = "us-east-1"
}

resource "aws_sqs_queue" "orders_dead_letter" {
  name                      = "production-orders-dlq"
  message_retention_seconds = 1209600

  lifecycle {
    prevent_destroy = true
  }
}

resource "aws_sqs_queue" "orders" {
  name                       = "production-orders"
  visibility_timeout_seconds = 60
  message_retention_seconds  = 345600

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.orders_dead_letter.arn
    maxReceiveCount     = 5
  })

  lifecycle {
    prevent_destroy = true
  }
}

The redrive_policy references the dead-letter queue ARN, creating an implicit dependency. Terraform therefore creates aws_sqs_queue.orders_dead_letter before configuring the main queue.

Both queues use prevent_destroy, so a configuration change that would delete either production queue is rejected during planning or applying. The queues use fixed names, so create_before_destroy would not be appropriate for replacing them: AWS would not allow the old and new queues to coexist under the same name.

Key Takeaways

  • Terraform infers dependencies from resource attribute references and uses them to order operations.
  • Use depends_on only when a real dependency is not visible in the configuration.
  • prevent_destroy is useful for protecting production data, but it is not a complete security or backup control.
  • create_before_destroy can reduce replacement downtime when the provider allows both objects to exist temporarily.
  • Use ignore_changes only when another system intentionally manages the ignored attribute.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top