AWS Storage

Amazon S3 Fundamentals Hands-On: Buckets, Objects, Versioning & Presigned URLs

Quick take: Amazon S3 looks like a folder full of files and is nothing of the sort. A bucket is a globally-unique, Region-bound container; an object is a blob addressed by a flat key; the “folders” you see in the console are a lie the UI tells you using the / delimiter. Get those three ideas right and the rest — versioning, presigned URLs, multipart upload, static hosting — clicks into place. Get them wrong and you meet BucketAlreadyExists, a 403 you can’t explain, a presigned link that 403s at the wrong minute, and a multipart upload quietly billing you forever. This is the hands-on ground floor, built so the deep articles stand on solid rock.

You will create your first S3 bucket in the next ten minutes, and the single most important thing to understand before you do is that S3 is an object store, not a filesystem. There are no directories, no rename, no “move” — there is a flat namespace of keys mapping to immutable blobs, and every richer feature is layered on top of that one primitive. A bucket is a named container that lives in exactly one Region and whose name must be unique across every AWS account on Earth. An object is the thing you store: a value (0 bytes to 5 TB), a key (the full path-like string, up to 1,024 bytes), and metadata. That is the entire data model. Everything else — versioning, lifecycle, website hosting, presigned URLs — is a behaviour attached to buckets and objects.

The reason this article exists is that almost every S3 mistake a beginner makes traces back to imagining a filesystem that is not there. You try to “create a folder” and wonder why it is empty. You expect mv and there is only copy-then-delete. You overwrite an object and expect an undo, then learn about versioning the hard way. You share a private object by making the whole bucket public because you never met presigned URLs. You upload a 4 GB file with a single PUT and hit EntityTooLarge because you never met multipart upload. Each of these is a five-minute fix once you hold the right mental model, and a multi-hour outage or a surprise bill when you do not.

By the end you will create buckets from both the AWS CLI and Terraform, upload objects with cp and sync, push a large file as a multipart upload and stop the cost leak of abandoned parts, mint a presigned GET URL and hand it to someone with no AWS account, turn on versioning and restore an object you overwrote, stand up a static website, and tear the whole thing down cleanly. You will also walk out with a troubleshooting playbook for the seven errors that eat a beginner’s afternoon. This is a Beginner article that does not talk down to you: real limits, real error strings, real commands.

What problem this solves

Storing files is the most basic thing a cloud does, and it is also where teams quietly lose the most time and money because the abstraction is subtly different from the local disk they grew up on. S3 solves durable, unlimited, pay-per-use object storage — eleven nines of durability, no capacity to provision, no server to patch, reachable over HTTPS from anywhere. But “it’s just files in the cloud” is exactly the wrong intuition, and the gap between that intuition and how S3 actually works is the problem this article closes.

Without a correct model of buckets, keys and the flat keyspace, the failures are specific and repetitive. You cannot create a bucket because the name is taken (BucketAlreadyExists) — because names are globally unique, not per-account. Your upload is denied (AccessDenied) and you cannot tell whether it is IAM, the bucket policy, or Block Public Access. Your “folder” is empty and confusing because folders are not real. Your presigned URL works for you and 403s for the recipient. Your sync re-uploads gigabytes that did not change. Your static site returns XML instead of your homepage because you hit the REST endpoint, not the website endpoint. Every one of these has a precise cause and a one-line fix — once you know the model.

Who hits this: everyone, on day one. S3 is the most-used AWS service and the first one most people touch, which means the beginner mistakes above are made millions of times a year. It underpins nearly everything else — CloudFront serves from it, data lakes are built on it, backups land in it, Terraform state lives in it, CI artifacts pile up in it. Learning S3 correctly is not optional foundational trivia; it is the substrate for a career’s worth of AWS work. Here is the field this article covers and the one trap attached to each idea:

Concept you’ll master What it really is The beginner trap it removes
Bucket Globally-unique, Region-bound container Thinking the name is per-account → BucketAlreadyExists
Object Value + key + metadata, immutable Expecting in-place edit / rename
Key Flat UTF-8 string up to 1,024 bytes Thinking a/b/c.txt implies real folders
Prefix Leading part of a key + / delimiter Believing the console’s “folders” exist
Durability & consistency 11 nines, strong read-after-write Coding retries for stale reads you’ll never see
Multipart upload Parallel chunked PUT for big objects Single-PUT a 6 GB file → EntityTooLarge
Presigned URL Time-limited, credential-free access to one object Making the whole bucket public to share one file
Versioning Keep every overwrite as a version Overwrite = permanent loss, no undo
Static hosting Serve a site from a bucket Hitting the REST endpoint and getting XML

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You need an AWS account with the AWS CLI v2 installed and configured (aws configure with an access key, or better, an SSO/IAM Identity Center profile), permission to use S3 (the managed AmazonS3FullAccess policy is fine for a sandbox), and optionally Terraform ≥ 1.5 if you want the IaC path. You should be comfortable running commands in a terminal and reading JSON. You do not need to know IAM policy grammar yet — this article points to where that lives without requiring it.

This is a Foundations / Storage article and the entry point to the S3 track. It is upstream of almost everything: once buckets and objects make sense, Amazon S3 Storage Classes and Lifecycle teaches you to cut the bill by matching each object to its access pattern; the security half — keeping buckets private, writing bucket policies, and the four Block Public Access switches — lives in S3 Bucket Policies and Block Public Access; and when you want a real CDN with HTTPS in front of a private bucket, Amazon CloudFront Hands-On: CDN Setup, Origins, Caching picks up exactly where the static-hosting section here stops. Before any of it, Securing a Brand-New AWS Account makes sure you are not doing this as the root user.

A quick map of who owns what in an S3 setup, so a design review talks to the right people:

Layer What lives here Who usually owns it What it can break
Bucket identity Name, Region, ownership Platform / cloud team Global-uniqueness clash; wrong-Region latency
Access control IAM, bucket policy, Block Public Access Security team Public leak, or 403 on legitimate access
Object producers PutObject, key layout, storage class App / data team Hot prefixes, cold data on the wrong class
Data protection Versioning, Object Lock, replication Platform + compliance Silent overwrite loss; runaway version cost
Delivery Presigned URLs, website endpoint, CloudFront App / platform Public buckets; HTTP-only sites; expired links
Cost & cleanup Lifecycle, abort-MPU, expiry FinOps + platform Orphaned multipart parts; version bloat

Core concepts

Five ideas make every later decision obvious. Internalise these and the CLI just becomes typing.

A bucket is a globally-unique, Region-bound container. When you create a bucket, the name you choose must be unique across every AWS account in the partition (the standard aws partition covers all commercial Regions), not just your account — because S3 addresses buckets by DNS name (my-bucket.s3.ap-south-1.amazonaws.com) and DNS is global. The bucket then physically lives in one Region you pick at creation and never leaves it; that is where the bytes sit and what determines latency and data-residency. There is no nesting — a bucket cannot contain another bucket. This is why your first create-bucket may fail with BucketAlreadyExists: someone, somewhere, already took that name.

An object is a value, a key, and metadata — and it is immutable. An object is the unit you store: the value (the bytes, 0 to 5 TB), the key (the full name, e.g. uploads/2026/07/clip.mp4, up to 1,024 bytes of UTF-8), and metadata (system-set like Content-Type and Content-Length, plus optional user-defined x-amz-meta-* headers up to 2 KB total). You cannot edit an object in place or append to it; you overwrite it with a whole new PUT, which (without versioning) replaces it entirely. “Rename” and “move” do not exist as primitives — they are copy-to-new-key-then-delete-old.

