One Bucket, Two Terraform Owners - the Last apply Wins
I added one S3 lifecycle rule and terraform apply failed. The cause: two resources each owning the lifecycle configuration of the same bucket. S3 lifecycle is document-level, not rule-level, so the last winner silently erases the other side's rules.
TL;DR
To reclaim upload objects that got a presign but never a commit, I designed a tmp/ prefix with a lifecycle expiration rule. When the rule merged, the staging apply failed. The cause: a module and the environment config each owned the lifecycle of the same physical bucket. S3's lifecycle API replaces the whole document, so two owners overwrite each other's rules on every apply. I used a removed block (destroy = false) to make Terraform forget the module's resource in state only, leaving a single owner. A document-level resource must have exactly one owner.
On this page
It started as an ordinary cleanup problem. Users upload media files (recordings and images) through presigned URLs. The server issues an upload URL, the client uploads straight to S3, then calls a commit API to say "register this key as a real asset." The problem is what happens when someone gets a presign but never commits. The app crashes, the network drops, the user leaves the screen, and the bucket is left with an object that isn't registered anywhere.
I wanted a lifecycle rule to clean these up, but there was no way to write one. Committed and uncommitted objects were mixed under the same prefix, so any rule that says "delete old things" would delete real assets too. A daily upload quota kept the pile from growing fast, but the fact remained: there was no path to reclaim the space.
The design: what isn't committed lives in tmp
The backbone of the fix is key namespace separation.
- presign issues a temporary key under the
tmp/prefix. - When commit passes validation (existence check via HEAD, Content-Type, size limit), it promotes the object to its final key with CopyObject and deletes the tmp original.
- Objects whose commit never arrives stay in
tmp/, and a lifecycle rule expires them after 7 days.
Now the lifecycle rule only has to look at tmp/. Real assets are outside its blast radius from the start. The clients didn't need to change. I read all three upload flows to confirm this: every one of them uses the key returned in the commit response for its follow-up calls, so the server can change the key shape without them noticing. Commits for old-format keys already in flight at deploy time still go through the existing path.
One trap here. This bucket has versioning enabled. On a versioned bucket, expiration doesn't delete an object. It only adds a delete marker, and the original bytes stay behind as a noncurrent version. Without a paired noncurrent_version_expiration (1 day), the cleanup runs and not a single byte is reclaimed.
The design review paid for itself too. Putting a review on the promotion logic surfaced three confirmed defects. A hole where an admin-only custom key path could commit a tmp/ key as the final key, letting the lifecycle rule delete a live asset (blocked tmp final keys at the shared commit boundary). The promotion's sourceKey acting as an arbitrary copy primitive (enforced the equation between tmp and final keys). And an idempotency problem where two concurrent commits of the same tmp key make one side's copy die with a 404. All three were fixed in code before moving on.
And then apply failed
I added the lifecycle rule in Terraform and merged. The staging apply failed.
The error was a timeout writing the lifecycle configuration, but digging in, the timeout was a symptom. The structure was the problem. Two Terraform resources each owned this bucket's lifecycle. One lived inside the image resize module (the module attached its own rule for its own purposes). The other lived in the per-environment bucket config file (where I had just added the tmp rule). Same physical bucket, two owners.
S3's lifecycle API has no "add one rule" operation. PutBucketLifecycleConfiguration replaces the entire document. Terraform's aws_s3_bucket_lifecycle_configuration sits on top of that, so the resource overwrites the bucket's whole lifecycle document with just the rules it knows about. With two owners, each keeps pushing a document containing only its own rules. If they meet inside one apply, the concurrent writes time out. If they meet separately, it's worse: the apply succeeds without any error, and the last winner erases the other side's rules.
That's the coldest part of this incident. The timeout was actually the lucky outcome, because it was a loud failure. If this structure had stayed alive, the next apply that touched the image resize module for any reason would have silently deleted the tmp expiration rule I had just added. The plan would have shown nothing but a one-line in-place update on that bucket resource, and uncommitted objects would have started piling up forever again. No monitoring anywhere would have noticed that a cleanup rule had vanished.
In fact, this exact spot had been flagged once during code review, as a suspicion that "these two resources seem to touch the same bucket." At the time it was filed as a potential risk. It took only minutes after the merge for potential to become real, when the apply failed.
The fix: one owner
The direction was clear. Each bucket must have exactly one lifecycle owner. I removed the lifecycle resource from the module and made the per-environment config file the single owner, moving the module's rule over as well.
The tool for this was Terraform's removed block. If you just delete the resource declaration from the module, Terraform tries to destroy the real lifecycle configuration. That opens a window where the rules currently live in production get deleted and then recreated. Give the removed block destroy = false and Terraform forgets the resource in state only. The real thing stays untouched while it disappears from the ledger, so ownership transfers to the environment config with zero downtime.
After the fix, the staging apply passed, and after rolling out to prod I checked the actual bucket state directly through the AWS API. Both buckets had the tmp expiration rule alive, and all three pre-existing rules intact. The reason I didn't stop at terraform plan is exactly what this incident taught me: a plan is just each owner's ledger, and in a document-overwrite structure, two ledgers know nothing about each other.
What it leaves behind
A document-level resource must have exactly one owner. Not just S3 lifecycle. Bucket policies, CORS configuration, notification configuration - every Terraform resource built on a "replace the whole thing" API has the same property. If declarations pointing at the same target exist once in a module and once in the environment config, you don't get a compile error. You get a silent overwrite at runtime. Before adding a new rule, first go find out whether that document already has an owner.
One more. Deleting a resource declaration and deleting the real thing are different acts, and Terraform expresses that distinction with the removed block. When the goal is an ownership transfer, the moment you go through destroy, zero downtime is gone.
Finally, a reclamation path starts with key design. When data with different lifespans - committed and uncommitted - lives in the same namespace, no cleanup rule can be written safely. If the lifespans differ, separate where they live first. For the record, this approach does not retroactively reclaim the uncommitted objects that already piled up. Those objects can't be distinguished from real assets (that was the starting point of this whole problem), so I deliberately chose to apply the scheme to new uploads only.
Frequently asked questions
My S3 lifecycle rules disappear after terraform apply. Why?
aws_s3_bucket_lifecycle_configuration is a resource that owns the bucket's entire lifecycle configuration document. If two of these resources point at the same bucket (say, one in a module and one in the environment config), each replaces the whole document with only the rules it knows about, so whichever side applies last erases the other side's rules. If both run in the same apply, the concurrent writes can also time out. Consolidate to one owning resource per bucket.
I set lifecycle expiration on a versioned S3 bucket but storage isn't shrinking.
On a versioned bucket, expiration doesn't delete objects. It adds a delete marker and turns the existing version into a noncurrent one. Unless you also set noncurrent_version_expiration, the bytes are never reclaimed. Design the expiration rule and the noncurrent version expiration as a pair.
Related posts
- 💻 Dev
The Tests Were Green - the Notification Had Never Been Sent, Not Once
The code that enqueued jobs had been failing every time since day one. The error was swallowed, and the tests stayed green thanks to a mock. The story of how a single colon quietly killed three features.
- 💻 Dev
The Discount You Weren't Claiming: Why Spot and Graviton Are Cheap
Our AWS bill came in over budget two months running. Hunting for resources to cut, I found the real lever wasn't 'how much you use' but 'how you buy it.' Why Spot and Graviton are cheap, and why one line item that looks like waste - RDS Proxy - should stay on: a story about the 'why' behind every number on a cloud bill.
- 💻 Dev
"Just Turn It Off in Staging" - What the Noisy Alert Was Actually Hiding
Every day in staging, the same Google Play 404 alert fired. Killing the source would have made it quiet - but quiet and fixed turned out to be two different things. A 404 is a permanent error that retries can never resolve, yet it was being lumped in with 5xx and retried anyway.