Dependencies

Understand implicit and explicit resource dependencies in Terraform

8 min read

Dependencies

In the previous tutorial, we organized our project structure. Now let's talk about order — because Terraform needs to know what to create first.

You can't attach a security group to a VPC that doesn't exist yet. That's like trying to put furniture in a room before building the house. Most of the time, Terraform figures this out automatically. Sometimes you need to give it a hint.

Implicit Dependencies

"How does Terraform know what order to create things?"

When a resource references another resource's attribute, Terraform automatically understands the dependency. It just works:

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.main.id  # Reference creates dependency
  cidr_block = "10.0.1.0/24"
}

Terraform sees that aws_subnet.public uses aws_vpc.main.id and knows:

  1. Create VPC first
  2. Then create subnet

You don't need to declare this. It just works. How cool is that?

Dependency Chain

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.1.0/24"
}

resource "aws_instance" "web" {
  ami           = "ami-12345678"
  instance_type = "t2.micro"
  subnet_id     = aws_subnet.public.id  # Depends on subnet
}

Create order: VPC → Subnet → Instance

Parallel Creation

"Does Terraform create everything one at a time?"

Nope! Resources without dependencies are created in parallel — Terraform is smart about speed:

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_s3_bucket" "logs" {
  bucket = "my-logs-bucket"
}

resource "aws_sqs_queue" "tasks" {
  name = "task-queue"
}

These three resources have no dependencies on each other. Terraform creates all three simultaneously. Parallel processing, baby.

Explicit Dependencies

"What if Terraform can't see the relationship?"

Sometimes Terraform can't figure it out from the code alone. Use depends_on to make it explicit.

When You Need depends_on

resource "aws_iam_role" "lambda" {
  name = "lambda-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "lambda.amazonaws.com"
      }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_lambda_function" "processor" {
  function_name = "processor"
  role          = aws_iam_role.lambda.arn  # Implicit dependency on role
  handler       = "index.handler"
  runtime       = "nodejs18.x"
  filename      = "lambda.zip"

  # But Lambda might fail if policy isn't attached yet!
  depends_on = [aws_iam_role_policy_attachment.lambda_basic]
}

The Lambda function references the role ARN (implicit dependency), but it doesn't reference the policy attachment. Without depends_on, Terraform might try to create the Lambda before the policy is attached, and the Lambda would fail to invoke. Sneaky bug.

VPC Endpoints Example

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_vpc_endpoint" "s3" {
  vpc_id       = aws_vpc.main.id
  service_name = "com.amazonaws.us-west-2.s3"
}

resource "aws_instance" "private" {
  ami           = "ami-12345678"
  instance_type = "t2.micro"
  subnet_id     = aws_subnet.private.id

  # Instance expects S3 access via VPC endpoint
  depends_on = [aws_vpc_endpoint.s3]
}

Database Before Application

resource "aws_db_instance" "main" {
  identifier     = "mydb"
  engine         = "postgres"
  instance_class = "db.t3.micro"
  # ...
}

resource "aws_ecs_service" "api" {
  name            = "api"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.api.arn

  # App needs database ready (even though no direct reference)
  depends_on = [aws_db_instance.main]
}

depends_on Gotchas

Before you slap depends_on on everything...

Don't Overuse It

# Bad: Unnecessary depends_on
resource "aws_instance" "web" {
  subnet_id = aws_subnet.public.id  # Already creates implicit dependency!

  depends_on = [aws_subnet.public]  # Redundant
}

If there's already a reference, you don't need depends_on. That's redundant — like wearing a belt and suspenders and tying your pants with rope.

Module Dependencies

module "database" {
  source = "./modules/database"
  # ...
}

module "application" {
  source = "./modules/application"

  database_url = module.database.connection_string  # Implicit

  # But if app needs db ready beyond just the URL...
  depends_on = [module.database]
}

Multiple Dependencies

resource "aws_lambda_function" "processor" {
  # ...

  depends_on = [
    aws_iam_role_policy_attachment.lambda_vpc,
    aws_iam_role_policy_attachment.lambda_logs,
    aws_vpc_endpoint.lambda,
  ]
}

Lifecycle Rules

Control how resources are created, updated, and destroyed. These are the safety rails.

create_before_destroy

"Can I swap out a resource without downtime?"

resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = "t2.micro"

  lifecycle {
    create_before_destroy = true
  }
}

When the AMI changes:

  1. Create new instance
  2. Update references
  3. Destroy old instance

Useful for zero-downtime deployments. New server comes up, old server goes down. Smooth.

prevent_destroy

"What if someone accidentally destroys the production database?"

Prevent it:

resource "aws_db_instance" "production" {
  identifier     = "prod-db"
  engine         = "postgres"
  instance_class = "db.t3.large"

  lifecycle {
    prevent_destroy = true
  }
}

Terraform will error if you try to destroy this resource. It's the "are you absolutely sure?" guard for critical infrastructure.

To actually destroy it:

  1. Remove prevent_destroy from config
  2. Run terraform apply
  3. Then terraform destroy