The keyspace is flat; folders are a UI illusion. There are no directories in S3. The key logs/2026/app.log is a single flat string that happens to contain slashes. S3 treats / as an ordinary character; the console fabricates a folder tree by asking S3 to group keys on the / delimiter and showing you the common prefixes. A prefix is just the leading substring of a key. This matters because it explains why “empty folders” are weird (they are zero-byte placeholder objects the console creates), why ListObjectsV2 with --delimiter / returns CommonPrefixes, and why request-rate scaling is described per prefix.

Durability is eleven nines and consistency is strong. S3 stores each object redundantly across at least three Availability Zones in the Region, giving 99.999999999% (11 nines) of designed durability and 99.99% designed availability for S3 Standard — you are not going to lose an object to a disk failure. Since December 2020, S3 also gives strong read-after-write consistency by default, in every Region, at no extra cost: write a new object or overwrite an existing one, and the very next GET or LIST returns the latest data. You do not need to code around eventual consistency anymore; that advice is stale.

Everything richer is a toggle on buckets and objects. Versioning, lifecycle, static website hosting, replication, Object Lock, requester pays, transfer acceleration — all of these are configurations you switch on for a bucket, and presigned URLs, byte-range GETs, multipart upload and tags are behaviours of the object API. None of them change the core model; they layer onto it. Learn the model once and each feature is a small addition rather than a new mystery.

The vocabulary in one table

Pin down every moving part before the deep sections. The glossary at the end repeats these for lookup; this is the mental model side by side:

Term One-line definition Where it lives Why it matters
Bucket Named, Region-bound, globally-unique container The Region you pick Unit of ownership, policy, billing
Object Value + key + metadata, immutable blob Inside a bucket The thing you actually store
Key Full UTF-8 name of an object, ≤ 1,024 bytes Per object Addresses the object; drives prefix scaling
Value / body The bytes, 0 B – 5 TB Per object What you PUT and GET
Prefix Leading part of a key (up to a /) Logical, not stored Fakes folders; unit of request-rate scaling
Delimiter Character (/) used to group keys LIST request param How the console builds a folder tree
Metadata System + user key/value pairs on an object Per object Content-Type, cache, custom x-amz-meta-*
Version ID Unique id for one version of a key Versioned bucket Lets you keep/restore/delete versions
Delete marker Placeholder that hides all versions of a key Versioned bucket A “delete” that is reversible
Presigned URL Time-limited signed URL to one object Generated client-side Credential-free share/upload
Multipart upload Chunked, parallel PUT of a large object Object API Uploads > 100 MB reliably; can leak cost
ETag Hash/identifier of object content Per object/version Change detection; multipart ETags differ

Buckets: naming, Region, and the “no folders” truth

A bucket is the first thing you create and the hardest to change later (you cannot rename or move it — you make a new one and copy). Two decisions matter: the name and the Region.

The naming rules that actually bite

S3 general-purpose bucket names follow DNS rules because the name becomes a hostname. Break them and create-bucket fails with InvalidBucketName; bend them (dots) and virtual-hosted HTTPS breaks subtly. The full ruleset:

Rule Requirement Why it exists Failure if broken
Length 3–63 characters DNS label limits InvalidBucketName
Characters Lowercase a–z, 0–9, ., - DNS-safe set InvalidBucketName
Start / end Begin and end with a letter or number DNS labels InvalidBucketName
No adjacent dots .. not allowed DNS validity InvalidBucketName
Not an IP Must not look like 192.168.5.4 Avoid host ambiguity InvalidBucketName
Global uniqueness Unique across all accounts in the partition DNS is global BucketAlreadyExists (409)
No xn-- prefix Reserved (Punycode) Internationalized-domain safety InvalidBucketName
No reserved prefixes Not sthree-, amzn-s3-demo- AWS-reserved InvalidBucketName
No reserved suffixes Not -s3alias, --ol-s3, --x-s3, .mrap Access-point / directory-bucket reserved InvalidBucketName
Dots + TLS Avoid . unless hosting a website . breaks the *.s3 wildcard cert HTTPS cert mismatch

The practical rule: lowercase, hyphens not dots, prefix with your org and a purpose, and add a unique suffix. Something like kloudvin-media-prod-ap-south-1 collides with nobody and reads clearly. Reserve dotted names (www.example.com) only for the static-website case where the bucket name must match the domain.

Region choice and addressing

A bucket lives in one Region, chosen at creation. That Region drives latency (put data near its users/compute), data residency (compliance), and cost (per-GB rates vary). Two addressing styles reach the same bucket:

Addressing style Form Notes
Virtual-hosted (preferred) https://bucket.s3.ap-south-1.amazonaws.com/key Modern default; breaks on dotted names over TLS
Path-style (legacy) https://s3.ap-south-1.amazonaws.com/bucket/key Being deprecated; avoid for new work
Website endpoint http://bucket.s3-website-ap-south-1.amazonaws.com HTTP only; index/error docs (see hosting section)
Global us-east-1 quirk https://bucket.s3.amazonaws.com Legacy global endpoint resolves to us-east-1

There is one creation gotcha worth memorising now: in us-east-1 you create a bucket with no location constraint, but in every other Region you must pass a LocationConstraint (CLI: --create-bucket-configuration LocationConstraint=<region>), or you get an error. The Region also decides the endpoint you must talk to — send a request to the wrong regional endpoint and S3 answers with a 301 PermanentRedirect or 400 AuthorizationHeaderMalformed telling you the expected Region.

There are no folders

Say it once more because it is the root of half of all S3 confusion: there are no folders. The console’s folder tree is generated on the fly from key prefixes and the / delimiter. When you “create a folder” named photos/ in the console, S3 stores a zero-byte object with the key photos/ as a placeholder so the empty folder shows up. Delete every object under a prefix and the “folder” vanishes, because it never existed as a container.

You think Reality in S3 Consequence
a/b/c.txt is a file in nested folders One flat key: the string a/b/c.txt No traversal cost; prefixes are free
Creating a folder makes a container Optionally makes a folder/ zero-byte object “Empty folders” are real objects
Moving a file is cheap Copy to new key + delete old “Move” is two operations, billed as such
Listing a folder is a directory read LIST with a prefix + delimiter Pagination applies; 1,000 keys per page

Objects, keys, metadata and the flat keyspace

An object is value + key + metadata. Each part has limits and behaviours worth knowing cold.

Object anatomy

Part What it is Limit / detail
Value (body) The bytes you store 0 bytes to 5 TB per object
Key The object’s full name Up to 1,024 bytes UTF-8; any character (slashes are literal)
Version ID Identifies a version (if versioning on) Opaque string; null on unversioned objects
System metadata S3/HTTP-managed Content-Type, Content-Length, Last-Modified, ETag, storage class
User metadata Your x-amz-meta-* headers Total ≤ 2 KB; returned on GET/HEAD; set at write time
Tags Up to 10 key/value pairs Separate from metadata; used by lifecycle, IAM, cost allocation
ACL / policy Legacy per-object ACL + bucket policy Prefer bucket policy + BPA (security sibling)

The limits that decide how you upload

These numbers govern day-to-day behaviour. Memorise the first three:

Limit Value What hits it What to do instead
Max object size 5 TB Huge media/backups Multipart upload
Max single-PUT size 5 GB One-shot PutObject of a big file Multipart (auto in CLI/SDK)
Multipart max parts 10,000 Very large objects / tiny parts Increase part size
Multipart part size 5 MB – 5 GB (last part any size) Chunking large uploads Keep parts ≥ 5 MB
Multipart recommended above ~100 MB Reliability on big files Let the CLI/SDK auto-multipart
Key length 1,024 bytes Very deep “paths” Shorten key structure
User metadata total 2 KB Stuffing data into headers Store data in the object/DB
Tags per object 10 Over-tagging Use fewer, meaningful tags
Buckets per account 10,000 default (up to 1,000,000 via quota) Many-bucket designs Request a quota increase
Objects per bucket Unlimited

