Skip to main content
AWS · lesson 4 of 10
AWS beginner 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

aws s3 localstack storage

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.”

Frequently Asked Questions

Can I follow this lesson without an AWS account?
Yes. S3 is one of the services LocalStack's open-source Community edition emulates well, so every command here runs against `awslocal` with no AWS account and no billing risk. The commands are identical against real AWS once you drop the LocalStack endpoint and use real credentials.
Why are S3 bucket names globally unique?
A bucket name resolves to a DNS hostname (bucket-name.s3.amazonaws.com), and DNS names must be unique across the entire internet, not just your account. This is why bucket names in tutorials look like acme-reports-2026-a91f — a random or account-specific suffix avoids collisions with every other AWS customer.
What does S3 versioning actually protect against?
Once enabled, every PUT to the same key creates a new version instead of overwriting the old one; a DELETE adds a delete marker rather than erasing data. It protects against accidental overwrites and deletes, including from a buggy script, by keeping every prior version retrievable until you explicitly delete a specific version ID. It does not protect against a bucket being deleted entirely, which is what MFA Delete and bucket policies with explicit Deny are for.
Is a bucket private or public by default?
Private. New buckets have Block Public Access enabled and no bucket policy, so only the bucket owner's credentials (or an explicit grant) can read or write objects. Public access requires deliberately disabling Block Public Access and attaching a policy that grants it — it does not happen by accident on a modern AWS account.