AWS Builder Center

I Built a Lambda to Empty an S3 Bucket Before Terraform Destroy, Only to find a One-Line Fix

CloudFormation lacks a native tool/command to empty the contents of a bucket when destroying the stacks. Terraform force-destroy = true command does that without additional services, as long as MFA is not enabled on the bucket.

There’s a specific annoyance that pushes people toward automation: doing the same manual task enough times that building a fix feels less painful than repeating it. This was exactly what I faced every time I ran terraform destroy on a stack with an S3 bucket that had objects in it: the destroy would fail. AWS won’t let you delete a non-empty bucket (either in the console or with its native IaC, CloudFormation). So I’d go into the console, manually empty it, then come back and destroy again. Small annoyance, but a recurring one, and recurring annoyances are exactly what get automated.
My first instinct came from CloudFormation. In CloudFormation, there’s no native flag to empty a bucket, and if you want a stack deletion to clean up a non-empty bucket, the standard pattern is a custom resource backed by a Lambda function invoked on stack deletion that empties the bucket before CloudFormation tries to delete it. I’d built and seen that pattern before, so when I hit the same problem in Terraform, I assumed the same shape of solution would be needed: a Lambda, triggered at destroy time, cleaning up the bucket first, then gets deleted down the line.
The only difference this time was I was using Terraform. That assumption turned out to be wrong. I figured Terraform developers or the community knew of this quirk and thought we weren’t having it. So, as Engineers would do, they did the heavy lifting behind the scenes, leading to a single command that would clean up the bucket during a Terraform destroy operation. Thus, Terraform’s force_destroy argument does exactly this in one line. Set it to true, and Terraform will empty the bucket (including all object versions and delete markers) before deleting it. No Lambda, no IAM role, no destroy-time provisioner. The problem I set out to solve already had a one-line answer in the provider.
I didn’t know that when I started. Therefore, I built the CloudFormation-shaped solution anyway and, in hindsight, that turned out to be the more valuable outcome, because building the manual version taught me a meaningfully larger slice of Terraform and AWS than the flag would have.
What I actually built
The stack has one job: guarantee that a specific S3 bucket (aws_s3_bucket.artifacts) gets fully emptied before Terraform tries to delete it, using a Lambda function instead of the native flag.
Trust and permissions. Before a Lambda can do anything, it needs an identity. That starts with a trust policy, i.e., a document stating which service is allowed to assume a role, in this case lambda.amazonaws.com. That trust policy gets attached to an aws_iam_role, which by itself grants no permissions; it just establishes who can act as this identity. The actual permissions used were s3:PutObject, s3:DeleteObject, s3:ListBucket, s3:DeleteObjectVersion, which I created in a policy document and attached to the role.
Packaging and shipping the function. The Lambda’s code needs to get from a local .py file to somewhere AWS can run it. That means zipping it with archive_file, uploading the zip to an S3 bucket dedicated to Lambda code, and pointing the aws_lambda_function resource at that object. The function itself takes the target bucket name as an environment variable (BUCKET_TO_EMPTY), so the same Lambda code isn’t hardcoded to one bucket.
The destroy-time trigger. This is the part that doesn’t have a clean equivalent anywhere else in the stack, and it’s the piece I’d flag to anyone attempting this pattern themselves. Terraform doesn’t have a native “run this thing right before you delete that other thing” primitive. The workaround is a null_resource with a provisioner “local-exec” block scoped to when = destroy. That provisioner runs a raw AWS CLI command aws lambda invoke against the cleanup function, but only during terraform destroy, not during apply. Provisioners are explicitly called out in Terraform’s own documentation as a last resort, for good reason: they step outside Terraform’s declarative model and shell out to imperative commands. Getting the ordering right required an explicit depends_on on both the Lambda function and the artifacts bucket, because Terraform has no other way to infer that this null_resource’s destroy-time action needs to happen before the bucket resource is torn down.
What this taught me that force_destroy wouldn’t have
Using force_destroy = true teaches you that the flag exists. Building the Lambda version forced me through IAM trust and permission modeling, Lambda packaging and deployment via Terraform, environment variable configuration for a function, and the specific mechanics of destroy-time ordering with provisioners a much wider tour of both Terraform and AWS than a boolean flag ever would.
What I’d actually recommend
If your use case is simple, like mine with a bucket with no versioning, no compliance requirement to audit what got deleted, no cross-account writes into it, then force_destroy = true is the correct production answer. It’s one line, it’s maintained by Terraform, and it doesn’t introduce a Lambda, an IAM role, and a provisioner into your state to delete some objects.
Where a custom cleanup Lambda still earns its place: buckets with versioning and MFA delete enabled, where force_destroy’s behavior is less predictable; situations where you want an audit trail of exactly what got deleted before teardown, or cases where objects are being written into the bucket by something outside your Terraform state entirely, and you want deliberate, logged control over the cleanup step rather than a silent bulk wipe.
I didn’t have any of those requirements. I had an assumption carried over from a different tool, and a bucket that wouldn’t delete. But the wrong assumption is what led me into the parts of Terraform and IAM that a one-line flag would have let me skip and that ended up being worth more than getting the answer quickly.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article