Amazon S3 or Simple Storage Service is a widely used object storage service. When data is stored in S3, by default it is private for security purposes. S3 bucket policy is one of the frequently used ways to provide access to a bucket and objects. The reference material establishes a progression from manual console verification to Infrastructure as Code enforcement, with the Permissions tab and Bucket policy section in the console serving as the visual confirmation point for a policy that Terraform attaches. The article shows a workflow where a bucket is created and a policy is attached, then the console is inspected to verify attachment, followed by a cleanup via destruction. The core idea is that Terraform can express the same JSON policy document that would be pasted in the console, but with state tracking, versioning, and repeatable application.
The operational context for Terraform S3 bucket policies is resource-based access control. An S3 bucket policy is a resource-based IAM policy that you can use to provide access to your s3 bucket and the objects in it. The meaning of resource-based policy is, that instead of applying the policy to a principal like user/group/role, you apply the policy to the s3 bucket itself on which access is required. This inversion changes the security model from identity-centric to resource-centric. The bucket becomes the authorization boundary, and any principal that matches the Principal element is evaluated against the actions listed for the specified resources. This model is essential for cross-account access, service principals, and public access scenarios where IAM policies on the caller would not suffice.
S3 Bucket Policy Structure and Statement Semantics
S3 bucket policies are JSON documents that define who can do what with your bucket and its objects. They complement IAM policies by providing resource-based access control - meaning you can grant access to principals from other AWS accounts, enforce encryption requirements, restrict access to specific VPCs, and more.
Writing bucket policies in Terraform is straightforward once you understand the policy structure. The common patterns revolve around Effect, Principal, Action, and Resource. A bucket policy consists of one or more statements, each with an Effect (Allow/Deny), Principal (who), Action (what), and Resource (which objects).
The resource ARN for a bucket is distinct from the resource ARN for objects inside the bucket. ListBucket applies to the bucket ARN (without /) while GetObject applies to the objects (with /). This distinction is enforced by AWS evaluation logic and is a frequent source of misconfiguration. Specifying the bucket ARN for ListBucket and the bucket ARN with /* suffix for GetObject is required for correct operation.
A basic bucket policy allowing a specific IAM role to read objects is expressed in Terraform as:
resource "aws_s3_bucket" "data" {
bucket_prefix = "app-data-"
}
resource "aws_s3_bucket_policy" "data" {
bucket = aws_s3_bucket.data.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowAppRoleRead"
Effect = "Allow"
Principal = {
AWS = aws_iam_role.app.arn
}
Action = [
"s3:GetObject",
"s3:ListBucket"
]
Resource = [
aws_s3_bucket.data.arn,
"${aws_s3_bucket.data.arn}/*"
]
}
]
})
}
The impact of separating bucket and object ARNs is that a principal granted only GetObject on ${arn}/* cannot list the bucket contents, which protects enumeration. The contextual layer is that the same pattern appears in public read-only policies where Principal is "*".
Public Read Access Policy Creation with awss3bucketpolicy and awsiampolicydocument
In one of my previous posts, I shared with you “How to Create an S3 Bucket using Terraform”. In this post, I will show you how to create S3 bucket policy using one of the most popular IaC tools called Terraform. You will also see how the policy is created and attached to the bucket.
The reference configuration begins with provider and required providers:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.27"
}
}
}
provider "aws" {
profile = "default"
region = "ap-south-1"
}
The provider profile and region determine where the bucket and policy are created. The region ap-south-1 is the location where the demo resources are provisioned. The impact is that bucket names must be globally unique within AWS, and region selection influences latency and data residency.
The bucket resource is declared:
resource "aws_s3_bucket" "demo-bucket"{
bucket = "ck-demo-bucket-04th"
}
The bucket name ck-demo-bucket-04th is the identifier used in subsequent policy references. The bucket must exist before a policy can be attached, which creates an implicit dependency in Terraform.
The policy attachment uses awss3bucket_policy:
resource "aws_s3_bucket_policy" "public_read_access" {
bucket = aws_s3_bucket.demo-bucket.id
policy = data.aws_iam_policy_document.public_read_access.json
}
The bucket attribute references the bucket id, ensuring Terraform links the policy to the correct bucket. The policy attribute is sourced from a data source, which separates policy authoring from policy attachment.
The data source generates the JSON document:
data "aws_iam_policy_document" "public_read_access" {
statement {
principals {
type = "*"
identifiers = ["*"]
}
actions = [
"s3:GetObject",
"s3:ListBucket",
]
resources = [
aws_s3_bucket.demo-bucket.arn,
"${aws_s3_bucket.demo-bucket.arn}/*",
]
}
}
The principals block with type = "" and identifiers = [""] creates a public principal. The actions list includes s3:GetObject and s3:ListBucket. The resources list contains the bucket ARN and the bucket ARN with /* suffix. This pattern matches the console verification step where the Permissions tab is opened and the Bucket policy section shows the public read-only policy attached to the bucket.
The impact of using awsiampolicy_document is that Terraform can validate the policy syntax before apply and render human-readable HCL instead of raw JSON. The contextual layer is that the same data source pattern can be reused across multiple buckets by parameterizing identifiers and actions.
The final configuration file to create S3 bucket policy using Terraform demonstrates the deprecation note: using policy parameter on bucket resource is deprecated and it is recommended to use awss3bucketpolicy resource to attach an s3 bucket policy to your bucket. The standalone resource awss3bucketpolicy is the preferred mechanism.
Updating Existing S3 Bucket Policies with Terraform
Updating an existing bucket policy requires retrieving the existing policy, importing it into state, and then declaring awss3bucket_policy with the merged statements.
Here's how you can approach this:
First, retrieve the existing bucket policy using a data source or by importing the existing policy into your Terraform state.
Then, define the awss3bucket_policy resource in your Terraform configuration, referencing the existing bucket.
In the policy attribute of this resource, include both the existing policy statements and the new policy you want to add.
The example configuration shows replacement semantics:
resource "aws_s3_bucket_policy" "bucket_policy" { bucket = aws_s3_bucket.your_bucket.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { // Existing policy statements here }, { // New policy statement to add Sid = "NewPolicyStatement" Effect = "Allow" Principal = { Service = "some-aws-service.amazonaws.com" } Action = ["s3:SomeAction"] Resource = ["${aws_s3_bucket.your_bucket.arn}/*"] } ] }) }
In this example, you would replace the placeholder comments with your actual existing policy statements and the new policy statement you want to add.
Remember that Terraform will replace the entire bucket policy when you apply this configuration. This is the critical behavioral detail. Terraform manages the awss3bucket_policy resource as a single object, so any apply replaces the entire policy document with the one defined in configuration. The impact is that drift detection will show the policy as changed if manual edits were made in the console. The contextual layer is that this replacement model encourages policy consolidation in Terraform and discourages split ownership between console and code.
The safe workflow is to first import the existing policy, then merge statements in HCL, then apply. Without import, Terraform will attempt to create a new policy and may overwrite existing statements.
Terraform AWS Modules S3 Bucket Module Policy Integration
Terraform module which creates S3 bucket on AWS with all (or almost all) features provided by Terraform AWS provider.
These features of S3 bucket configurations are supported:
- static web-site hosting
- access logging
- versioning
- CORS
- lifecycle rules
- server-side encryption
- object locking
- Cross-Region Replication (CRR)
- ELB log delivery bucket policy
- ALB/NLB log delivery bucket policy
- WAF log delivery bucket policy
- Account-level Public Access Block
- S3 Directory Bucket
- S3 Table Bucket
- S3 Vectors
The module source is terraform-aws-modules/s3-bucket/aws. The module abstracts the low-level resources and exposes boolean and map arguments for common patterns.
A basic module invocation:
module "s3_bucket" {
source = "terraform-aws-modules/s3-bucket/aws"
bucket = "my-s3-bucket"
acl = "private"
control_object_ownership = true
object_ownership = "ObjectWriter"
versioning = {
enabled = true
}
}
The bucket is created with private ACL and ObjectWriter ownership control. Versioning is enabled via a map.
Log delivery bucket examples show policy attachment through module arguments:
module "s3_bucket_for_logs" {
source = "terraform-aws-modules/s3-bucket/aws"
bucket = "my-s3-bucket-for-logs"
acl = "log-delivery-write"
force_destroy = true
control_object_ownership = true
object_ownership = "ObjectWriter"
attach_elb_log_delivery_policy = true
}
The acl set to log-delivery-write and attachelblogdeliverypolicy = true configures the bucket for ELB access log delivery. The force_destroy = true argument allows deletion of non-empty bucket during destroy.
A variant for ALB/NLB logs:
module "s3_bucket_for_logs" {
source = "terraform-aws-modules/s3-bucket/aws"
bucket = "my-s3-bucket-for-logs"
force_destroy = true
control_object_ownership = true
object_ownership = "ObjectWriter"
attach_elb_log_delivery_policy = true
attach_lb_log_delivery_policy = true
}
attachelblogdeliverypolicy is required for ALB logs and attachlblogdeliverypolicy is required for ALB/NLB logs. The module internally constructs the appropriate bucket policy for the ELB service principal.
The module also supports placeholders to keep bucket policy with correct S3 bucket and AWS account properties. You can use the placeholders S3BUCKETID, S3BUCKETARN, and AWSACCOUNTID in the policy document. Those values will be replaced with the actual values during the policy attachment. This is especially useful when using bucket prefixes.
Conditional creation is handled via create_bucket argument:
module "s3_bucket" {
source = "terraform-aws-modules/s3-bucket/aws"
create_bucket = false
}
This S3 bucket will not be created. Terraform does not allow to use count inside module block, so the solution is to specify argument create_bucket.
The impact of using the module is reduced boilerplate and tested policy generation for log delivery, WAF, and public access scenarios. The contextual layer is that module arguments map to multiple underlying resources including awss3bucketpolicy, awss3bucketpublicaccessblock, and awss3bucket_acl.
Policy Parameters, Placeholders, and Conditional Logic
To keep bucket policy with correct S3 bucket and AWS account properties, you can use the placeholders S3BUCKETID, S3BUCKETARN, and AWSACCOUNTID in the policy document. Those values will be replaced with the actual values during the policy attachment. This is especially useful when using bucket prefixes.
When bucket_prefix is used instead of bucket, the actual bucket name is generated by Terraform. Placeholders ensure the policy document references the real ARN at apply time rather than a literal string.
Sometimes you need to have a way to create S3 resources conditionally but Terraform does not allow to use count inside module block, so the solution is to specify argument create_bucket.
The practical effect is that teams can toggle bucket creation in feature branches without removing module blocks from configuration.
Cleanup and Destruction Workflow
Finally, if you are doing this exercise for learning purposes, you can clean up by destroying the created resource.
terraform destroy
Type yes, and hit enter
Once you hit enter, your resources get destroyed. Once done, this is how you see the destruction complete message.
The destroy workflow removes the bucket policy first, then the bucket. If force_destroy is not set, Terraform will refuse to destroy a non-empty bucket. The impact is cost avoidance and state hygiene for ephemeral environments.
Common Policy Patterns and Considerations
The reference material repeatedly emphasizes the separation of bucket ARN and object ARN. A table of typical patterns helps clarify usage:
| Use Case | Principal | Actions | Resource |
| Public read objects | * | s3:GetObject, s3:ListBucket | bucket ARN, bucket ARN/* |
| App role read objects | AWS = role ARN | s3:GetObject, s3:ListBucket | bucket ARN, bucket ARN/* |
| Service write logs | Service = elb.amazonaws.com | s3:PutObject | bucket ARN/* |
| Cross-account write | AWS = account ID | s3:PutObject | bucket ARN/* |
The table shows how Principal, Action, and Resource are combined to express intent. The impact is that incorrect Resource scoping leads to Access Denied errors or overly permissive access.
The conclusion of the reference material states: In this article, we learnt How to Create S3 Bucket Policy using Terraform. You can use the standalone resource awss3bucketpolicy to create a policy or use policy parameter in the resource awss3bucket. However, using policy parameter on bucket resource is deprecated and it is recommended to use awss3bucketpolicy resource to attach an s3 bucket policy to your bucket.
The operational recommendation is to keep policies in Terraform for version control, peer review, and drift detection. The module approach is preferable for standard patterns like log delivery, while the standalone awss3bucketpolicy with awsiampolicydocument data source is appropriate for custom policies with dynamic principals.
Conclusion
The configuration surface for Terraform S3 bucket policies spans three layers: raw resource declaration with awss3bucketpolicy and awsiampolicydocument, module encapsulation via terraform-aws-modules/s3-bucket/aws, and policy update semantics that replace the entire document on apply. The resource-based nature of S3 bucket policies means the bucket ARN and object ARN must be treated separately, with ListBucket scoped to the bucket ARN and GetObject scoped to the bucket ARN with /* suffix. Public access patterns use Principal type * and identifiers *, while service-specific patterns use Service principals and narrow actions.
The module approach provides tested policy generation for ELB log delivery, ALB/NLB log delivery, WAF log delivery, and public access block configurations, with placeholders for bucket ID, bucket ARN, and account ID to support prefix-based naming. Conditional creation via create_bucket avoids the limitation of count inside module blocks. Update workflows require importing existing policies and merging statements, with the awareness that Terraform replaces the entire policy on each apply.
Cleanup is performed via terraform destroy, with forcedestroy required for non-empty buckets. The deprecation of the policy parameter on awss3bucket reinforces the use of awss3bucketpolicy as the canonical attachment mechanism. This architecture enables repeatable, auditable, and versioned access control for S3 storage as code.