System vs user metadata

Metadata travels with the object and is returned on GET/HEAD. System metadata is mostly set for you; user metadata is yours to define at write time (and immutable afterward without a copy):

Metadata Set by Example Mutable without re-PUT?
Content-Type You (or guessed) video/mp4 No — copy object onto itself to change
Content-Length S3 5242880 No
Last-Modified S3 timestamp No
ETag S3 MD5 (single PUT) or -N composite (multipart) No
Cache-Control You max-age=86400 Via copy
x-amz-meta-* You x-amz-meta-owner: vinod Via copy
Storage class You / lifecycle STANDARD, GLACIER Via transition/copy

Prefixes, delimiters, and listing

Listing is where the flat keyspace shows through. ListObjectsV2 returns up to 1,000 keys per page (use the continuation token to paginate). Add a delimiter and S3 rolls keys up into common prefixes — that is exactly how the console draws folders:

LIST parameter Effect Example
--prefix Only keys starting with this string --prefix logs/2026/
--delimiter Group keys sharing a prefix up to the delimiter --delimiter /CommonPrefixes
--max-keys Cap results per page (≤ 1,000) --max-keys 100
--start-after Begin listing after this key resume/paginate
--continuation-token Fetch the next page from previous response
# List "top-level folders" under a prefix — this is what the console does
aws s3api list-objects-v2 --bucket kloudvin-media-prod \
  --prefix uploads/ --delimiter / \
  --query 'CommonPrefixes[].Prefix'
# Output: [ "uploads/2026/", "uploads/archive/" ]

Request-rate scaling is per prefix. S3 automatically scales to at least 3,500 PUT/COPY/POST/DELETE and 5,500 GET/HEAD requests per second per prefix, and there is no limit on the number of prefixes. If you hammer a single hot prefix you can see 503 SlowDown; spread writes across more prefixes (e.g. by leading the key with a high-cardinality token) and throughput scales roughly linearly.

Request type Per-prefix baseline (req/s) How to scale further
GET / HEAD 5,500 More prefixes; add CloudFront in front
PUT / COPY / POST / DELETE 3,500 More prefixes; parallelize writes
Number of prefixes Unlimited Design keys for spread, not sequential hotspots

The object API: PUT, GET, LIST, DELETE, multipart, byte-range

Everything you do to objects is one of a handful of API calls. The high-level aws s3 commands (cp, sync, mv, rm, ls) are ergonomic wrappers; the low-level aws s3api commands map one-to-one to the REST API and expose every parameter.

Operation REST / s3api call High-level aws s3 Notes
Upload one object put-object cp Single PUT ≤ 5 GB; auto-multipart in high-level
Download one object get-object cp Supports byte-range
List objects list-objects-v2 ls 1,000/page; use prefix + delimiter
Delete one object delete-object rm 204 on success; delete marker if versioned
Delete many delete-objects rm --recursive Up to 1,000 keys per call
Copy object copy-object cp (S3→S3) Server-side; also does metadata/class change
Get metadata only head-object Cheap existence/size/class check
Sync a tree (many calls) sync Compares size + timestamp, uploads deltas
Start multipart create-multipart-upload (auto in cp) Returns an UploadId
Multipart cleanup list-/abort-multipart-upload Stop paying for orphaned parts

Single PUT vs multipart upload

A single PutObject tops out at 5 GB. Beyond that you must use multipart upload, and AWS recommends it for anything over ~100 MB even under 5 GB, because it uploads parts in parallel, lets you retry a failed part instead of the whole file, and survives flaky networks. The high-level aws s3 cp/sync do this automatically once a file crosses the configured threshold.

Dimension Single PUT Multipart upload
Max size 5 GB 5 TB (10,000 parts × ≤ 5 GB)
Parallelism None Parts upload concurrently
Retry granularity Whole object Per part
Best for Small/medium objects Large files, unreliable links
Failure cost Re-upload everything Re-upload one part
Hidden risk None Abandoned parts keep billing

Multipart part-size math matters: parts must be 5 MB–5 GB (except the final part) and there are at most 10,000 parts, so for a 5 TB object you need part sizes around 512 MB+. The AWS CLI exposes the knobs:

# Tune when/how the CLI switches to multipart (defaults: 8 MB threshold, 8 MB chunks)
aws configure set default.s3.multipart_threshold 100MB
aws configure set default.s3.multipart_chunksize 64MB
aws configure set default.s3.max_concurrent_requests 10
CLI setting Default Meaning
multipart_threshold 8 MB File size above which cp/sync uses multipart
multipart_chunksize 8 MB Size of each part
max_concurrent_requests 10 Parallel part transfers
max_queue_size 1,000 Task queue depth

The one thing that costs money silently: if a multipart upload is interrupted and never completed or aborted, the uploaded parts stay in the bucket, invisible to ls, and you are billed for them. Set a lifecycle rule to abort incomplete uploads (shown in the lab), and audit with list-multipart-uploads.

Byte-range GETs

You do not have to download a whole object. A byte-range GET fetches just the bytes you ask for (Range: bytes=0-1048575), returning HTTP 206 Partial Content. It powers video seeking, resumable downloads, and parallel/multi-threaded downloaders:

# Download only the first 1 MiB of a large object
aws s3api get-object --bucket kloudvin-media-prod --key masters/film.mov \
  --range bytes=0-1048575 first-mib.bin
# Response includes: "ContentRange": "bytes 0-1048575/5368709120", HTTP 206
Use case Range behaviour Status code
Video/audio seek Player requests the needed byte window 206
Resumable download Resume from the last received byte 206
Parallel download Many threads fetch disjoint ranges 206
Range past EOF Requested range not satisfiable 416 InvalidRange

Presigned URLs: credential-free access to one object

This is the feature every beginner needs and few discover in time. A presigned URL is a normal HTTPS URL with a signature baked into the query string that grants temporary, credential-free access to exactly one object for one operation. You (a principal with the right permissions) generate it; you hand it to anyone — a browser, a partner, a mobile app — and they use it with no AWS account and no credentials. It is how you share a private object without making the bucket public, and how a browser uploads straight to S3 without your keys ever touching the client.

The mechanics: the URL encodes the signer’s identity, a timestamp (X-Amz-Date), an expiry (X-Amz-Expires), the allowed HTTP method, and a signature. S3 validates all of it on use. The permissions the URL carries are the intersection of the signer’s permissions and the operation — a presigned PUT only works if the signer can s3:PutObject.

Generating them

# Presigned GET (download) — CLI supports GET directly; default expiry 3600s
aws s3 presign s3://kloudvin-media-prod/reports/q2.pdf --expires-in 900
# Output: https://kloudvin-media-prod.s3.ap-south-1.amazonaws.com/reports/q2.pdf?
#         X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Date=...&
#         X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Signature=...

The AWS CLI presign command only makes GET URLs. For a presigned PUT (browser-direct upload), use an SDK:

import boto3
s3 = boto3.client("s3", region_name="ap-south-1")
url = s3.generate_presigned_url(
    ClientMethod="put_object",
    Params={"Bucket": "kloudvin-media-prod", "Key": "uploads/user123/avatar.png",
            "ContentType": "image/png"},
    ExpiresIn=600,          # 10 minutes
)
# The client does:  curl -X PUT -H "Content-Type: image/png" --upload-file avatar.png "<url>"

GET vs PUT presigned URLs

Aspect Presigned GET Presigned PUT
Purpose Let someone download a private object Let someone upload without your keys
CLI support aws s3 presign SDK only (generate_presigned_url)
Signer must have s3:GetObject s3:PutObject
Method must match GET PUT (mismatch → 403)
Common use Email a report link, temp media Browser/mobile direct upload
Extra guard Constrain Content-Type, size (via policy/POST)

