S3 Is Not Just Storage — 10 Things Most Engineers Get Wrong
Most engineers treat S3 like a hard drive in the cloud — upload files, download files, done. But S3 is actually a full-featured data platform, and misunderstanding it costs companies thousands of dollars every month. Here are 10 things you're probably getting wrong.
1. Bucket Policies vs ACLs — Pick One
AWS provides two ways to control access to S3: bucket policies and ACLs. The correct answer in 2025 is bucket policies only. ACLs are a legacy feature that AWS themselves recommend disabling.
# Block all public access and disable ACLs (do this for every bucket)
aws s3api put-public-access-block \
--bucket my-app-bucket \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
A clean bucket policy looks like this:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontOAC",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-app-bucket/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/EDFDVBD6EXAMPLE"
}
}
}
]
}
2. Versioning Saves You From Yourself
Without versioning, deleting a file is permanent. With versioning, every change creates a new version and "deletes" just add a delete marker.
# Enable versioning on a bucket
aws s3api put-bucket-versioning \
--bucket my-app-bucket \
--versioning-configuration Status=Enabled
# List all versions of a file
aws s3api list-object-versions \
--bucket my-app-bucket \
--prefix config/app-settings.json
# Restore a previous version by copying it
aws s3api copy-object \
--bucket my-app-bucket \
--copy-source my-app-bucket/config/app-settings.json?versionId=abc123 \
--key config/app-settings.json
But here's the trap: versioning keeps every old version forever unless you set lifecycle rules. That 2MB config file you update 50 times a day? That's 100MB/day of hidden storage costs.
3. Storage Classes — Stop Paying for Hot Storage
This is where most teams hemorrhage money. Not every file needs instant access.
| Storage Class | Use Case | Min Storage | Retrieval Cost | Monthly Cost (per GB) |
|---|---|---|---|---|
| S3 Standard | Frequently accessed data | None | None | $0.023 |
| S3 Standard-IA | Infrequent access (>30 days) | 30 days | Per-GB fee | $0.0125 |
| S3 One Zone-IA | Reproducible infrequent data | 30 days | Per-GB fee | $0.01 |
| S3 Glacier Instant | Archive with ms retrieval | 90 days | Per-GB fee | $0.004 |
| S3 Glacier Flexible | Archive, minutes to hours | 90 days | Per-GB + request | $0.0036 |
| S3 Glacier Deep Archive | Compliance, 12-hour retrieval | 180 days | Per-GB + request | $0.00099 |
Prices are for us-east-1 as of 2025. Check the pricing page for current rates.
4. Lifecycle Rules Automate Cost Savings
Don't manually move objects between storage classes. Automate it:
{
"Rules": [
{
"ID": "ArchiveOldLogs",
"Status": "Enabled",
"Filter": {
"Prefix": "logs/"
},
"Transitions": [
{
"Days": 30,
"StorageClass": "STANDARD_IA"
},
{
"Days": 90,
"StorageClass": "GLACIER"
},
{
"Days": 365,
"StorageClass": "DEEP_ARCHIVE"
}
],
"NoncurrentVersionExpiration": {
"NoncurrentDays": 30
}
}
]
}
# Apply the lifecycle configuration
aws s3api put-bucket-lifecycle-configuration \
--bucket my-app-bucket \
--lifecycle-configuration file://lifecycle.json
That NoncurrentVersionExpiration is critical — it cleans up old versions so versioning doesn't explode your bill.
5. Encryption — There's No Reason Not To
Every S3 bucket should have encryption enabled. Since January 2023, AWS encrypts all new objects with SSE-S3 by default, but you should understand your options:
| Encryption Type | Key Management | Cost | Use Case |
|---|---|---|---|
| SSE-S3 | AWS manages keys | Free | Default, good enough for most |
| SSE-KMS | AWS KMS manages keys | ~$1/key/month + API calls | Audit trail, key rotation control |
| SSE-C | You provide keys per request | Free (you manage keys) | Regulatory requirements |
# Set default encryption to SSE-KMS
aws s3api put-bucket-encryption \
--bucket my-app-bucket \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "alias/my-s3-key"
},
"BucketKeyEnabled": true
}]
}'
Enable BucketKeyEnabled to reduce KMS API costs by up to 99%.
6. Pre-Signed URLs for Temporary Access
Need to let a user download a private file without making the bucket public? Pre-signed URLs:
# Generate a pre-signed URL valid for 1 hour (3600 seconds)
aws s3 presign s3://my-app-bucket/reports/quarterly-2025.pdf \
--expires-in 3600
This returns a URL with embedded credentials that expires automatically. Perfect for download links in emails, temporary file sharing, and allowing uploads without AWS credentials.
7. Static Website Hosting — The $0.50/Month Website
S3 can serve a static website directly, but you should always pair it with CloudFront:
# Enable static website hosting
aws s3 website s3://my-website-bucket \
--index-document index.html \
--error-document error.html
# Sync your build folder
aws s3 sync ./dist s3://my-website-bucket --delete
8. Event Notifications — Trigger Actions on Upload
S3 can notify Lambda, SQS, or SNS when objects are created, deleted, or restored:
# Configure event notification to trigger Lambda on new uploads
aws s3api put-bucket-notification-configuration \
--bucket my-app-bucket \
--notification-configuration '{
"LambdaFunctionConfigurations": [{
"LambdaFunctionArn": "arn:aws:lambda:us-east-1:123456789012:function:process-upload",
"Events": ["s3:ObjectCreated:*"],
"Filter": {
"Key": {
"FilterRules": [{"Name": "prefix", "Value": "uploads/"}]
}
}
}]
}'
9. Cross-Region Replication for Disaster Recovery
Replicate objects automatically to another region:
# Enable replication (requires versioning on both buckets)
aws s3api put-bucket-replication \
--bucket my-app-bucket \
--replication-configuration '{
"Role": "arn:aws:iam::123456789012:role/S3ReplicationRole",
"Rules": [{
"Status": "Enabled",
"Destination": {
"Bucket": "arn:aws:s3:::my-app-bucket-replica",
"StorageClass": "STANDARD_IA"
}
}]
}'
10. The Cost Traps Nobody Warns You About
- Request costs:
ListObjectscalls cost $0.005 per 1,000 requests. An app checking "does this file exist?" in a loop can generate thousands of dollars in charges. - Data transfer out: Free into S3, but $0.09/GB out to the internet. Use CloudFront to reduce this.
- Incomplete multipart uploads: Failed large uploads leave fragments that cost storage. Add a lifecycle rule to clean them up.
{
"Rules": [{
"ID": "CleanupIncompleteUploads",
"Status": "Enabled",
"Filter": {},
"AbortIncompleteMultipartUpload": {
"DaysAfterInitiation": 7
}
}]
}
What's Next?
You've got IAM and S3 locked down. Next up: EC2 instances — we'll break down instance types, pricing models, and why you're probably paying 60% more than you need to for your compute.
This is Part 3 of our AWS series. S3 is deceptively deep — bookmark this one for reference.
