Originally published on graycloudarch.com.
The table bucket created without errors. The Terraform apply was clean. The KMS key was attached. The task definition had the right IAM role.
Then the first read operation hit AccessDenied on a KMS decrypt call, and the error gave no hint about what was actually wrong.
We'd added S3 Table Buckets to a data lake architecture — purpose-built Iceberg storage with 10x faster metadata operations compared to standard S3. The architecture decision was straightforward. The Terraform implementation had five gotchas that weren't in any documentation I found before running into them. This post documents all of them.
The KMS Key Policy Needs a Service Principal You Won't Guess
The KMS principal for S3 Table Buckets is s3tables.amazonaws.com — not s3.amazonaws.com.
This distinction matters because most teams already have a KMS key for their S3 buckets with a key policy that includes s3.amazonaws.com as a service principal. When you encrypt a table bucket with that same key without updating the policy, the bucket creates successfully. ACLs, tags, Terraform state — everything looks right. The failure happens on the first metadata read, when the s3tables service tries to decrypt and the key policy doesn't authorize it.
The error is AccessDenied from KMS, which sends you looking at IAM policies on your application role before you think to check the key policy. The required addition:
{
"Sid": "AllowS3TableBuckets",
"Effect": "Allow",
"Principal": {
"Service": "s3tables.amazonaws.com"
},
"Action": ["kms:GenerateDataKey", "kms:Decrypt"],
"Resource": "*"
}
In Terraform, this goes in your KMS key resource's policy document — either as an additional statement block in an aws_iam_policy_document data source, or as a JSON merge if your key policy is managed elsewhere. The fix took about 10 minutes once we knew what to look for. Finding it took most of an afternoon.
If you're using a customer-managed key (and you should be for any production data lake), add this statement before you create the table bucket, not after. The bucket will create cleanly either way — the failure only appears at access time.
The IAM Permissions Are in a Different Namespace
Once the KMS issue was resolved, the next AccessDenied came from a different place: table operations.
Standard S3 permissions — s3:GetObject, s3:PutObject, s3:ListBucket — don't apply to Table Bucket operations. Table Bucket operations live in the s3tables:* namespace: s3tables:GetTableBucket, s3tables:CreateTable, s3tables:GetTableData, s3tables:PutTableData.
Roles with full S3 access have no access to table buckets. Roles with table bucket permissions still need standard S3 for underlying object operations. You need both, explicitly granted.
The minimal IAM policy for a role that reads from table buckets:
data "aws_iam_policy_document" "table_bucket_read" {
statement {
actions = [
"s3tables:GetTableBucket",
"s3tables:ListTables",
"s3tables:GetTable",
"s3tables:GetTableData",
]
resources = [
aws_s3tables_table_bucket.this.arn,
"${aws_s3tables_table_bucket.this.arn}/*",
]
}
# Underlying S3 object access — required in addition to s3tables:* permissions
statement {
actions = ["s3:GetObject", "s3:ListBucket"]
resources = [
"arn:aws:s3:::${aws_s3tables_table_bucket.this.name}",
"arn:aws:s3:::${aws_s3tables_table_bucket.this.name}/*",
]
}
}
For write access, add s3tables:PutTableData, s3tables:CreateTable, and s3tables:DeleteTable as needed. The principle of least privilege applies here more strictly than with standard S3 — there's no s3tables:* wildcard shortcut that's safe to use in production.
Don't Mix Table Buckets and Standard S3 in the Same Terraform Component
This one is subtle and doesn't always bite you immediately.
The aws_s3tables_table_bucket resource uses a different API endpoint from aws_s3_bucket. When both resource types are in the same Terraform root module or Terragrunt component, the AWS provider's resource graph can produce ordering conflicts on concurrent applies. The symptom isn't usually an apply error — it's unexpected diffs on subsequent plans, where a table bucket resource shows changes that shouldn't be there based on the configuration.
The fix is isolation: one Terragrunt component for table buckets, one for standard S3 buckets, both pulling encryption keys from a separate KMS component. The dependency chain is explicit and clean:
terraform/
└── data-lake/
├── kms/ # KMS key — created first
│ └── main.tf
├── s3-standard/ # landing, raw, curated S3 buckets
│ ├── main.tf
│ └── terragrunt.hcl # depends_on kms/
└── s3-table-buckets/ # table buckets in isolated state
├── main.tf
└── terragrunt.hcl # depends_on kms/
The standard S3 and table bucket components have no dependency on each other — they both depend on the KMS component and nothing else. If a table bucket apply fails, it doesn't touch standard S3 state. terraform plan for one doesn't show noise from the other.
Check Region Availability Before Designing Around Table Buckets
S3 Table Buckets launched in a limited set of regions and have been expanding, but as of mid-2026 they're still not available everywhere. The list includes us-east-1, us-west-2, eu-west-1, and a handful of others — but not all regions where you might run a data platform.
The check is fast:
aws s3tables list-table-buckets --region us-east-1
# Returns an empty list if available, an endpoint error if not
If Table Buckets aren't available in your required region, the fallback is the pre-Table Buckets architecture: standard S3 plus Glue Data Catalog for Iceberg metadata management. That architecture works well and is broadly available. The Iceberg lakehouse post covers it.
Don't let region availability be a late discovery. Run this check before the architecture is committed.
terraform import for Existing Table Buckets Doesn't Work Cleanly
If a table bucket was created manually — console, CLI, a one-off script — before your Terraform module existed, bringing it under IaC management is messy.
The terraform import command for aws_s3tables_table_bucket expects a resource ID format that's different from what you'd derive from the ARN. The exact format is the table bucket name, not the ARN, not the resource ID from the console. AWS documentation is inconsistent about this.
Even when the import runs without errors, the resulting state may show plan diffs for attributes like created_at and arn that Terraform can't manage but includes in the resource schema. These show up as perpetual diffs that you can't suppress cleanly.
The safer path:
# Reference the existing bucket with a data source — don't try to import it
data "aws_s3tables_table_bucket" "existing" {
name = "my-existing-table-bucket"
}
# Reference it from other resources
resource "aws_s3tables_namespace" "raw" {
table_bucket_arn = data.aws_s3tables_table_bucket.existing.arn
namespace = ["raw"]
}
Use the data source to reference the existing bucket, manage new namespaces and tables via Terraform, and defer full ownership transfer (replacing the manually-created bucket with a Terraform-managed one) to a planned migration window. It's more work than import, but the state stays clean.
The architecture itself — Table Buckets replacing Glue Data Catalog for Iceberg metadata — is solid. These are operational details that mostly show up after the design decision is made. Better to find them here than at 2am during a data pipeline deployment.
Building out a data lake architecture on AWS and running into Table Bucket or Iceberg issues? This is the kind of platform work I do regularly. Get in touch.

Top comments (0)