Expiry — the number that trips people

The maximum lifetime of a presigned URL depends on what credentials signed it, and this is the single most common presigned-URL bug:

Signing credential Max expiry Practical note
IAM user long-term access key 7 days (604,800 s) The absolute ceiling
STS temporary credentials (assume-role) ≤ remaining life of those creds Often far less than 7 days
IAM role on EC2 / instance profile ≤ credential rotation window Can be ~1 hour
IAM Identity Center (SSO) session ≤ session duration Frequently 1–8 hours
CLI default (if --expires-in omitted) 3,600 s (1 hour) Set it explicitly

The trap: you generate a “7-day” link on an EC2 instance using its role, and it dies in an hour because the underlying temporary credentials expire first. If you need the full 7 days, sign with a long-term IAM user key (and guard that key).

S3 Transfer Acceleration (a first mention)

For uploads/downloads from far away, S3 Transfer Acceleration routes traffic over the CloudFront edge network to the bucket’s Region, often materially faster over long distances. It uses a distinct endpoint and costs extra per GB:

Property Detail
Endpoint bucket.s3-accelerate.amazonaws.com
Enable put-bucket-accelerate-configuration --accelerate-configuration Status=Enabled
Requires DNS-compliant bucket name without dots
Cost Additional per-GB transfer fee (only pay if it’s actually faster)
When to use Global users uploading to a single-Region bucket
When not Same-Region traffic (no benefit, just cost)

Versioning: keep every overwrite, restore any mistake

By default, a PUT to an existing key overwrites and destroys the old object — there is no undo. Turn on versioning and S3 keeps every version of a key: an overwrite makes the new object the current version and demotes the old one to noncurrent (both retained, both billed); a delete inserts a delete marker instead of removing anything.

The three versioning states

State Meaning Reversible?
Unversioned (default) No versions; overwrite destroys old
Enabled Every version kept; deletes add markers Can be suspended
Suspended New objects get null version; existing versions retained Can re-enable

Once a bucket has been versioning-enabled, it can only be suspended, never returned to pristine “unversioned.” Suspending does not delete existing versions — it just stops making new ones (new writes get a null version id).

Delete markers and how “delete” works

With versioning on, DeleteObject without a version id does not delete anything — it adds a delete marker that becomes the current version, so a subsequent GET returns 404 as if the object were gone. Nothing is actually removed; every version still sits behind the marker, still billed. To truly remove data you must delete specific versions by id.

Operation On a versioned bucket Result
PUT existing key New current version; old → noncurrent Both retained
DELETE (no version id) Inserts a delete marker Object “hidden”, 404 on GET, nothing freed
DELETE with version id (a real version) Permanently deletes that version Bytes freed
DELETE with version id (a delete marker) Removes the marker Object restored to previous current
GET (no version id) Returns current version (or 404 if marker) Latest
GET with version id Returns that exact version Time travel

Restoring an overwritten or deleted object

Two restore moves you will use constantly:

MFA-delete

For high-value buckets you can add MFA-delete, which requires a valid MFA code to permanently delete a version or to change the bucket’s versioning state. It is deliberately awkward: it can only be enabled by the bucket owner using the root account’s MFA, via the CLI/API (not the console), and versioning must be enabled. Use it for compliance/immutability-adjacent needs; for stronger guarantees reach for Object Lock (covered in the storage-classes and security articles).

Versioning feature What it protects against Cost / caveat
Versioning Accidental overwrite/delete You pay for every retained version
Noncurrent-version lifecycle Version bloat / runaway cost Configure expiry (see lifecycle article)
Delete markers Reversible deletes They accumulate; clean up dangling markers
MFA-delete Malicious/accidental permanent delete Root-only, CLI-only, awkward by design

Static website hosting (and why CloudFront is better)

S3 can serve a static website — HTML/CSS/JS/images, no server-side code — straight from a bucket. You enable website hosting, name an index document (index.html) and an error document (error.html), and S3 exposes a website endpoint. This is the fast way to publish a landing page, docs site, or SPA.

Website endpoint vs REST endpoint — the confusion that generates the most 403s

The bucket answers on two different endpoints that behave completely differently. Hitting the wrong one is the classic “why is my site returning XML?” bug:

Aspect Website endpoint REST endpoint
Host form bucket.s3-website-REGION.amazonaws.com (dash) or bucket.s3-website.REGION.amazonaws.com (dot) bucket.s3.REGION.amazonaws.com
Protocol HTTP only (no HTTPS) HTTPS
Root request / Serves the index document Lists/denies; returns XML, not index.html
Errors Serves your custom error document Returns S3 XML error
Auth Anonymous (needs public read) SigV4 for private objects
Redirect rules Supported Not supported
Best for Simple public sites API-style object access

Note the endpoint format varies by Region age: older Regions use a dash (s3-website-us-east-1), newer ones a dot (s3-website.ap-south-1). Always confirm with get-bucket-website or the console.

Making it publicly readable — carefully

The website endpoint serves anonymous requests, so the objects must be publicly readable. That means either relaxing Block Public Access and attaching a public-read bucket policy, or — far better — keeping the bucket private and putting CloudFront + Origin Access Control (OAC) in front. Block Public Access is on by default for good reason; turning it off is a deliberate act, covered in the security sibling.

// Public-read bucket policy for a *website* bucket (only if you truly want it public)
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "PublicReadForWebsite",
    "Effect": "Allow",
    "Principal": "*",
    "Action": "s3:GetObject",
    "Resource": "arn:aws:s3:::kloudvin-site-prod/*"
  }]
}

Why CloudFront + OAC wins in production

Requirement S3 website endpoint CloudFront + OAC over private bucket
HTTPS / TLS No (HTTP only) Yes (ACM cert, HTTP→HTTPS redirect)
Custom domain Via DNS, but HTTP Yes, with cert
Bucket stays private No (needs public read) Yes (OAC signs requests)
Caching / global edge No Yes (600+ POPs)
WAF / geo-restriction No Yes
Cost at scale Data transfer from S3 Cheaper egress + caching

The takeaway for beginners: the S3 website endpoint is a great way to learn and to publish throwaway pages, but any real site should be a private bucket behind CloudFront with OAC. That is exactly the setup in Amazon CloudFront Hands-On.

Storage classes, tags, metadata and requester pays — a first look

Every object has a storage class that trades price for retrieval latency and minimums. As a beginner you will use S3 Standard (the default) for everything; just know the others exist and that a one-line lifecycle rule can move cold data to cheaper classes automatically. The full option-by-option treatment — the minimums, retrieval fees, and the traps that erase the saving — is in S3 Storage Classes and Lifecycle.

Storage class One-liner When (as a beginner)
S3 Standard Hot, millisecond, no retrieval fee Your default; leave it here
Intelligent-Tiering Auto-tiers by access, small monitoring fee Unknown/mixed access, large objects
Standard-IA Cheaper storage, retrieval fee, 30-day/128 KB minimums Warm data ≥ 128 KB (via lifecycle)
One Zone-IA Standard-IA in one AZ, ~20% cheaper Reproducible data only
Glacier Instant / Flexible / Deep Archive Archive, cheap storage, restore latency Long-term archives (later)

Tags vs metadata confuse newcomers because both are key/value pairs on an object. They serve different jobs:

Feature Object tags Object metadata
Count / size Up to 10 pairs User metadata ≤ 2 KB total
Mutable Yes, independently (put-object-tagging) No — requires a copy to change
Used by Lifecycle filters, IAM conditions, cost allocation HTTP behaviour (Content-Type, cache)
Returned on GET No (separate call) Yes, as headers
Billed Small per-tag charge Included

