I recently worked through a hands-on Amazon S3 practical series covering the features I would actually expect to use while working with AWS storage.
Instead of only documenting definitions, this post focuses on what I configured, the commands I used, how I verified the behavior, and what I observed when something went wrong.
For the concepts behind these practicals, I have already covered S3 in two detailed sessions:
- Session 1: AWS S3 Deep Dive — Objects, Encryption, Bucket Policies & Everything In Between
- Session 2: AWS S3 — Versioning, Static Hosting, CORS, Object Lock & More
This post is the practical companion to those two sessions. The concepts are covered there; here I focus on actually building, testing, verifying, and troubleshooting the S3 features.
The labs covered:
- S3 bucket configuration and lifecycle management
- Bucket policies with IAM, EC2 and HTTPS-only access
- SSE-KMS encryption with CloudTrail verification
- Pre-signed URLs
- AWS CLI
s3 sync - S3 Versioning and version recovery
- Static website hosting
- S3 CORS
- S3 Object Lock
Note: This is a practical write-up, so I have intentionally kept the focus on implementation and verification rather than turning it into a generic S3 theory article.
1. S3 Bucket Configuration and Lifecycle Management
Objective
Create an S3 bucket with a secure baseline and configure a lifecycle rule that automatically transitions objects to lower-cost storage classes over time.
Configuration
For the lab:
- Block Public Access remained enabled.
- Bucket Versioning was enabled.
- Lifecycle rule:
s3-lab-lifecycle - The rule applied to all objects.
- Current objects transition to:
- Standard-IA after 30 days
- Glacier Flexible Retrieval after 90 days
The lifecycle flow was:
Day 0
↓
Object uploaded
↓
Day 30 → Standard-IA
↓
Day 90 → Glacier Flexible Retrieval
Result
The lifecycle rule was successfully created and enabled, and the S3 console confirmed the configured transition periods.
What this demonstrates
Instead of manually moving old objects, S3 Lifecycle can automatically transition objects between storage classes based on age and the rules we define.
2. S3 Bucket Policy — IAM Read, EC2 Upload and HTTPS-only Access
Objective
Configure a bucket policy that:
- Allows a specific IAM user to read objects.
- Allows an EC2 role to upload objects.
- Denies S3 access when the request is not using HTTPS.
Resources
Bucket: policytesting-bucket-ts
IAM User: Tejas-IAM
EC2 Role: EC2-S3-ReadOnly-Role
Region: ap-south-1
Policy design
The policy contained three logical statements:
IAM User
↓
s3:GetObject
↓
S3 bucket
EC2 IAM Role
↓
s3:PutObject
↓
S3 bucket
HTTP request
↓
Explicit Deny
↓
S3 bucket
The important security condition was:
"Bool": {
"aws:SecureTransport": "false"
}
This denies requests that are not using HTTPS.
Verification
The IAM user successfully downloaded an object from S3 using AWS CLI.
An EC2 instance was launched with EC2-S3-ReadOnly-Role attached, and the role was verified from the instance. The EC2 instance was then able to upload an object to S3.
Result
Both permission paths worked as intended:
- IAM user → object read
- EC2 role → object upload
- Non-HTTPS access → explicitly denied
Practical takeaway
A useful S3 security pattern is to combine:
- identity-based permissions,
- resource-based bucket policies,
- and explicit security-condition denies.
3. SSE-KMS Encryption with CloudTrail Verification
Objective
Configure S3 default encryption using a customer-managed KMS key and verify the resulting cryptographic operations through CloudTrail.
KMS configuration
The lab used:
Key alias: s3-lab-key
Key type: Symmetric
Key usage: Encrypt and decrypt
Region: ap-south-1
Key administrator: Tejas-IAM
Key user: Tejas-IAM
Configure S3 encryption
The bucket:
sse-kms-bucket-tejas
was configured for server-side encryption using the customer-managed KMS key.
Verification
After uploading and downloading an object, I checked CloudTrail Event History.
The relevant KMS operations were:
GenerateDataKey
Decrypt
GenerateDataKey appeared during the encryption flow, while Decrypt events were associated with accessing encrypted data.
Troubleshooting
Initially, the expected cryptographic events were not obvious in CloudTrail.
Instead of relying only on the initial event list, I changed the lookup to search by Event name. That exposed the expected:
GenerateDataKey
Decrypt
events.
Result
SSE-KMS was successfully configured and the related KMS operations were visible through CloudTrail.
Practical takeaway
Encryption configuration alone is not always enough when troubleshooting. CloudTrail can provide the audit trail needed to understand which AWS services and cryptographic operations were involved.
4. S3 Pre-signed URL
Objective
Keep an S3 object private while providing temporary access through a pre-signed URL.
Object
Bucket: sse-kms-bucket-tejas
Object: presigned-test.txt
The object itself remained private.
No public permissions were added.
Generate the URL
aws s3 presign s3://sse-kms-bucket-tejas/presigned-test.txt --expires-in 300
300 seconds = 5 minutes.
Test
I opened the generated URL in an Incognito browser window.
The private object was accessible successfully.
After the five-minute validity period expired, I refreshed the same URL.
S3 returned:
AccessDenied Request has expired
Result
The test demonstrated the main behavior of a pre-signed URL:
Private S3 object
+
Temporary signed URL
↓
Temporary access
↓
URL expires
↓
Access denied
Practical takeaway
A pre-signed URL is useful when an application needs to provide temporary access to a private S3 object without making the object itself public.
5. AWS CLI s3 sync
Objective
Use aws s3 sync to synchronize a local directory with S3 and verify that only changed files are uploaded during a subsequent sync.
Create the test directory
mkdir s3-sync-lab
cd s3-sync-lab
echo "File 1 - original content" > file1.txt
echo "File 2 - original content" > file2.txt
Initial sync
aws s3 sync . s3://sse-kms-bucket-tejas/s3-sync-lab/
Then verify:
aws s3 ls s3://sse-kms-bucket-tejas/s3-sync-lab/
The first synchronization uploaded both files.
Modify one file
echo "File 1 - UPDATED content" > file1.txt
Run sync again:
aws s3 sync . s3://sse-kms-bucket-tejas/s3-sync-lab/
This time, only file1.txt was uploaded.
Result
The practical demonstrated the differential behavior of:
aws s3 sync
Unchanged content did not need to be uploaded again.
Practical takeaway
This is useful for scripts, backups and deployment workflows where repeatedly uploading an entire directory would be unnecessary.
6. S3 Versioning and Version Recovery
Objective
Enable Versioning, upload the same object key multiple times, identify the generated Version IDs, retrieve an older version, and remove the current version to demonstrate recovery.
Enable Versioning
Versioning was enabled on:
sse-kms-bucket-tejas
Create multiple versions
The same object key was reused:
version-test-tejas.txt
Version 1:
echo "Version 1 - Created by Tejas" > version-test-tejas.txt
aws s3 cp version-test-tejas.txt \
s3://sse-kms-bucket-tejas/version-test-tejas.txt
Version 2:
echo "Version 2 - Updated by Tejas" > version-test-tejas.txt
aws s3 cp version-test-tejas.txt \
s3://sse-kms-bucket-tejas/version-test-tejas.txt
Version 3:
echo "Version 3 - Final update by Tejas" > version-test-tejas.txt
aws s3 cp version-test-tejas.txt \
s3://sse-kms-bucket-tejas/version-test-tejas.txt
List versions
aws s3api list-object-versions \
--bucket sse-kms-bucket-tejas \
--prefix version-test-tejas.txt
The practical produced multiple retained versions for the same object key.
The latest version was marked as current, while previous versions remained available.
Retrieve an older version
aws s3api get-object \
--bucket sse-kms-bucket-tejas \
--key version-test-tejas.txt \
--version-id <VERSION_ID> \
version-1-retrieved.txt
Then:
cat version-1-retrieved.txt
The historical content was:
Version 1 - Created by Tejas
Delete the current version
A specific Version ID was deleted:
aws s3api delete-object \
--bucket sse-kms-bucket-tejas \
--key version-test-tejas.txt \
--version-id <CURRENT_VERSION_ID>
Then the versions were listed again:
aws s3api list-object-versions \
--bucket sse-kms-bucket-tejas \
--prefix version-test-tejas.txt
The previous version became the latest available version.
Troubleshooting
The version listing output opened in the terminal's less viewer.
To return to the shell:
q
There was also an initial retrieval mistake where:
cat version-1-retrieved.txt
failed because the file had not yet been created.
After running get-object successfully, the historical content could be read.
Result
This lab demonstrated that Versioning can preserve historical object states and allow an older version to be retrieved even after a newer version becomes current.
7. S3 Static Website Hosting
Objective
Host a simple static website directly from an S3 bucket and configure a custom error page.
Bucket
Bucket: s3-static-website-tejas
Region: ap-south-1
For this lab, Block Public Access was disabled because the website content needed to be publicly readable through the S3 website endpoint.
Website configuration
Static website hosting was enabled with:
Index document: cors-test.html
Error document: error.html
Verification
The S3 region-specific website endpoint was opened in a browser.
The website loaded successfully.
A non-existent page was then requested to trigger the configured error document.
The custom 404 page was returned successfully.
Result
Both paths were verified:
Valid request
↓
Index / website content
Invalid request
↓
Custom error.html
Practical takeaway
S3 website hosting is useful for simple static content, although real production architectures may place additional services such as CloudFront in front of the content.
8. S3 CORS
Objective
Demonstrate a browser cross-origin request failure and then allow the request using an S3 CORS configuration.
This was one of the more useful troubleshooting labs because the browser behavior made the difference between S3 access and browser CORS permission very visible.
Resources
Origin website bucket: s3-cors-origin-tejas
Object bucket: s3-cors-lab-tejas
Object: cat.jpg
The webpage was hosted from one S3 website origin and attempted to fetch the image from another S3 origin.
Test before CORS
The webpage attempted a JavaScript fetch() request for cat.jpg.
The browser blocked the response because the required:
Access-Control-Allow-Origin
header was not present.
The browser console showed the CORS failure.
Configure CORS
For the practical, the object bucket was configured with:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET"],
"AllowedOrigins": ["*"],
"ExposeHeaders": []
}
]
Test again
A fresh pre-signed URL was generated and the browser test was repeated.
The final response showed:
Response type: cors Status: 200 Access-Control-Allow-Origin: * Blob: image/jpeg
The image was successfully returned as a Blob.
Important troubleshooting observation
During the lab, an expired pre-signed URL initially produced:
HTTP 403
with an XML error response.
That was separate from the CORS configuration itself.
After generating a fresh pre-signed URL, the request returned:
HTTP 200
and included the CORS response header.
Another useful observation was that fetch() does not automatically reject its promise just because the HTTP status is 403. The response status should be checked explicitly.
For example:
fetch(url)
.then(response => {
console.log("Status:", response.status);
console.log("CORS header:",
response.headers.get("Access-Control-Allow-Origin"));
return response.blob();
})
.then(data => console.log("Blob:", data));
Result
The practical demonstrated both sides of CORS:
No CORS header
↓
Browser blocks cross-origin access
CORS configured
↓
HTTP 200 + CORS header
↓
JavaScript receives the object
9. S3 Object Lock
Objective
Protect an S3 object version using Governance Mode and verify that a normal delete request is blocked while retention is active.
Bucket configuration
A new bucket was created:
s3-object-lock-tejas
Versioning and Object Lock were enabled.
Default retention was left disabled because retention would be applied to the specific test object version.
Create and upload the test object
echo "Object Lock test created by Tejas" > tejas-object-lock.txt
aws s3 cp tejas-object-lock.txt \
s3://s3-object-lock-tejas/
Find the Version ID
aws s3api list-object-versions \
--bucket s3-object-lock-tejas \
--prefix tejas-object-lock.txt
The response provided the Version ID for the uploaded object.
Apply Governance retention
The object version was configured with Governance Mode and a one-day retention period:
aws s3api put-object-retention \
--bucket s3-object-lock-tejas \
--key tejas-object-lock.txt \
--version-id "<VERSION_ID>" \
--retention "Mode=GOVERNANCE,RetainUntilDate=<RETENTION_DATE>"
Verify retention
aws s3api get-object-retention \
--bucket s3-object-lock-tejas \
--key tejas-object-lock.txt \
--version-id "<VERSION_ID>"
The response showed:
{
"Retention": {
"Mode": "GOVERNANCE",
"RetainUntilDate": "<RETENTION_DATE>"
}
}
Attempt deletion
A normal delete was attempted:
aws s3api delete-object \
--bucket s3-object-lock-tejas \
--key tejas-object-lock.txt \
--version-id "<VERSION_ID>"
S3 rejected the operation with:
AccessDenied Access Denied because object protected by object lock.
Result
The retention worked as expected.
The object version could not be normally deleted while the Object Lock retention period was active.
Governance vs Compliance
The practical used Governance Mode.
The important distinction is:
- Governance Mode provides retention protection while allowing appropriately authorized bypass operations.
- Compliance Mode is stricter and is designed for scenarios where even privileged administrators should not be able to bypass the retention.
What These Practicals Demonstrated
After completing the labs, the S3 concepts were no longer just console options or documentation terms.
The practical sequence covered:
| Feature | Practical verification |
|---|---|
| Lifecycle | Automatic storage-class transition rules |
| Bucket Policy | IAM read + EC2 upload + HTTPS-only deny |
| SSE-KMS | S3 encryption using customer-managed KMS |
| CloudTrail |
GenerateDataKey and Decrypt events |
| Pre-signed URL | Temporary private-object access |
| CLI Sync | Differential synchronization |
| Versioning | Historical versions and recovery |
| Static Website | Website endpoint + custom 404 |
| CORS | Browser block → CORS-enabled success |
| Object Lock | Governance retention → delete blocked |
Troubleshooting Lessons
A few issues during the labs were particularly useful because they showed how AWS behaves in real situations.
1. CloudTrail events were not immediately obvious
Searching by event name exposed the expected KMS operations:
GenerateDataKey
Decrypt
2. Long AWS CLI output can open in less
When a command such as list-object-versions produces a long response:
q
returns to the shell.
3. Generate the output file before reading it
Running:
cat version-1-retrieved.txt
before running get-object results in:
No such file or directory
The correct sequence is:
S3 version
↓
get-object
↓
local file created
↓
cat local file
4. CORS and HTTP errors are different problems
A request can fail because:
- the object URL expired,
- S3 returned 403,
- the object does not exist,
- or the browser blocks a successful cross-origin response because the CORS headers are missing.
Testing with a fresh pre-signed URL helped separate the authentication/expiry issue from the CORS issue.
5. Object Lock actually blocks deletion
The Object Lock test was especially clear because S3 returned an explicit:
AccessDenied
when a protected object version was deleted.
Final Takeaway
The biggest value of these S3 labs was not memorizing the S3 console.
It was seeing how the different controls interact:
Amazon S3
|
+-------------+-------------+
| | |
Security Storage Access
| | |
IAM / Policy Lifecycle Pre-signed URL
HTTPS-only Versioning CORS
SSE-KMS Object Lock
|
CloudTrail
S3 becomes much easier to reason about once these features are connected to actual operations:
- Who can access the object?
- How is the object encrypted?
- How long should it remain in a storage class?
- What happens when the object is overwritten?
- Can an old version be recovered?
- Can the object be accessed temporarily without making it public?
- Can a browser access it cross-origin?
- Can the object be deleted during a retention period?
These practicals gave me hands-on verification of each of those questions rather than only theoretical answers.
Commands Used in the Labs
A compact list of the most important commands from the practicals:
# Pre-signed URL
aws s3 presign s3://sse-kms-bucket-tejas/presigned-test.txt --expires-in 300
# Sync local directory to S3
aws s3 sync . s3://sse-kms-bucket-tejas/s3-sync-lab/
# List object versions
aws s3api list-object-versions \
--bucket sse-kms-bucket-tejas \
--prefix version-test-tejas.txt
# Retrieve a specific version
aws s3api get-object \
--bucket sse-kms-bucket-tejas \
--key version-test-tejas.txt \
--version-id <VERSION_ID> \
version-1-retrieved.txt
# Delete a specific version
aws s3api delete-object \
--bucket sse-kms-bucket-tejas \
--key version-test-tejas.txt \
--version-id <VERSION_ID>
# List Object Lock versions
aws s3api list-object-versions \
--bucket s3-object-lock-tejas \
--prefix tejas-object-lock.txt
# Apply Object Lock retention
aws s3api put-object-retention \
--bucket s3-object-lock-tejas \
--key tejas-object-lock.txt \
--version-id "<VERSION_ID>" \
--retention "Mode=GOVERNANCE,RetainUntilDate=<RETENTION_DATE>"
# Verify Object Lock retention
aws s3api get-object-retention \
--bucket s3-object-lock-tejas \
--key tejas-object-lock.txt \
--version-id "<VERSION_ID>"
# Attempt deletion of a protected version
aws s3api delete-object \
--bucket s3-object-lock-tejas \
--key tejas-object-lock.txt \
--version-id "<VERSION_ID>"
Conclusion
This S3 practical series covered the service from basic bucket configuration through security, encryption, access control, synchronization, version recovery, static hosting, browser CORS behavior and immutable-object protection.
The most useful part was not simply getting a "Success" message from AWS. It was deliberately testing the expected behavior — including expired URLs, historical versions, browser CORS failures and an Object Lock deletion failure.
That is the kind of hands-on understanding I want to build as I continue learning AWS, Cloud and DevOps.
Top comments (0)