Nuno
Nuno

Importing 54 Legacy S3 Buckets Into Terraform Without Changing One Permission

154 import blocks, before and after snapshots, and zero changes to ACLs, policies or public access. Plus a deletion list that held live log destinations.
Vander Nunes
August 06, 2026
XInstagramYouTubeLinkedIn
Old storage buckets being brought into a code repository with a lock on each one

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.

Why these buckets weren't in code

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.

Check the live state, then decide whether to import at all

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:

  • service-owned buckets, created and managed by an AWS service or another tool;
  • CloudFormation-managed buckets, because two IaC tools owning one resource ends badly;
  • buckets protected by service control policies, where an admin role gets a 403 but a narrow service role gets a 200. If you can't read it with your normal role, you shouldn't manage it with your normal role.

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.

Import every sub-resource

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.

prevent_destroy does not cascade

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
  }
}

Don't let default tags strip live tags

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)

Bucket names were case-sensitive back then

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.

Snapshots as proof

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.

The audit was stale

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.

The deletion list that wasn't safe

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.

What I'd do differently

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.

Checklist for importing legacy S3

  1. Check live state and search every repo for the bucket name.
  2. Skip what's owned by a service, by CloudFormation, or protected by an SCP. Log why.
  3. Import every live sub-resource.
  4. Put prevent_destroy on the bucket and on every sub-resource.
  5. Merge default tags with live tags.
  6. Snapshot before and after, and diff.
  7. Before deleting anything, check for writers.

If you have years of hand-made buckets to bring under control, I can help. See AWS.