Requester Pays flips who pays for access: normally the bucket owner pays for storage, requests and egress; enable Requester Pays and the requester pays for requests and data transfer (owner still pays storage). It is used for large public datasets. Requesters must opt in per request:

Aspect Detail
Enable put-bucket-request-payment --request-payment-configuration Payer=Requester
Requester must send x-amz-request-payer: requester (CLI: --request-payer requester)
Anonymous access Disabled on Requester-Pays buckets
Response confirms x-amz-request-charged: requester
Missing the header 403 AccessDenied

Architecture at a glance

The diagram traces a single S3 request from left to right. A client — the AWS CLI, an SDK, or a browser — signs each request with SigV4 and sends it to the bucket’s regional S3 endpoint, where IAM + the bucket policy + Block Public Access decide allow or deny before a byte moves. The request resolves against the globally-unique, Region-bound bucket (which gives you 11 nines of durability and strong read-after-write consistency), then to an object addressed by its key inside a flat keyspace where the “folders” are only prefixes; with versioning on, every overwrite is retained behind delete markers. Two delivery variants branch off the right: a presigned URL lets someone with no AWS credentials GET or PUT exactly one object until it expires, and the static-website endpoint serves your index/error documents over HTTP. The six numbered badges mark the exact hops where beginner failures bite — a presigned 403, an AccessDenied/BPA denial, a BucketAlreadyExists name clash, a hot-prefix 503 SlowDown, a versioning/delete-marker surprise, and the website-vs-REST-endpoint mixup — and the legend narrates each as symptom, confirm and fix.

S3 request path from a CLI/SDK client through the regional endpoint and auth into a globally-unique bucket, resolving to objects by key and prefix with retained versions, and branching to a presigned-URL path and a static-website path, with six numbered failure badges

Real-world scenario

KloudVin Learning, a fictional but very typical ed-tech startup in Bengaluru, needed to store and serve course videos, lesson PDFs, and student-uploaded assignments — and did every beginner thing wrong before getting it right.

The first mistake was the bucket name. An engineer tried to create videos in the console and got BucketAlreadyExists. He assumed the tool was broken; in fact the name is globally unique and had been taken years ago. They renamed to kloudvin-learning-media-ap-south-1, which read clearly and never collided.

The second mistake was sharing. To let a partner download a single 2 GB masterclass file, someone flipped off Block Public Access and made the whole bucket public — exposing every student’s uploaded assignment to the internet for three days until a security review caught it. The fix was a presigned GET URL: a 15-minute link to that one object, bucket private again, nobody else exposed. They wrote a tiny Lambda that mints presigned URLs on demand for authorized downloads.

The third mistake was uploads. Their web app uploaded student assignments by streaming the file through their own EC2 servers to S3, which fell over whenever twenty students submitted at once. They switched to presigned PUT URLs: the browser uploads directly to S3, the server only issues the short-lived signed URL. Server load dropped to near zero and large uploads stopped timing out. They constrained the presigned PUTs to a 50 MB size and application/pdf content type.

The fourth mistake was a 6 GB course export. A nightly job did a single PutObject of a 6 GB archive and failed every night with EntityTooLarge. They switched the job to aws s3 cp, which auto-multiparts anything over the threshold — but then noticed the bill creeping up. list-multipart-uploads revealed dozens of abandoned multipart uploads from earlier failed runs, all silently billed. A one-line lifecycle rule (AbortIncompleteMultipartUpload after 1 day) plugged the leak, and they aborted the existing orphans.

The fifth mistake was an overwrite. An intern re-ran an export script that overwrote the wrong object and there was no undo — the original was gone. They enabled versioning on the media bucket; a week later when it happened again, restoring was a single copy-object from the noncurrent version. They added a noncurrent-version lifecycle expiry so retained versions did not balloon the bill.

By the end, KloudVin’s S3 setup was boring in the best way: private buckets, presigned URLs for every share and upload, multipart with abort-cleanup for big files, versioning with bounded retention, and a static marketing site fronted by CloudFront over a private bucket. None of it was advanced — it was just the fundamentals, applied on purpose.

Advantages and disadvantages

Advantages Disadvantages / trade-offs
Eleven nines durability, no capacity to manage Not a filesystem — no rename/append/POSIX semantics
Unlimited scale, pay only for what you store Per-request + egress costs add up at scale
Strong read-after-write consistency by default Easy to misconfigure access (public leaks)
Presigned URLs share without exposing the bucket Presigned expiry pitfalls (credential-bound)
Versioning gives undo for overwrites/deletes Retained versions/parts silently cost money
Static hosting in minutes Website endpoint is HTTP-only, needs public read
Multipart makes huge/unreliable uploads robust Abandoned multipart parts leak cost invisibly
Global reach via Transfer Acceleration / CloudFront Region/endpoint mismatches cause 301/400s

The trade-offs cluster around one theme: S3’s flexibility means you own the correctness. Durability is free; getting access, cleanup and expiry right is on you. That is exactly what the hands-on lab builds muscle for.

Hands-on lab

A complete, free-tier-friendly walkthrough: create a bucket (CLI + Terraform), upload with cp/sync, do a real multipart upload, mint and use a presigned GET, enable versioning and restore an overwrite, host a static site, then tear it all down. Set your own globally-unique bucket name — replace kloudvin-lab-<yourname>-2026 everywhere. Region here is ap-south-1; change as needed.

⚠️ Cost note: Everything here is a few cents at most and well within the S3 free tier (5 GB storage, 20,000 GET, 2,000 PUT/month for 12 months). The only things that cost money if forgotten are retained storage, orphaned multipart parts, and object versions — the teardown removes all three.

Step 0 — Prerequisites

aws --version            # expect aws-cli/2.x
aws sts get-caller-identity   # confirm you're NOT the root user
export BUCKET=kloudvin-lab-yourname-2026
export REGION=ap-south-1

Step 1 — Create a bucket (CLI)

# Outside us-east-1 you MUST pass a LocationConstraint
aws s3api create-bucket --bucket "$BUCKET" --region "$REGION" \
  --create-bucket-configuration LocationConstraint="$REGION"
# Expected: { "Location": "http://kloudvin-lab-yourname-2026.s3.amazonaws.com/" }

aws s3api get-bucket-location --bucket "$BUCKET"
# Expected: { "LocationConstraint": "ap-south-1" }

If you get BucketAlreadyExists, the name is taken globally — pick another. In us-east-1 only, omit the --create-bucket-configuration flag entirely.

Step 1 (alternate) — Create the same bucket with Terraform

# main.tf
terraform {
  required_providers { aws = { source = "hashicorp/aws", version = "~> 5.0" } }
}
provider "aws" { region = "ap-south-1" }

resource "aws_s3_bucket" "lab" {
  bucket = "kloudvin-lab-yourname-2026"
}

# Keep it private (defaults are on, but be explicit)
resource "aws_s3_bucket_public_access_block" "lab" {
  bucket                  = aws_s3_bucket.lab.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}
terraform init && terraform apply   # type yes
# Expected: aws_s3_bucket.lab: Creation complete after 2s [id=kloudvin-lab-yourname-2026]

Step 2 — Upload objects with cp and sync

echo "hello s3" > hello.txt
aws s3 cp hello.txt "s3://$BUCKET/notes/hello.txt"
# Expected: upload: ./hello.txt to s3://.../notes/hello.txt

mkdir -p site && echo "<h1>KloudVin</h1>" > site/index.html && echo "not found" > site/error.html
aws s3 sync ./site "s3://$BUCKET/www/"
# Expected: upload: site/index.html to s3://.../www/index.html  (and error.html)

