When terraform destroy Fails: DependencyViolation and Resources That Won't Delete
Running terraform destroy on an experimental stack and watching it stall halfway is more common than it should be. The conclusion up front: destroy failures come in three branches — something created outside Terraform is holding on (DependencyViolation), the resource is not empty, or a deliberately installed safety catch fired. Each has its own diagnosis location and release procedure, and the safety catches in particular have an ordering rule: fix the code and apply first. The designing side of these catches is covered in Basics #7, resource lifecycle control; this post is the releasing side.
Failure types at a glance #
| Type | Typical error | Cause | Fix |
|---|---|---|---|
| External dependent remains | DependencyViolation | A resource made outside Terraform still references it | Find and remove the dependent first |
| Not empty | BucketNotEmpty etc. | S3 objects, ECR images remain | Set force_destroy, apply, then destroy |
| Safety catch | lifecycle error, deletion protection | prevent_destroy, deletion_protection | Release in code, apply, then destroy |
| Slow deletion | timeout while waiting | CloudFront and friends are simply slow to delete | Adjust timeouts, rerun |
| Partial destroy remains | Rerun shows what is left | An earlier failure deleted only part | Rerunning destroy is the default |
DependencyViolation: the culprit is outside Terraform #
The DependencyViolation you hit deleting a security group or VPC is the signature destroy failure. Terraform deletes in dependency order among the resources it knows, so this error does not happen between Terraform-managed resources. Its appearance means something Terraform does not know about is holding the resource.
The usual culprits: an EC2 instance launched by hand in the console that uses a Terraform-managed security group, ENIs left in a subnet by Lambda or an ALB, or another stack’s or team’s resources built inside this VPC. Diagnosis has a fixed location per target: for a security group, search the EC2 console for ENIs referencing it; for a subnet, list that subnet’s ENIs; for a VPC, check what remains in the VPC console’s resource map. On the CLI, aws ec2 describe-network-interfaces --filters answers the same question.
Once found, remove the dependent and rerun destroy. One caution: that dependent may be another team’s production resource. The step of confirming its owner before deleting is not optional.
Non-empty resources: force_destroy needs the apply first #
S3’s BucketNotEmpty is the second most common failure. Terraform will not delete a bucket holding objects by default. Setting force_destroy = true makes it delete objects and bucket together, but there is an ordering trap: the argument only works once it is reflected in state, so write it, run apply once, and then destroy. Editing the code right before destroy and skipping the apply runs with the old setting and reproduces the same error.
Versioned buckets must shed every object version and delete marker, which takes a while at volume. Emptying ahead of time with lifecycle rules, or aws s3 rm --recursive for bulk cases, is often faster. ECR repositories with leftover images have the same structure, with force_delete = true as the counterpart argument.
Safety catches: the release order is fixed #
A resource with prevent_destroy stops the destroy at planning with “Instance cannot be destroyed.” This is design working, not an incident, so the first question is whether deleting is actually right. If it is, remove prevent_destroy = true from the code (or set it false) and run destroy. Lifecycle settings are read from configuration, so no apply is needed for this one — but routing the code change through a PR for review is the operating principle.
RDS and ALB deletion_protection sits on a different layer: it is a property of the AWS resource itself, so flip the argument to false and apply it to the real resource before destroy can pass. RDS stacks a second catch on top: with skip_final_snapshot at its default false, deletion fails without a final_snapshot_identifier, so decide whether to keep a snapshot, align the arguments, and go apply then destroy. As covered in Practice #5, RDS and S3, this friction on the data tier is installed on purpose — disabling it by default because it is inconvenient points the wrong way.
Delays, partial failures, and leftovers #
CloudFront distributions and some global resources take tens of minutes to delete. A timeout error often means deletion is still in progress; rerunning destroy a little later confirms it already vanished and moves on. For repeat offenders, extend the delete window with a timeouts block.
When destroy fails midway, what was deleted has left state and what remains is still there. Like apply’s partial state, removing the cause and rerunning destroy is the default recovery. The rarer inverse — the real resource is gone but state still lists it — is caught by refresh, and failing that, cleaned with a removed block or terraform state rm. Those tools belong to Ops #2, drift and state surgery.
Summary #
- Destroy failures split three ways: external dependents, non-empty resources, safety catches. Classify from the error message first.
- DependencyViolation means something outside Terraform is holding on. Find the dependent via ENIs and the resource map, confirm its owner, then remove it.
- Releasing force_destroy and deletion_protection requires an apply to reach the real resource before the destroy. Editing code and destroying immediately repeats the error.
- prevent_destroy firing is design at work. Release it through a code change, but decide first whether deletion is actually intended.
- Timeouts and partial failures default to rerunning. The root prevention is the discipline of never hand-creating resources inside a Terraform-managed VPC.