The landscape of Amazon Simple Storage Service (S3) security has undergone a fundamental shift in recent years. For a long time, Access Control Lists (ACLs) were the primary method for managing fine-grained permissions on buckets and individual objects. However, AWS has moved toward a more centralized and robust security model based on IAM policies and S3 Bucket Policies. This transition has created significant friction for DevOps engineers and platform architects using Terraform, specifically manifesting as the dreaded AccessControlListNotSupported error.
Understanding how to navigate the deprecation of S3 ACLs while maintaining the ability to deploy complex storage architectures—such as static website hosting, log delivery buckets, and Cross-Region Replication (CRR)—is essential for any modern cloud infrastructure stack.
The S3 ACL Deprecation Crisis
Since April 2023, AWS has changed the default behavior for S3 bucket ownership. For all new buckets created after this date, the default S3 Object Ownership setting is BucketOwnerEnforced. When this setting is active, ACLs are disabled entirely.
This change was introduced to simplify access management. By enforcing bucket ownership, AWS removes the complexity of managing permissions at the object level via ACLs, favoring the more scalable and auditable nature of IAM and bucket policies. However, many legacy Terraform configurations still include the acl argument within the aws_s3_bucket resource. When these configurations are applied to new buckets, they trigger a failure because the AWS API rejects any attempt to set an ACL on a bucket where ACLs are disabled.
Anatomy of the AccessControlListNotSupported Error
When a Terraform apply fails due to this ownership shift, the error messages typically appear in one of two forms:
Error: error creating S3 Bucket (my-bucket) ACL: AccessControlListNotSupported: The bucket does not allow ACLs status code: 400Error: error putting S3 Bucket ACL: AccessControlListNotSupported: The bucket does not allow ACLs
These errors occur because Terraform is attempting to send an ACL request to an S3 bucket that has the BucketOwnerEnforced setting. In this state, the bucket simply does not support the ACL API calls, leading to a 400 Bad Request response from the AWS endpoint.
Managing S3 Buckets via the Terraform AWS S3 Module
For those seeking a more comprehensive wrapper than the standard AWS provider resources, the terraform-aws-modules/s3-bucket/aws module provides a highly flexible way to deploy buckets with a wide array of integrated features. This module abstracts much of the complexity associated with the recent ACL changes while providing hooks for advanced configurations.
Supported Features in the S3 Module
The comprehensive S3 module supports nearly every feature provided by the Terraform AWS provider, ensuring that complex enterprise requirements can be met.
| Feature | Description |
|---|---|
| Static Web-Site Hosting | Configures the bucket to serve web content directly to the internet. |
| Versioning | Keeps multiple variants of an object in the same bucket for recovery. |
| CORS | Cross-Origin Resource Sharing configuration for web applications. |
| Lifecycle Rules | Automates object expiration or transition to cheaper storage classes. |
| Server-Side Encryption | Ensures data at rest is encrypted using SSE-S3 or SSE-KMS. |
| Object Locking | Prevents objects from being deleted or overwritten for a fixed period. |
| Cross-Region Replication | Synchronizes data between buckets in different AWS regions (CRR). |
| S3 Directory Buckets | High-performance buckets for large-scale datasets. |
| S3 Table Buckets | Specialized buckets optimized for tabular data structures. |
| S3 Vectors | Support for vector-based data storage. |
Implementation Examples
The module allows for the easy creation of specialized buckets. For instance, creating a standard private bucket with versioning enabled is straightforward:
```hcl
module "s3_bucket" {
source = "terraform-aws-modules/s3-bucket/aws"
bucket = "my-s3-bucket"
acl = "private"
controlobjectownership = true
object_ownership = "ObjectWriter"
versioning = {
enabled = true
}
}
```
For log delivery, such as ELB (Elastic Load Balancer) or WAF (Web Application Firewall) logs, the module provides specific flags to attach the necessary delivery policies.
```hcl
module "s3bucketfor_logs" {
source = "terraform-aws-modules/s3-bucket/aws"
bucket = "my-s3-bucket-for-logs"
acl = "log-delivery-write"
forcedestroy = true
controlobjectownership = true
objectownership = "ObjectWriter"
attachelblogdeliverypolicy = true
}
```
Note that for ALB/NLB logs, both attach_elb_log_delivery_policy and attach_lb_log_delivery_policy may be required to ensure full compatibility across different load balancer types.
Technical Deep Dive: Resource-Based Access Control
To resolve the AccessControlListNotSupported error, developers must understand the hierarchy of S3 ownership controls. If you must use ACLs (which is rare in modern setups), you cannot simply define an aws_s3_bucket_acl resource; you must first define the ownership controls.
The Dependency Chain for ACLs
If a project requirement dictates the use of a public-read ACL, the configuration must follow a strict dependency order to prevent race conditions and API errors. The correct order is:
1. Create the aws_s3_bucket.
2. Define aws_s3_bucket_ownership_controls to enable ACLs (e.g., set to BucketOwnerPreferred).
3. Define aws_s3_bucket_public_access_block to allow public ACLs.
4. Apply the aws_s3_bucket_acl.
Below is the authoritative implementation for a bucket requiring a public ACL:
```hcl
resource "awss3bucket" "cfs3bucket" {
bucket = "my-bucket"
}
resource "awss3bucketownershipcontrols" "example" {
bucket = awss3bucket.cfs3bucket.id
rule {
object_ownership = "BucketOwnerPreferred"
}
}
resource "awss3bucketpublicaccessblock" "example" {
bucket = awss3bucket.cfs3_bucket.id
blockpublicacls = false
blockpublicpolicy = false
ignorepublicacls = false
restrictpublicbuckets = false
}
resource "awss3bucketacl" "example" {
dependson = [
awss3bucketownershipcontrols.example,
awss3bucketpublicaccess_block.example,
]
bucket = awss3bucket.cfs3bucket.id
acl = "public-read"
}
```
Migrating Existing ACL Configurations
Migrating a legacy environment to the new ownership model requires a surgical approach to avoid unintended bucket recreation. Because changing certain bucket properties can trigger a "Forces new resource" action in Terraform, a phased migration is necessary.
Step-by-Step Migration Process
The following workflow ensures that existing data is preserved while updating the security posture:
- Remove the ACL Argument: Delete the
aclargument from within theaws_s3_bucketresource. - Handle ACL Resources: Either remove the standalone
aws_s3_bucket_aclresource or, if it must remain, add anaws_s3_bucket_ownership_controlsresource with adepends_onblock. - Plan and Verify: Run
terraform plan. If the plan indicates that the bucket will be destroyed and recreated, this is a critical warning. - Implement Lifecycle Protection: To prevent accidental recreation during the transition, use the
lifecycleblock to tell Terraform to ignore changes to the ACL.
```hcl
resource "awss3bucket" "my_bucket" {
bucket = "my-bucket-name"
lifecycle {
ignore_changes = [acl]
}
}
```
- State Management: If the bucket already has ACLs that are no longer desired in the Terraform state, remove the ACL resource from the state file directly:
terraform state rm aws_s3_bucket_acl.my_bucket_acl
Advanced Configuration and Policy-Based Access
In the modern AWS paradigm, Bucket Policies are the gold standard for access control. They provide a JSON-based structure that allows for complex conditional logic, which ACLs cannot achieve.
Implementing a CloudFront Access Policy
A common use case for S3 is serving content via Amazon CloudFront. Instead of using a "public-read" ACL—which is a security risk—the best practice is to keep the bucket private and grant access specifically to the CloudFront service principal using a bucket policy.
hcl
resource "aws_s3_bucket_policy" "allow_cloudfront" {
bucket = aws_s3_bucket.static_site.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowCloudFrontServicePrincipal"
Effect = "Allow"
Principal = {
Service = "cloudfront.amazonaws.com"
}
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.static_site.arn}/*"
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.cdn.arn
}
}
}
]
})
}
This approach ensures that users cannot bypass the CDN to access the S3 bucket directly, providing a significant security layer over the old ACL method.
Comparison of S3 Bucket Configuration Parameters
When configuring buckets via Terraform, it is vital to understand the difference between the various arguments available in the provider and the specialized modules.
| Parameter | Type | Default | Requirement/Effect |
|---|---|---|---|
bucket |
string | null | Forces new resource if changed. Must be $\le$ 63 chars. |
bucket_prefix |
string | null | Creates unique name with prefix. $\le$ 37 chars. |
force_destroy |
bool | false | Deletes all objects (even locked ones) on destroy. |
ignore_public_acls| bool |
true | Blocks public ACLs from granting access. | |
block_public_policy| bool |
true | Blocks public bucket policies. | |
eventbridge |
bool | false | Enables Amazon EventBridge notifications. |
data_redundancy |
string | null | Controls availability (e.g., SingleAvailabilityZone). |
Critical Considerations for Replication and Encryption
When deploying advanced features like Cross-Region Replication (CRR) using Terraform modules, there are several technical pitfalls to avoid.
Encryption and Replication Roles
If a bucket has encryption enabled (which is the default in the terraform-aws-modules/s3-bucket/aws module), the IAM role used for replication must possess specific permissions. The role requires encryption and decryption permissions for:
- The KMS key of the source bucket.
- The KMS key of the destination bucket.
Furthermore, the destination bucket policy must explicitly allow S3 Replication permissions from the source bucket's principal.
Handling Terraform Drift in Replication
Users should be aware of a known issue regarding drift detection. When using replication configurations with filters, the terraform plan command may report changes (drift) even when the actual AWS configuration has not changed. This is a recognized issue within the Terraform community and may be resolved in future provider releases.
Conclusion
The transition away from S3 ACLs represents a broader shift in cloud security toward "Policy as Code" and centralized identity management. While the AccessControlListNotSupported error has caused significant frustration for many, it serves as a catalyst for adopting more secure patterns.
For the majority of use cases, the solution is simple: remove the acl argument and rely on IAM and S3 Bucket Policies. For the minority of cases where ACLs are mandatory—such as specific legacy integrations or third-party tool requirements—the explicit declaration of aws_s3_bucket_ownership_controls is the only viable path forward.
By leveraging the terraform-aws-modules/s3-bucket/aws module, engineers can maintain high-velocity deployment cycles while ensuring that their storage infrastructure remains compliant with AWS's evolving security standards. The key to success lies in respecting the dependency chain: Bucket $\rightarrow$ Ownership Controls $\rightarrow$ Public Access Block $\rightarrow$ ACL.