# See the flat keyspace + how the console fakes folders
aws s3api list-objects-v2 --bucket "$BUCKET" --prefix "" --delimiter "/" \
  --query '{prefixes: CommonPrefixes[].Prefix, keys: Contents[].Key}'
# Expected prefixes: ["notes/", "www/"]  — those are prefixes, not folders

Step 3 — Multipart upload of a large file

# Create a ~200 MB file (portable)
head -c 209715200 /dev/urandom > bigfile.bin   # 200 MiB

# Lower the multipart threshold so cp definitely uses multipart, then upload
aws configure set default.s3.multipart_threshold 16MB
aws configure set default.s3.multipart_chunksize 16MB
aws s3 cp bigfile.bin "s3://$BUCKET/uploads/bigfile.bin"
# Expected: a progress bar; cp transparently splits into ~13 parts and completes

# A multipart object's ETag ends in "-N" (part count), unlike a single-PUT MD5
aws s3api head-object --bucket "$BUCKET" --key uploads/bigfile.bin --query 'ETag'
# Expected: "\"d41d8cd...-13\""

# Check for (and there should be none yet) in-progress/abandoned uploads
aws s3api list-multipart-uploads --bucket "$BUCKET"
# Expected: {} (no incomplete uploads because cp completed cleanly)

Now install the cost-leak guard — a lifecycle rule that aborts any multipart upload not completed within 1 day:

cat > abort-mpu.json <<'JSON'
{ "Rules": [ {
  "ID": "abort-incomplete-mpu",
  "Filter": {},
  "Status": "Enabled",
  "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 }
} ] }
JSON
aws s3api put-bucket-lifecycle-configuration --bucket "$BUCKET" \
  --lifecycle-configuration file://abort-mpu.json
# Terraform equivalent
resource "aws_s3_bucket_lifecycle_configuration" "lab" {
  bucket = aws_s3_bucket.lab.id
  rule {
    id     = "abort-incomplete-mpu"
    status = "Enabled"
    filter {}
    abort_incomplete_multipart_upload { days_after_initiation = 1 }
  }
}

Step 4 — Generate and use a presigned GET URL

URL=$(aws s3 presign "s3://$BUCKET/notes/hello.txt" --expires-in 300)
echo "$URL"
# Use it with NO credentials — plain curl, as any recipient would:
curl -s "$URL"
# Expected: hello s3

# Prove expiry works: wait past 300s (or set --expires-in 5) and retry
# Expected after expiry: <Error><Code>AccessDenied</Code>...Request has expired...

Step 5 — Enable versioning, overwrite, and restore

aws s3api put-bucket-versioning --bucket "$BUCKET" \
  --versioning-configuration Status=Enabled
aws s3api get-bucket-versioning --bucket "$BUCKET"   # Expected: { "Status": "Enabled" }

# Version 1 already exists (hello.txt). Overwrite it → version 2, v1 becomes noncurrent
echo "OOPS wrong content" > hello.txt
aws s3 cp hello.txt "s3://$BUCKET/notes/hello.txt"

aws s3api list-object-versions --bucket "$BUCKET" --prefix notes/hello.txt \
  --query 'Versions[].{VersionId:VersionId,IsLatest:IsLatest,Size:Size}'
# Expected: two versions; note the VersionId of the OLDER one (IsLatest=false)

OLD=<paste-old-VersionId>
# Restore by copying the old version back over the key as the new current version
aws s3api copy-object --bucket "$BUCKET" --key notes/hello.txt \
  --copy-source "$BUCKET/notes/hello.txt?versionId=$OLD"
aws s3 cp "s3://$BUCKET/notes/hello.txt" -   # print to stdout
# Expected: hello s3   (original content restored)

Bonus — the delete-marker behaviour:

aws s3api delete-object --bucket "$BUCKET" --key notes/hello.txt   # inserts a delete marker
aws s3 cp "s3://$BUCKET/notes/hello.txt" - 2>&1 || echo "404 as expected (hidden by marker)"
# Find the delete-marker VersionId and remove it to un-delete
aws s3api list-object-versions --bucket "$BUCKET" --prefix notes/hello.txt \
  --query 'DeleteMarkers[].VersionId'
aws s3api delete-object --bucket "$BUCKET" --key notes/hello.txt --version-id <marker-id>
# Object reappears

Step 6 — Host a static website

aws s3api put-bucket-website --bucket "$BUCKET" --website-configuration \
  '{"IndexDocument":{"Suffix":"index.html"},"ErrorDocument":{"Key":"error.html"}}'

# The website endpoint (HTTP only). Note dot vs dash varies by Region.
echo "http://$BUCKET.s3-website.$REGION.amazonaws.com/www/index.html"

To actually serve it you must allow public read (BPA off + public-read policy) or front it with CloudFront + OAC. For the lab, compare the two endpoints and observe the difference — the REST endpoint returns XML/403, the website endpoint would serve index.html once public:

curl -sI "https://$BUCKET.s3.$REGION.amazonaws.com/www/index.html" | head -1   # REST: 200 only if you're authed
curl -sI "http://$BUCKET.s3-website.$REGION.amazonaws.com/"       | head -1     # website: 403 until public

Keeping the bucket private and using CloudFront + OAC is the production path — continue in the CloudFront article.

Step 7 — Teardown (removes every cost source)

# 1) Abort any lingering multipart uploads
aws s3api list-multipart-uploads --bucket "$BUCKET" \
  --query 'Uploads[].{Key:Key,UploadId:UploadId}' --output text | \
  while read KEY UPLOAD_ID; do
    [ -n "$UPLOAD_ID" ] && aws s3api abort-multipart-upload \
      --bucket "$BUCKET" --key "$KEY" --upload-id "$UPLOAD_ID"; done

# 2) Delete ALL object versions and delete markers (required for a versioned bucket)
aws s3api delete-objects --bucket "$BUCKET" --delete "$(aws s3api list-object-versions \
  --bucket "$BUCKET" --output json --query \
  '{Objects: Versions[].{Key:Key,VersionId:VersionId}}')" 2>/dev/null
aws s3api delete-objects --bucket "$BUCKET" --delete "$(aws s3api list-object-versions \
  --bucket "$BUCKET" --output json --query \
  '{Objects: DeleteMarkers[].{Key:Key,VersionId:VersionId}}')" 2>/dev/null

# 3) Remove website config and delete the (now empty) bucket
aws s3api delete-bucket-website --bucket "$BUCKET"
aws s3api delete-bucket --bucket "$BUCKET"
# Expected: no output = success

# Terraform users: `terraform destroy` (add force_destroy=true to the bucket to
# let TF delete a non-empty/versioned bucket in one shot).
rm -f hello.txt bigfile.bin abort-mpu.json; rm -rf site

If delete-bucket returns BucketNotEmpty, you still have versions, delete markers, or in-progress multipart parts — re-run steps 1 and 2.

Common mistakes & troubleshooting

This is the section you will come back to. First the playbook — symptom → root cause → how to confirm → fix — then an S3 error/status-code reference, then a decision table.

