AWS · lesson 4 of 10
S3 Fundamentals
Create a bucket, upload and download objects, attach a bucket policy, and turn on versioning to recover an overwritten file — all against LocalStack.
By SkillByExample · Published
S3 stores objects — arbitrary bytes with a key — in buckets, and almost every other AWS service uses it somewhere underneath (Lambda deployment packages, CloudFormation templates, RDS snapshots). This lesson covers the operations you’ll reach for constantly: create, upload, download, restrict access, and recover from an overwrite.
This lesson runs on LocalStack via awslocal — S3 is one of the services its
open-source Community edition emulates faithfully, so there is no AWS account or billing
involved. Swap awslocal for aws against a real account and every command is identical.
Creating a bucket
awslocal s3 mb s3://acme-reports-2026
$ awslocal s3 mb s3://acme-reports-2026
make_bucket: acme-reports-2026
Uploading and listing
echo "region,revenue
us-east-1,184320
eu-west-1,96044" > q3-report.csv
awslocal s3 cp q3-report.csv s3://acme-reports-2026/exports/q3-report.csv
$ awslocal s3 cp q3-report.csv s3://acme-reports-2026/exports/q3-report.csv
upload: ./q3-report.csv to s3://acme-reports-2026/exports/q3-report.csv
awslocal s3 ls s3://acme-reports-2026/exports/
$ awslocal s3 ls s3://acme-reports-2026/exports/
2026-09-13 10:24:07 52 q3-report.csv
Downloading
awslocal s3 cp s3://acme-reports-2026/exports/q3-report.csv ./downloaded.csv
cat downloaded.csv
$ awslocal s3 cp s3://acme-reports-2026/exports/q3-report.csv ./downloaded.csv
download: s3://acme-reports-2026/exports/q3-report.csv to ./downloaded.csv
$ cat downloaded.csv
region,revenue
us-east-1,184320
eu-west-1,96044
A bucket policy
Bucket policies grant or deny access at the bucket level, evaluated alongside any IAM
policy the caller has — the effective permission is the intersection unless one has an
explicit Deny. This policy allows read-only public access to a public/ prefix, useful
for something like a static asset folder, while leaving everything else private.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadPublicPrefixOnly",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::acme-reports-2026/public/*"
}
]
}
awslocal s3api put-bucket-policy \
--bucket acme-reports-2026 \
--policy file://bucket-policy.json
awslocal s3api get-bucket-policy --bucket acme-reports-2026 --query Policy --output text
$ awslocal s3api get-bucket-policy --bucket acme-reports-2026 --query Policy --output text
{"Version": "2012-10-17", "Statement": [{"Sid": "PublicReadPublicPrefixOnly", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::acme-reports-2026/public/*"}]}
Objects under exports/ remain private — the policy’s Resource only names public/* —
which is exactly the least-privilege pattern from the iam lesson applied at the bucket
level instead of the user level.
Versioning: recovering an overwrite
awslocal s3api put-bucket-versioning \
--bucket acme-reports-2026 \
--versioning-configuration Status=Enabled
awslocal s3api get-bucket-versioning --bucket acme-reports-2026
$ awslocal s3api get-bucket-versioning --bucket acme-reports-2026
{
"Status": "Enabled"
}
Now overwrite the file, the way a buggy nightly job might:
echo "region,revenue
us-east-1,0" > q3-report.csv
awslocal s3 cp q3-report.csv s3://acme-reports-2026/exports/q3-report.csv
$ awslocal s3 cp q3-report.csv s3://acme-reports-2026/exports/q3-report.csv
upload: ./q3-report.csv to s3://acme-reports-2026/exports/q3-report.csv
The good data is not gone — it is a prior version:
awslocal s3api list-object-versions \
--bucket acme-reports-2026 --prefix exports/q3-report.csv \
--query 'Versions[].{VersionId:VersionId,IsLatest:IsLatest,Size:Size}'
$ awslocal s3api list-object-versions --bucket acme-reports-2026 --prefix exports/q3-report.csv --query 'Versions[].{VersionId:VersionId,IsLatest:IsLatest,Size:Size}'
[
{
"VersionId": "3HL4kqCxf3fSQhwR-9m2GB.uSDwT9Kk8",
"IsLatest": true,
"Size": 20
},
{
"VersionId": "QUE8bV.j2Xd.EImcaLBK9nWTfnjmS.Uy",
"IsLatest": false,
"Size": 52
}
]
awslocal s3api get-object \
--bucket acme-reports-2026 --key exports/q3-report.csv \
--version-id QUE8bV.j2Xd.EImcaLBK9nWTfnjmS.Uy recovered.csv
cat recovered.csv
$ cat recovered.csv
region,revenue
us-east-1,184320
eu-west-1,96044
The 52-byte version — the original — is still fully readable, addressed directly by
VersionId, even though the key now points at the 20-byte broken overwrite by default.
Practice
1. Create a bucket with an invalid (already-DNS-reserved-looking) name and read the error.
awslocal s3 mb s3://Acme_Reports
$ awslocal s3 mb s3://Acme_Reports
make_bucket failed: s3://Acme_Reports An error occurred (InvalidBucketName) when calling
the CreateBucket operation: The specified bucket is not valid.
Bucket names must be lowercase, no underscores — because they become part of a DNS hostname, and DNS labels are case-insensitive and don’t allow underscores.
2. Try to GET an object under exports/ with no credentials, confirming the bucket policy does not leak it.
curl -s -o /dev/null -w "%{http_code}\n" \
http://localhost:4566/acme-reports-2026/exports/q3-report.csv
403
Compare to a public/ object:
awslocal s3 cp q3-report.csv s3://acme-reports-2026/public/q3-report.csv
curl -s -o /dev/null -w "%{http_code}\n" \
http://localhost:4566/acme-reports-2026/public/q3-report.csv
200
The prefix in the policy’s Resource is doing exactly what it says.
3. Delete the current object version and list versions again.
awslocal s3api delete-object --bucket acme-reports-2026 --key exports/q3-report.csv
awslocal s3api list-object-versions --bucket acme-reports-2026 \
--prefix exports/q3-report.csv --query 'DeleteMarkers[].IsLatest'
[true]
A delete marker, not a real deletion — aws s3 ls will now show nothing at that key, but
every prior version is still retrievable by version ID until the delete marker itself is
removed.
4. Restore the file by removing the delete marker.
awslocal s3api list-object-versions --bucket acme-reports-2026 \
--prefix exports/q3-report.csv \
--query 'DeleteMarkers[0].VersionId' --output text
GgP0F1wnRQyHqz3.NNzjxN2VwEmxtRE9
awslocal s3api delete-object --bucket acme-reports-2026 \
--key exports/q3-report.csv --version-id GgP0F1wnRQyHqz3.NNzjxN2VwEmxtRE9
awslocal s3 ls s3://acme-reports-2026/exports/
2026-09-13 10:31:44 20 q3-report.csv
Deleting the specific version ID of a delete marker un-deletes the key — a subtlety that trips people up the first time they need it.
5. Compute the total size of everything under exports/.
awslocal s3api list-objects-v2 --bucket acme-reports-2026 --prefix exports/ \
--query 'sum(Contents[].Size)'
20
sum() inside a --query expression avoids piping through jq for a simple aggregate —
worth remembering before reaching for an external tool.
Next: VPC and networking — subnets, route tables, and the internet gateway that makes or breaks “why can’t my instance reach the internet.”