S3 is the storage layer underneath almost every AWS workload. Static websites serve assets from it, Lambda functions write results to it, CloudFront pulls content through it, RDS snapshots land in it. It shows up in every architecture diagram as a box with an arrow pointing at it, and most teams never look past that box until something goes wrong — a bucket gets exposed publicly, costs spike unexpectedly, or a critical file gets overwritten and there is no way to get it back.
Setting up S3 correctly for production takes about fifteen minutes and prevents a category of problems that would otherwise take hours to recover from.
Block Public Access First
When you create a new S3 bucket, public access is blocked by default. That is the correct state for almost every production bucket. The mistake happens when someone turns it off to troubleshoot something and never turns it back on, or when a bucket policy is written incorrectly and quietly makes objects publicly readable.
The safest thing to do after creating any production bucket is to enforce block public access explicitly at the bucket level, so no policy or ACL change can accidentally expose it:
aws s3api put-public-access-block \
--bucket my-production-bucket \
--public-access-block-configuration \
BlockPublicAcls=true,\
IgnorePublicAcls=true,\
BlockPublicPolicy=true,\
RestrictPublicBuckets=true
If you are serving content publicly — a static website, downloadable assets — the correct pattern is to keep the bucket private and put CloudFront in front of it with Origin Access Control. CloudFront can serve the content publicly while the bucket itself never needs to be exposed. Turning on public access at the bucket level is almost never the right solution.
If you attempt to apply a bucket policy that grants public read access while block public access is enabled, S3 will reject the policy with an error. This is the intended behavior — it is a guard, not a bug.
Enable Versioning
Versioning keeps every version of every object ever written to the bucket. When a file is overwritten, S3 stores the new version alongside the previous one rather than replacing it. When a file is deleted, S3 creates a delete marker rather than actually removing the data.
aws s3api put-bucket-versioning \
--bucket my-production-bucket \
--versioning-configuration Status=Enabled
The immediate benefit is accidental deletion recovery. If a deployment script writes the wrong file to the wrong key, or a bug in application code deletes an object it should not have deleted, versioning means you can restore the previous state without any backup infrastructure.
The thing versioning does not do automatically is control how many versions accumulate. A bucket with versioning enabled and no lifecycle rules will keep every version of every object indefinitely. On a bucket with frequent writes this adds up quickly — you are paying for storage on objects no application is reading. The solution to this is lifecycle rules, covered next.
One important behavior: once versioning is enabled on a bucket it can be suspended but not fully disabled. Suspending versioning stops creating new versions but does not delete existing ones. Plan accordingly before enabling it.
Set Up Lifecycle Rules
Lifecycle rules automate what happens to objects and their versions over time. They are one of the most effective cost controls in S3 and require no ongoing maintenance once configured.
A typical production setup combines two rules: one that transitions current versions of infrequently accessed objects to a cheaper storage class, and one that expires old versions after a set number of days.
aws s3api put-bucket-lifecycle-configuration \
--bucket my-production-bucket \
--lifecycle-configuration '{
"Rules": [
{
"ID": "transition-old-versions",
"Status": "Enabled",
"Filter": {},
"NoncurrentVersionTransitions": [
{
"NoncurrentDays": 30,
"StorageClass": "STANDARD_IA"
}
],
"NoncurrentVersionExpiration": {
"NoncurrentDays": 90
}
},
{
"ID": "expire-incomplete-multipart",
"Status": "Enabled",
"Filter": {},
"AbortIncompleteMultipartUpload": {
"DaysAfterInitiation": 7
}
}
]
}'
The first rule moves noncurrent versions to S3 Standard-IA after 30 days — a storage class that costs roughly 45% less than Standard but charges a retrieval fee, which is acceptable for old versions you are unlikely to need. After 90 days, old versions are deleted entirely. The second rule cleans up incomplete multipart uploads — files that were partially uploaded and abandoned. These are invisible in the console but count toward your storage bill.
If your bucket stores logs or exports that are only needed for a fixed window, add a rule that expires current objects after that window. A logging bucket that expires objects after 90 days costs a fraction of one that retains them indefinitely.
S3 Storage Classes and When to Use Them
Storage class is the primary lever for cutting S3 costs. The default is S3 Standard, which is optimized for frequently accessed data. Most buckets do not need Standard for everything they contain.
| Storage Class | Best For | Relative Cost |
|---|---|---|
| S3 Standard | Frequently accessed data, active production objects | Baseline |
| S3 Standard-IA | Accessed less than once a month, must retrieve quickly | ~45% cheaper storage, retrieval fee applies |
| S3 Glacier Instant Retrieval | Archival data accessed once a quarter, millisecond retrieval | ~68% cheaper storage |
| S3 Glacier Flexible Retrieval | Long-term archival, retrieval in minutes to hours is acceptable | ~77% cheaper storage |
| S3 Intelligent-Tiering | Unknown or unpredictable access patterns | Monitoring fee per object, automatic tiering |
S3 Intelligent-Tiering is worth calling out separately. It monitors access patterns per object and automatically moves objects between frequent and infrequent access tiers. There is a small per-object monitoring fee — $0.0025 per 1,000 objects per month — which makes it cost-effective only above a certain object size. For objects smaller than 128KB, the monitoring fee often exceeds the savings. For larger objects with unpredictable access, it is one of the easiest cost optimizations available.
Enable Server-Side Encryption
Since January 2023, AWS encrypts all new objects in S3 by default using SSE-S3 — server-side encryption with Amazon-managed keys. For most workloads this is sufficient. If your compliance requirements mandate customer-managed keys, switch to SSE-KMS:
aws s3api put-bucket-encryption \
--bucket my-production-bucket \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:123456789012:key/your-key-id"
},
"BucketKeyEnabled": true
}]
}'
The BucketKeyEnabled: true flag is important for cost control when using SSE-KMS. Without it, every object request makes a separate call to AWS KMS to retrieve the encryption key, and KMS charges per API call. A bucket key generates a short-lived key at the bucket level that encrypts individual objects locally, reducing KMS API calls by up to 99%.
If you enable SSE-KMS without bucket keys on a high-throughput bucket, the KMS cost can exceed the S3 storage cost. This is a common surprise.
Write a Bucket Policy
A bucket policy controls who can perform which actions on the bucket and its objects. Without one, access is controlled entirely by IAM policies on the identities accessing the bucket. Adding a bucket policy lets you enforce constraints at the resource level — useful for restricting access to specific VPCs, requiring encryption on upload, or denying access from outside your organization.
A minimal production bucket policy that denies unencrypted uploads and restricts access to a specific IAM role:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyUnencryptedUploads",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::my-production-bucket/*",
"Condition": {
"StringNotEquals": {
"s3:x-amz-server-side-encryption": "aws:kms"
}
}
},
{
"Sid": "AllowAppRole",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/app-role"
},
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-production-bucket/*"
}
]
}
The deny statement on unencrypted uploads means any PutObject request that does not include the SSE-KMS header will be rejected, regardless of what the calling identity’s IAM policy allows. Explicit denies in bucket policies override IAM allows — the same evaluation rule that applies to all IAM policy evaluation.
If you restrict a bucket to a specific IAM role and then try to access it from the AWS console with your own user credentials, the deny will apply to you too. This is the correct behavior but it catches people off guard the first time.
Enable Access Logging
S3 access logging writes a record of every request made to a bucket — who made it, what object they accessed, what the response was, and when it happened. This is essential for security auditing and for diagnosing unexpected access patterns or costs.
aws s3api put-bucket-logging \
--bucket my-production-bucket \
--bucket-logging-status '{
"LoggingEnabled": {
"TargetBucket": "my-access-logs-bucket",
"TargetPrefix": "production/"
}
}'
Logs are delivered to a separate bucket — not the bucket being logged, since that would cause an infinite logging loop. The logs themselves are S3 objects and count toward your storage bill, so add a lifecycle rule on the logging bucket to expire logs after however many days your retention policy requires.
Final Thoughts
A production S3 bucket is not just a place to put files. The defaults get you running but they do not get you secure, resilient, or cost-efficient. The configuration covered here — blocking public access, enabling versioning, setting lifecycle rules, choosing the right storage class, enforcing encryption, writing a bucket policy, and enabling logging — adds up to maybe twenty minutes of setup that prevents a significant class of production incidents.
- Block public access explicitly — do not rely on no policy being in place
- Enable versioning but pair it with lifecycle rules or old versions will accumulate indefinitely
- Lifecycle rules on noncurrent versions and incomplete multipart uploads are the fastest cost wins
- Enable bucket keys when using SSE-KMS or KMS API costs will surprise you
- Write access logs to a separate bucket with its own expiry rule
The buckets that cause production incidents are almost never the ones that were carefully configured. They are the ones that were created quickly to solve an immediate problem and never revisited.