# Symptom Root cause Confirm (exact command) Fix
1 BucketAlreadyExists (409) on create Name is taken globally, not just in your account Try a different name; names are partition-unique Add org/purpose/unique suffix: kloudvin-media-prod-ap-south-1
2 IllegalLocationConstraintException / wrong-Region create Missing/incorrect LocationConstraint outside us-east-1 Note your --region vs constraint Pass --create-bucket-configuration LocationConstraint=<region> (omit only in us-east-1)
3 AccessDenied (403) on upload IAM identity policy, bucket policy, or Block Public Access denies aws sts get-caller-identity; aws s3api get-bucket-policy; get-public-access-block Grant s3:PutObject; fix the bucket policy; see the security sibling
4 Presigned URL 403 AccessDenied “Request has expired” Expiry elapsed, or credential lifetime shorter than --expires-in Read X-Amz-Expires/X-Amz-Date in the URL Re-issue; sign long-lived links with an IAM user key (7-day cap)
5 Presigned URL 403 SignatureDoesNotMatch HTTP method mismatch, edited URL, or client clock skew Compare method used vs signed; check clock Use the exact method it was signed for; sync system time (NTP)
6 Multipart upload “never completes”; bill creeps up Abandoned parts retained and billed, invisible to ls aws s3api list-multipart-uploads --bucket B Abort them; add AbortIncompleteMultipartUpload lifecycle rule
7 EntityTooLarge (400) on upload Single PUT > 5 GB (or a part > 5 GB) Check file size vs 5 GB Use aws s3 cp (auto-multipart) or s3api multipart
8 sync re-uploads everything each run S3 has no mtime; sync compares size + last-modified, and they changed aws s3 sync --dryrun ... shows what it would send Accept size+time semantics; use --size-only where safe
9 Static site returns XML, not index.html You hit the REST endpoint, not the website endpoint curl -I both endpoints Use bucket.s3-website[.-]REGION.amazonaws.com; enable website config
10 Static site 403 on the website endpoint Block Public Access on / no public-read policy get-public-access-block; get-bucket-policy Relax BPA + add public-read policy, or front with CloudFront + OAC
11 301 / AuthorizationHeaderMalformed (400) Talking to the wrong Region’s endpoint aws s3api get-bucket-location; read the “expecting region” hint Target the bucket’s own regional endpoint / set --region
12 RequestTimeTooSkewed (403) Client clock differs from S3 by > 15 min date -u; compare to real UTC Fix system clock / enable NTP
13 Object “deleted” but still billed Versioning on: delete inserted a delete marker, versions remain aws s3api list-object-versions --bucket B Delete specific versions by id; expire noncurrent versions via lifecycle
14 NoSuchKey (404) though “the folder exists” Folders are prefixes; the exact key is different (trailing slash, case) aws s3api list-objects-v2 --prefix ... Use the exact key string; keys are case-sensitive
15 Can’t delete bucket: BucketNotEmpty (409) Objects, versions, delete markers, or MPU parts remain list-object-versions; list-multipart-uploads Delete all versions + markers + abort MPUs first (or force_destroy)
16 Intermittent 503 SlowDown Request rate exceeds per-prefix scaling on a hot prefix Watch 5xx in CloudWatch; check key layout Spread across more prefixes; add retries with backoff; front with CloudFront

S3 error / status-code reference

HTTP S3 error code Meaning Likely cause Fix
200 OK Success
204 No Content Successful DELETE
206 Partial Content Byte-range GET succeeded
301 PermanentRedirect Wrong endpoint for the bucket’s Region Region mismatch Use the bucket’s regional endpoint
307 TemporaryRedirect New bucket still propagating Just-created bucket Retry after a moment / use regional endpoint
400 AuthorizationHeaderMalformed Region in request ≠ bucket Region Wrong --region Set the correct Region
400 EntityTooLarge Object/part exceeds limit Single PUT > 5 GB Use multipart
400 InvalidBucketName Name breaks DNS rules Uppercase/dots/length Rename per the rules
400 MalformedXML Bad config document Lifecycle/website JSON/XML wrong Fix the document
403 AccessDenied Not authorized IAM / bucket policy / BPA Grant permission; adjust policy
403 SignatureDoesNotMatch Signature invalid Method mismatch / edited URL / clock Re-sign; sync clock
403 RequestTimeTooSkewed Clock skew > 15 min Wrong system time NTP
403 InvalidObjectState Object archived (Glacier) GET on Glacier without restore Restore first (storage-classes article)
404 NoSuchBucket Bucket doesn’t exist Wrong name/Region Check name and Region
404 NoSuchKey Key doesn’t exist Typo/trailing slash/case Use the exact key
405 MethodNotAllowed Operation not allowed here Wrong endpoint/verb Correct method/endpoint
409 BucketAlreadyExists Name taken globally Not unique Pick a unique name
409 BucketAlreadyOwnedByYou You already own it Re-create in same account No-op / continue
409 BucketNotEmpty Bucket has content Objects/versions/MPUs remain Empty fully first
412 PreconditionFailed Conditional header failed If-Match/If-None-Match Re-read ETag
416 InvalidRange Range not satisfiable Range past EOF Request a valid range
500 InternalError Transient server error AWS-side Retry with backoff
503 SlowDown Throttling Hot-prefix request rate Spread prefixes; backoff

Decision table — “if you see…”

If you see… It’s probably… Do this
403 only for the recipient, works for you Presigned expiry or method mismatch Re-issue; check X-Amz-Expires, method
403 on your own upload IAM / bucket policy / BPA Check identity + policy + public-access-block
XML where your homepage should be REST endpoint, not website endpoint Switch to s3-website endpoint
Storage cost with no visible objects Orphaned MPU parts or noncurrent versions list-multipart-uploads; list-object-versions
301 / AuthorizationHeaderMalformed Region/endpoint mismatch get-bucket-location; fix --region
Object “gone” but bill unchanged Delete marker on a versioned bucket Delete the marker or the versions

The three nastiest in practice deserve prose. Orphaned multipart parts are the classic silent bill: a CI job that uploads big artifacts and crashes leaves parts that never appear in aws s3 ls, accrue storage cost forever, and are only visible via list-multipart-uploads — always ship the abort-incomplete lifecycle rule with any bucket that takes large uploads. The presigned-expiry-vs-credential trap bites teams who generate “7-day” links from code running under an IAM role (EC2, Lambda, ECS): the URL’s X-Amz-Expires may say 7 days, but the underlying temporary credentials expire in an hour, so the link dies early — sign long-lived links with a dedicated, tightly-scoped IAM user key. The versioned-delete surprise catches people who think deleting solved their cost problem: on a versioned bucket a plain delete only hides the object behind a marker while every version keeps billing, so a “cleanup” that used rm frees nothing — you must delete versions by id or expire noncurrent versions with lifecycle.

Best practices

Security notes

S3 security is a deep topic (its own article), but the fundamentals belong here because insecure defaults are how beginners leak data.

Control What it does Beginner default
Block Public Access (4 switches) Overrides ACLs/policies that would make data public Keep all ON unless hosting a public site deliberately
Bucket policy Resource-based allow/deny at the bucket Least privilege; no Principal: "*" unless intended
IAM identity policy What a user/role may do to S3 Grant specific actions/resources, not s3:*
Default encryption Encrypts objects at rest SSE-S3 on by default; SSE-KMS for key control
Presigned URLs Time-limited, single-object access Short expiry; scope by method; don’t over-share
MFA-delete Requires MFA to delete versions For compliance-critical buckets
Object Ownership / ACLs disabled Turns off legacy ACLs, policy-only access Keep ACLs disabled (the modern default)
CloudTrail data events + Access Logs Audit who did what Enable on sensitive buckets

The two rules that prevent most incidents: (1) keep buckets private and share via presigned URLs or CloudFront + OAC, never by flipping off Block Public Access to “just get it working”; and (2) grant IAM and bucket policies the specific actions and resources needed, never s3:* on arn:aws:s3:::*. Access-denied troubleshooting — reading the IAM decision, the bucket policy, and BPA together — is covered in S3 Bucket Policies and Block Public Access.

Cost & sizing

S3 has no capacity to provision — you pay for what you use across a few dimensions. The mistake is assuming it is only per-GB storage; requests and egress often dominate small-object, high-traffic workloads.