ignore_changes

"Some external process keeps changing my tags and Terraform keeps reverting them!"

Tell Terraform to look the other way:

resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = "t2.micro"

  lifecycle {
    ignore_changes = [ami]
  }
}

Changes to AMI outside Terraform won't trigger replacement. Useful when:

  • ASG updates AMI independently
  • External process modifies resource
  • You want Terraform to manage some attributes but not others
# Ignore all changes
lifecycle {
  ignore_changes = all
}

# Ignore multiple attributes
lifecycle {
  ignore_changes = [
    ami,
    user_data,
    tags["LastModified"],
  ]
}

replace_triggered_by

Force replacement when another resource changes:

resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = "t2.micro"

  lifecycle {
    replace_triggered_by = [
      aws_security_group.web.id,  # Replace instance when SG changes
    ]
  }
}

Targeting Resources

"Can I apply just one resource at a time?"

Yep. Useful for debugging.

Apply Specific Resources

# Apply only the VPC
terraform apply -target=aws_vpc.main

# Apply multiple targets
terraform apply -target=aws_vpc.main -target=aws_subnet.public

# Apply a module
terraform apply -target=module.networking

When to Use Targeting

  1. Debugging: Test one resource at a time
  2. Emergency fixes: Update critical resource without touching others
  3. Large changes: Break up big applies

When NOT to Use Targeting

Regular workflow. Targeting can leave state inconsistent — Terraform even warns you:

Warning: Resource targeting is in effect

Dependency Visualization

Show Dependencies

terraform graph

Output is GraphViz format. Visualize it:

terraform graph | dot -Tpng > graph.png

Or use online viewers like webgraphviz.com.

Understanding the Graph

digraph {
  aws_vpc.main
  aws_subnet.public -> aws_vpc.main
  aws_instance.web -> aws_subnet.public
}

Arrows point from dependent to dependency (reverse of creation order).

Common Patterns

NAT Gateway Dependencies

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
}

resource "aws_eip" "nat" {
  domain = "vpc"

  # EIP must be created after IGW exists
  depends_on = [aws_internet_gateway.main]
}

resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public.id

  # Explicit dependency on IGW
  depends_on = [aws_internet_gateway.main]
}

ECS Service Dependencies

resource "aws_ecs_cluster" "main" {
  name = "my-cluster"
}

resource "aws_ecs_task_definition" "app" {
  family = "app"
  # ...
}

resource "aws_lb_target_group" "app" {
  # ...
}

resource "aws_lb_listener" "app" {
  load_balancer_arn = aws_lb.main.arn
  # ...
}

resource "aws_ecs_service" "app" {
  name            = "app"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn

  load_balancer {
    target_group_arn = aws_lb_target_group.app.arn
    container_name   = "app"
    container_port   = 8080
  }

  # Service needs listener configured before it can register targets
  depends_on = [aws_lb_listener.app]
}

Database with Schema

resource "aws_db_instance" "main" {
  identifier     = "mydb"
  engine         = "postgres"
  instance_class = "db.t3.micro"
  # ...
}

resource "null_resource" "db_schema" {
  triggers = {
    schema_version = var.schema_version
  }

  provisioner "local-exec" {
    command = "psql ${aws_db_instance.main.endpoint} < schema.sql"
  }

  depends_on = [aws_db_instance.main]
}

resource "aws_ecs_service" "app" {
  # ...

  depends_on = [null_resource.db_schema]  # App needs schema ready
}

Circular Dependencies

"Terraform says I have a cycle. What did I do?"

Terraform doesn't allow circular dependencies (A depends on B, B depends on A). It's the Terraform equivalent of a deadlock:

# This won't work!
resource "aws_security_group" "web" {
  ingress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.lb.id]  # Depends on lb
  }
}

resource "aws_security_group" "lb" {
  egress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.web.id]  # Depends on web - CIRCULAR!
  }
}

Solution: Separate Rules

resource "aws_security_group" "web" {
  name = "web"
}

resource "aws_security_group" "lb" {
  name = "lb"
}

resource "aws_security_group_rule" "web_from_lb" {
  type                     = "ingress"
  from_port                = 80
  to_port                  = 80
  protocol                 = "tcp"
  security_group_id        = aws_security_group.web.id
  source_security_group_id = aws_security_group.lb.id
}

resource "aws_security_group_rule" "lb_to_web" {
  type                     = "egress"
  from_port                = 80
  to_port                  = 80
  protocol                 = "tcp"
  security_group_id        = aws_security_group.lb.id
  source_security_group_id = aws_security_group.web.id
}

Create both security groups first (no dependency), then create rules that reference both. Break the circle. Problem solved.

What's Next?

Dependencies are the invisible backbone of every Terraform project. You now know:

  • Implicit dependencies (just reference an attribute — magic!)
  • Explicit dependencies with depends_on (when Terraform needs help)
  • Lifecycle rules for safety and zero-downtime deployments
  • How to fix circular dependencies

Now let's handle sensitive stuff — managing secrets safely in Terraform. Let's go!