
In June 2026 I brought 54 legacy S3 buckets under Terraform in 7 pull requests. Together they carried 154 resources and 154 import blocks. No apply changed a bucket ACL, a policy principal, a public access setting or an IAM permission.
Importing is supposed to be a no-op. With S3 it's easy to make it not one. Here's how I kept it that way.
The trigger was a tagging audit for cost allocation and compliance: every bucket needed an owning department and a source repository tag. Across dozens of AWS accounts, a lot of buckets had neither, and many weren't managed by any IaC at all. Some were years old, created by hand before anyone used Terraform there.
You can't tag a bucket properly from code that doesn't know the bucket exists. So: import first, then tag.
Before writing any import, I checked each bucket live and searched every repository for its name. Then a short checklist decided whether it should be imported by me at all. I skipped:
Every skipped bucket went into a log with the reason and a follow-up. By the end that log held 171 buckets, each one with an owner to decide what happens next.
The S3 resource in the AWS Terraform provider is split into many sub-resources: versioning, encryption, lifecycle, public access block, policy, ACL, ownership controls, logging, CORS, website and others. If a bucket has a setting live and you don't import the matching sub-resource, Terraform doesn't know about it. Some later change may then remove or overwrite it.
So each bucket was imported with every sub-resource it actually had live, no more and no less.
import {
to = aws_s3_bucket.legacy_example
id = "example-legacy-bucket"
}
import {
to = aws_s3_bucket_versioning.legacy_example
id = "example-legacy-bucket"
}
import {
to = aws_s3_bucket_public_access_block.legacy_example
id = "example-legacy-bucket"
}
Import blocks need Terraform 1.5 or later, and for_each on imports needs 1.7. Some stacks were pinned to older versions, so the first PRs just raised the pinned version to 1.5.7. Without that, the import plan won't even run.
I put prevent_destroy on every imported bucket. Then I put it on every sub-resource too.
It's tempting to think protecting the bucket protects its settings. It doesn't. prevent_destroy on aws_s3_bucket stops Terraform deleting the bucket. It does nothing to stop Terraform destroying the bucket policy or the public access block resource, which removes that configuration from the live bucket.
resource "aws_s3_bucket_policy" "legacy_example" {
bucket = aws_s3_bucket.legacy_example.id
policy = data.aws_iam_policy_document.legacy_example.json
lifecycle {
prevent_destroy = true
}
}
The goal was to add tags. The risk was removing the ones already there.
If you set tags from your provider's default_tags only, any tag on the live bucket that isn't in that set is planned for removal. The pattern that worked was merging defaults with what's live, so the import adds the required tags and keeps the rest:
tags = merge(local.default_tags, local.live_tags_legacy_example)
Before 2018, S3 allowed uppercase letters in bucket names. So two buckets whose names differ only by case are two separate buckets. Any tool, script or reviewer that lowercases names will tell you they're duplicates. They're not. Check exact names against the live listing.
For every batch I took a JSON snapshot of each bucket's live configuration before the apply and again after: policy, ACL, public access block, versioning, encryption, lifecycle, tags. Then I diffed them. The only differences allowed were tags. Any other difference would have meant the import wasn't a no-op.
Halfway through, it became clear the audit spreadsheet was out of date. In one account, 58 of the 64 buckets flagged as missing tags were already tagged. I wrote a small read-only script that refreshed the spreadsheet from live state, so teams worked from current data instead of a snapshot from months before.
A separate piece of work drafted deletion tickets for buckets that looked unused. Before anything was deleted, I checked each bucket live for objects, versions and anything writing to it.
A list marked for deletion included a bucket that was the live destination for an audit trail, another receiving a security findings export, and a 12.8 TB log archive. All three were kept. Only 7 buckets that were verifiably empty, with 0 objects, 0 versions and no writers, were cleared for deletion.
I'd run the live refresh of the audit first, before planning any batches. It would have removed a lot of buckets from scope on day one.
prevent_destroy on the bucket and on every sub-resource.If you have years of hand-made buckets to bring under control, I can help. See AWS.
In 2022 I put a fintech's AWS platform into 116 Terragrunt units in four regions, and deploys got 70% faster. What I would keep today, and what not.
Our edge could not issue one TLS certificate because port 80 already redirected to HTTPS. How I fixed it without a proxy, and a new AWS account surprise.
A CDN forwarded the Origin header to S3 but left it out of the cache key, so the first site to load each font decided who else could use it.