Cost driver Billed on Notes
Storage Per GB-month, by storage class Standard is the baseline; colder classes cheaper to store
PUT/COPY/POST/LIST requests Per 1,000 requests Pricier tier than GET
GET/SELECT requests Per 10,000 requests Cheaper per request than PUT
Data transfer OUT to internet Per GB egress Biggest surprise; CloudFront often cheaper
Data transfer IN Free Uploads cost nothing to transfer
Same-Region S3↔EC2 Free Keep compute and bucket in one Region
Cross-Region transfer Per GB Replication and cross-Region reads cost
Management features Per unit Inventory, analytics, replication, Object Lambda
Transfer Acceleration Per GB premium Only when actually faster
Orphaned MPU parts / old versions Storage per GB The invisible line item — clean up

The free tier (first 12 months) covers 5 GB Standard storage, 20,000 GET, 2,000 PUT, and 100 GB egress/month — more than enough for this lab and for learning. Rough order-of-magnitude for a small production bucket in ap-south-1: storage is a few US cents (a few INR) per GB-month; a million GETs is well under a dollar; the number that surprises people is internet egress, which is why static sites and media belong behind CloudFront, not served straight from S3.

Right-sizing for S3 is less about “size” and more about hygiene: put cold data on cheaper classes with a lifecycle rule, expire noncurrent versions and abort incomplete uploads, and move public/high-traffic delivery to CloudFront. Do those three and an S3 bill essentially manages itself.

Interview & exam questions

1. Why must an S3 bucket name be globally unique? Because S3 addresses buckets by DNS name (bucket.s3.region.amazonaws.com), and DNS is a global namespace. Names are unique across all AWS accounts within a partition. (CLF-C02, SAA-C03)

2. Are there folders in S3? No. The keyspace is flat; keys are strings that may contain /. The console fabricates a folder view by grouping keys on the / delimiter into common prefixes. “Empty folders” are zero-byte placeholder objects. (CLF-C02, DVA-C02)

3. What consistency model does S3 provide today? Strong read-after-write consistency for PUTs of new objects, overwrite PUTs, DELETEs, and LIST operations, in all Regions, at no extra cost (since Dec 2020). No need to code around eventual consistency. (SAA-C03, DVA-C02)

4. When must you use multipart upload? Any object over 5 GB (the single-PUT limit) requires it, and AWS recommends it above ~100 MB for reliability and parallelism. Parts are 5 MB–5 GB, up to 10,000 parts, 5 TB total. (DVA-C02, SAA-C03)

5. What is a presigned URL and when do you use it? A time-limited, signed URL granting credential-free access to one object for one method. Use it to share a private object or to let a browser upload directly to S3 without exposing the bucket or your keys. (DVA-C02)

6. What limits a presigned URL’s maximum lifetime? The signing credentials. IAM user long-term keys allow up to 7 days; temporary/STS credentials cap the URL at the credentials’ remaining lifetime (often ≤ 1 hour). (DVA-C02)

7. What happens when you delete an object in a versioning-enabled bucket without specifying a version? S3 inserts a delete marker that becomes the current version; the object appears deleted (GET → 404) but every version is retained and still billed. Delete the marker to restore. (SAA-C03, DVA-C02)

8. How do you permanently delete data in a versioned bucket? Issue DELETE with an explicit versionId for each version (and remove delete markers). Plain deletes only add markers. Lifecycle noncurrent-version expiration automates it. (SAA-C03)

9. Website endpoint vs REST endpoint — what’s the difference? The website endpoint (s3-website) serves index/error documents over HTTP only and needs public read; the REST endpoint (s3.region) is HTTPS + SigV4 and returns XML, not index.html. Hitting the wrong one is a classic 403/XML bug. (SAA-C03)

10. Why prefer CloudFront + OAC over the S3 website endpoint for a real site? HTTPS/TLS, caching at 600+ edge locations, WAF/geo-restriction, cheaper egress, and the bucket stays private (OAC signs origin requests) instead of being public. (SAA-C03)

11. What is the per-prefix request-rate scaling for S3? At least 3,500 PUT/COPY/POST/DELETE and 5,500 GET/HEAD requests per second per prefix, with no limit on the number of prefixes; spread keys across prefixes to scale throughput. (SAA-C03)

12. How do abandoned multipart uploads cost you money, and how do you stop it? Interrupted uploads leave parts that are billed but invisible to ls. Detect with list-multipart-uploads; prevent with an AbortIncompleteMultipartUpload lifecycle rule. (DVA-C02, SOA-C02)

Quick check

  1. Your create-bucket fails with BucketAlreadyExists even though you have no such bucket. Why?
  2. You “deleted” an object on a versioned bucket but storage cost did not drop. What happened, and how do you actually free the space?
  3. A teammate’s presigned URL works for you but 403s for them ten minutes later. Name two likely causes.
  4. You upload a 6 GB file with a single PutObject and get EntityTooLarge. What is the fix?
  5. Your static site returns XML instead of index.html. What did you do wrong?

Answers

  1. Bucket names are globally unique across all AWS accounts in the partition — someone else already owns that name. Choose a more specific, unique name.
  2. A delete on a versioned bucket without a version id inserts a delete marker; all versions are retained and still billed. Delete the specific versions by id (or set a noncurrent-version expiration lifecycle rule).
  3. Expiry elapsed (X-Amz-Expires passed, or the signer’s temporary credentials expired sooner than the stated window), or method/URL mismatch / clock skew causing SignatureDoesNotMatch.
  4. Use multipart upload — either aws s3 cp (which auto-multiparts) or the s3api multipart flow. Single PUT tops out at 5 GB.
  5. You hit the REST endpoint (bucket.s3.region.amazonaws.com), which returns XML, instead of the website endpoint (bucket.s3-website[.-]region.amazonaws.com) — and you need website hosting enabled with public read (or CloudFront + OAC).

Glossary

Term Definition
Bucket A globally-unique, Region-bound container for objects; the unit of ownership, policy and billing.
Object An immutable blob of 0 B–5 TB, addressed by a key, carrying metadata (and a version id if versioned).
Key The object’s full UTF-8 name (≤ 1,024 bytes); a flat string where / is a literal character.
Prefix The leading portion of a key; the basis for the console’s “folders” and for request-rate scaling.
Delimiter A character (usually /) passed to LIST to group keys into common prefixes.
Flat keyspace S3’s namespace model — no real directories, just keys mapping to objects.
Metadata System (Content-Type, ETag) and user (x-amz-meta-*, ≤ 2 KB) key/value data on an object.
Multipart upload Chunked, parallel upload of a large object (parts 5 MB–5 GB, ≤ 10,000 parts, ≤ 5 TB).
Presigned URL A time-limited, signed URL granting credential-free access to one object for one HTTP method.
Byte-range GET A GET for a subset of an object’s bytes, returning HTTP 206 Partial Content.
Versioning Bucket setting that retains every version of a key; overwrites demote old versions to noncurrent.
Delete marker A placeholder inserted by a non-versioned delete on a versioned bucket; hides all versions reversibly.
Version ID The unique identifier of one version of a key.
Strong read-after-write consistency S3’s guarantee that a read after a write returns the latest data, in all Regions.
Static website hosting Serving HTML/CSS/JS directly from a bucket via the website endpoint (HTTP, index/error docs).
Website endpoint bucket.s3-website[.-]region.amazonaws.com — serves index/error docs over HTTP; needs public read.
REST endpoint bucket.s3.region.amazonaws.com — HTTPS + SigV4 API endpoint; returns XML, not index.html.
Requester Pays Bucket mode where the requester (not the owner) pays for requests and egress.
Transfer Acceleration Uploads/downloads routed over the CloudFront edge network for long-distance speed.
ETag An identifier of object content — MD5 for single PUTs, a composite -N value for multipart objects.

Next steps

AWSS3BucketsObjectsVersioningPresigned URLsMultipart UploadStatic Website Hosting
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading