S3 Object Storage

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Commands and behaviour below are current for AWS CLI v2 (2.37 line), the AWS SDK for Java 2.x and Spring Cloud AWS 4.x (Spring Boot 4) or 3.4.x (Spring Boot 3.5), checked 2026-10-01. S3 gained a lot of features in 2025 and 2026 (S3 Tables, S3 Vectors, the account regional namespace, conditional deletes, S3 Files), so confirm flags with aws s3api help and the linked reference pages before relying on them in automation. Region us-east-1 is a placeholder throughout.

Amazon Simple Storage Service (S3) is the place where Bookshelf keeps every file that is not a database row: the static web front end in bookshelf-web-<account-id> and the book covers and PDF uploads in bookshelf-uploads-<account-id>. This page is the data-plane and storage-management half of S3: what a bucket and an object are, how to upload and read them from the console, the CLI and Java, which storage class and lifecycle rule keeps the bill small, and how versioning and replication protect the data. Access control (Block Public Access, bucket policies, encryption, Object Lock, CORS) and the static site served through CloudFront are in S3 Security and Static Hosting; the CDN itself is in CloudFront and CDN Delivery. Block and file storage (EBS, EFS) is in Block and File Storage on AWS, and the Azure counterpart of this page is Blob Storage. The generic Spring Boot view of object stores, including a comparison of the three clouds, is in File Storage & Object Stores.

Four columns comparing the S3 bucket types: general purpose, directory (Express One Zone), table (S3 Tables) and vector (S3 Vectors), with their contents, API namespace and typical use

Buckets, objects and keys

S3 is an object store, not a file system. You create a bucket (a named container in one Region) and put objects into it. An object is data plus metadata, addressed by its key. The key covers/9780134685991.jpg is one flat string; the slash is only a convention. S3 has no real folders: the console shows covers/ as a folder because it groups keys by prefix and delimiter, and an empty "folder" is just a zero-byte object whose key ends in a slash.

Since December 2020 S3 is strongly consistent for all operations in every Region: after a successful PUT, DELETE or overwrite, the next GET or LIST sees the change. There is no eventual-consistency window to code around, which older guides still describe. Concurrent writers to the same key are still last-writer-wins unless you use versioning or conditional writes (below).

Object metadata comes in three kinds: system metadata such as Content-Type, Content-Length, ETag and storage class; user-defined metadata (x-amz-meta- headers, up to 2 KB); and *tags (up to 10 key-value pairs per object) that drive lifecycle filters, cost allocation and access control. Metadata is immutable once written; changing it means copying the object onto itself.

Bucket types

Type What it is When to use it

General purpose

The classic bucket. Objects in any storage class except Express One Zone, stored across at least three Availability Zones (except One Zone-IA). All the features in this page apply.

Almost everything, including both Bookshelf buckets.

Directory

Bucket for the S3 Express One Zone class: data in one Availability Zone you choose (or a Local Zone), single-digit millisecond access, session-based authentication, hierarchical namespace. Created with aws s3api create-bucket and a directory-bucket name ending in --x-s3.

Latency-sensitive workloads co-located with compute, such as ML training or interactive analytics. Not for Bookshelf.

Table

Bucket for S3 Tables: tables in Apache Iceberg format with automatic compaction and snapshot management, queried by Athena, Redshift and Spark. Uses the s3tables API namespace (aws s3tables); Block Public Access is always on.

Analytics tables that would otherwise be self-managed Iceberg files in a general purpose bucket.

Vector

Bucket for S3 Vectors: vector indexes holding embeddings with metadata and a similarity query API. Uses the s3vectors namespace (aws s3vectors); Block Public Access is always on. Generally available since December 2025.

Cost-sensitive RAG and semantic search with infrequent queries. See Comparing Vector Databases for how it compares with OpenSearch, Aurora pgvector and the other AWS options.

Two newer S3 features are worth a mention without a walkthrough. S3 Metadata keeps a queryable table of your objects' system and custom metadata that updates near real time, so you can find objects with SQL instead of listing a bucket. S3 Files (2026) presents a bucket as a file system to compute that expects POSIX semantics; it is introduced in Block and File Storage on AWS, and its details are outside the scope of this page.

Naming and the account regional namespace

By default general purpose bucket names share one global namespace per partition: a name must be 3 to 63 characters of lowercase letters, digits, hyphens and periods, and unique across every account and Region. Whoever creates a name first owns it, and after you delete a bucket anyone can claim the name, which can send stray requests intended for your deleted bucket to a stranger. That is why guides suggest appending the account ID, as in bookshelf-uploads-111122223333.

In 2026 S3 added an opt-in account regional namespace: names of the form <prefix>-<account-id>-<region>-an (for example bookshelf-uploads-111122223333-us-east-1-an) can only ever be created by your account, can never be taken over by another account after deletion, and support every S3 feature. You request it with the x-amz-bucket-namespace: account-regional header (--bucket-namespace account-regional in the CLI); the console adds the suffix for you. The suffix counts towards the 63-character limit, and the namespace is available in all Regions except Middle East (Bahrain) and Middle East (UAE) at the time of writing. A Service Control Policy or IAM policy can require it with the s3:x-amz-bucket-namespace condition key. The rest of this page uses a shell variable BUCKET so either naming style works.

Size limits

  • A single object can be up to 50 TB (raised from 5 TB in 2025).

  • A single PutObject (or console upload) can carry at most 5 GB; larger objects must use multipart upload.

  • Multipart upload splits an object into up to 10,000 parts; each part is 5 MiB to 5 GiB except the last, which can be smaller.

  • There is no limit on the number of objects per bucket. The default quota is 10,000 general purpose buckets per account (a quota increase raises it); check Service Quotas for the current number.

Creating a bucket

In the AWS Management Console

  1. Choose the Region in the navigation bar (here us-east-1) and open S3.

  2. Under General purpose buckets, choose Create bucket.

  3. Enter the name. Choose Account Regional namespace (recommended) and enter only the prefix, or choose Global namespace and enter the whole unique name.

  4. Leave ACLs disabled (recommended) (Bucket owner enforced) and keep Block all public access ticked. Both are the defaults, and Bookshelf buckets stay private.

  5. Under Bucket Versioning choose Enable for the uploads bucket. Leave default encryption on SSE-S3 for now; the alternatives are in the security page.

  6. Choose Create bucket. To work from a terminal without installing anything, open AWS CloudShell from the toolbar: it already has the AWS CLI configured for your signed-in identity and Region.

With the AWS CLI

There are two command groups. aws s3 is a high-level, file-system-flavoured tool (mb, cp, sync, ls, rm, presign) that handles parallelism and multipart uploads for you. aws s3api maps one-to-one to S3 API operations and exposes every option. Use s3 for moving files, s3api for configuration.

export AWS_REGION=us-east-1
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
export BUCKET=bookshelf-uploads-$ACCOUNT_ID-$AWS_REGION-an

# Account regional namespace bucket. In us-east-1 no LocationConstraint is allowed;
# in any other Region add --create-bucket-configuration LocationConstraint=$AWS_REGION
aws s3api create-bucket \
  --bucket $BUCKET \
  --bucket-namespace account-regional \
  --region $AWS_REGION

# Versioning for the uploads bucket
aws s3api put-bucket-versioning \
  --bucket $BUCKET \
  --versioning-configuration Status=Enabled

# Confirm Block Public Access and default encryption (both on by default for new buckets)
aws s3api get-public-access-block --bucket $BUCKET
aws s3api get-bucket-encryption --bucket $BUCKET

For a global-namespace bucket drop --bucket-namespace and pick a unique name such as bookshelf-uploads-$ACCOUNT_ID. Older tutorials create buckets with a public ACL or a public bucket policy; since April 2023 new buckets have Block Public Access on and ACLs disabled, so those recipes now fail with AccessDenied (see the security page for the right pattern). Since January 2023 every new object is also encrypted at rest with SSE-S3 by default, and since April 2026 SSE-C is blocked by default on new buckets.

Uploading and managing objects

In the AWS Management Console

  1. Open the bucket and choose Upload.

  2. Choose Add files or Add folder (or drag them in). Expand Properties to pick a Storage class, set default checksum and encryption, and add tags.

  3. Choose Upload. The console handles files up to 160 GB; use the CLI, an SDK or Transfer Family beyond that.

  4. To work with an object, select it and use Open, Download, Copy S3 URI, or the Actions menu (copy, move, rename, change storage class, restore, edit metadata or tags, share with a presigned URL). Object actions → Share with a presigned URL asks for a duration (the console limits it to 12 hours).

  5. With versioning on, switch on Show versions in the object list to see every version and delete marker.

With the AWS CLI

# High-level: copy, sync, list, remove (multipart and parallelism handled for you)
aws s3 cp cover.jpg s3://$BUCKET/covers/9780134685991.jpg \
  --storage-class INTELLIGENT_TIERING \
  --metadata author=Bloch,source=import

aws s3 sync ./covers s3://$BUCKET/covers/ --exclude "*.tmp"
aws s3 ls s3://$BUCKET/covers/ --recursive --human-readable --summarize
aws s3 rm s3://$BUCKET/covers/old/ --recursive

# Low-level: one API call each, full control of headers and responses
aws s3api put-object \
  --bucket $BUCKET --key covers/9780134685991.jpg \
  --body cover.jpg --content-type image/jpeg \
  --tagging 'project=bookshelf&kind=cover'

aws s3api head-object --bucket $BUCKET --key covers/9780134685991.jpg
aws s3api list-objects-v2 --bucket $BUCKET --prefix covers/ \
  --query 'Contents[].[Key,Size,StorageClass]' --output table
aws s3api get-object --bucket $BUCKET --key covers/9780134685991.jpg out.jpg

Prefer list-objects-v2 over the older list-objects. aws s3 sync copies only new or changed files (size and modified time) and, with --delete, removes destination objects that no longer exist in the source, so run it with --dryrun first. The CLI computes a checksum for uploads by default (CRC64NVME for SDK and CLI clients since 2025), and you can ask S3 to return it with --checksum-mode ENABLED on get-object or head-object to verify integrity.

Conditional writes and deletes

S3 can refuse a write that would overwrite data someone else just wrote. If-None-Match: * makes PutObject, CopyObject or CompleteMultipartUpload fail with 412 Precondition Failed if the key already exists, which gives you "create only" semantics. If-Match: <etag> makes the write succeed only if the current object still has that ETag (optimistic concurrency, a compare-and-swap). If a concurrent delete wins the race you may see 409 Conflict or, with If-Match, 404 Not Found, and you retry from the start. Requests must be signed with Signature Version 4, and a bucket policy can require the headers. S3 also supports an If-Match condition on deletes, so a delete removes the object only if it has not changed; see the API reference for DeleteObject.

# Create-only: fails with PreconditionFailed if the key exists
aws s3api put-object \
  --bucket $BUCKET --key manifests/2026-10-01.json \
  --body manifest.json --if-none-match "*"

# Compare-and-swap with the ETag from an earlier head-object
ETAG=$(aws s3api head-object --bucket $BUCKET --key manifests/2026-10-01.json \
  --query ETag --output text)
aws s3api put-object \
  --bucket $BUCKET --key manifests/2026-10-01.json \
  --body manifest-v2.json --if-match "$ETAG"

Multipart upload and Transfer Acceleration

Multipart upload splits one object into parts uploaded in parallel and retried independently. AWS recommends it for objects of 100 MB or more, and it is mandatory above 5 GB. aws s3 cp and sync switch to multipart automatically above a threshold (8 MB by default) and you tune them with aws configure set default.s3.multipart_threshold 64MB, multipart_chunksize and max_concurrent_requests. The SDK transfer managers do the same.

The important operational rule: parts of an upload that was never completed or aborted stay in the bucket and are billed as storage in the class they were uploaded to. Always add an AbortIncompleteMultipartUpload lifecycle rule (below), and list stray uploads with:

aws s3api list-multipart-uploads --bucket $BUCKET
aws s3api abort-multipart-upload --bucket $BUCKET --key backups/big.tar --upload-id "$UPLOAD_ID"

Transfer Acceleration routes uploads and downloads through the nearest CloudFront edge location and over the AWS backbone to the bucket, which helps when clients are far from the bucket’s Region. Enable it per bucket and use the <bucket>.s3-accelerate.amazonaws.com endpoint; it is billed per GB on top of normal transfer and works only for buckets whose names have no periods.

aws s3api put-bucket-accelerate-configuration \
  --bucket $BUCKET --accelerate-configuration Status=Enabled
aws configure set default.s3.use_accelerate_endpoint true

Storage classes

Every object has a storage class that sets its price per GB, its availability and its minimum duration. You pick the class at upload time (--storage-class) or move objects later with a lifecycle rule or a copy.

Class (API value) Meant for Zones Minimum duration Retrieval

Standard (STANDARD)

Frequently accessed data; the default.

3 or more

none

none

Intelligent-Tiering (INTELLIGENT_TIERING)

Unknown or changing access patterns; moves objects between tiers automatically.

3 or more

none

none (monitoring fee per object)

Standard-IA (STANDARD_IA)

Long-lived data read about once a month, still millisecond access.

3 or more

30 days

per-GB fee

One Zone-IA (ONEZONE_IA)

Re-creatable infrequently accessed data and replicas; lost if the Availability Zone is destroyed.

1

30 days

per-GB fee

Express One Zone (EXPRESS_ONEZONE)

Latency-sensitive data in directory buckets.

1

none

none

Glacier Instant Retrieval (GLACIER_IR)

Archive read about once a quarter with millisecond access.

3 or more

90 days

per-GB fee

Glacier Flexible Retrieval (GLACIER)

Archive read about once a year; minutes to hours to restore.

3 or more

90 days

restore required

Glacier Deep Archive (DEEP_ARCHIVE)

Long-term archive read rarely; hours to restore.

3 or more

180 days

restore required

Standard-IA, One Zone-IA and Glacier Instant Retrieval bill a minimum of 128 KB per object, so they are a poor fit for small objects such as thumbnails. Glacier Flexible Retrieval and Deep Archive add about 40 KB of metadata per object (32 KB at the archive rate and 8 KB at the Standard rate). REDUCED_REDUNDANCY still exists in the API but AWS recommends against it; Standard is cheaper. Availability design targets and the full comparison are in the storage class overview.

Intelligent-Tiering

Intelligent-Tiering is the right default when you cannot predict access. Objects start in the Frequent Access tier, move to Infrequent Access after 30 days without access and to Archive Instant Access after 90 days, all with millisecond latency and no retrieval fees. A GET moves an object back to Frequent Access. Two optional tiers save more for data you can wait for: Archive Access (from 90 days, up to 730) and Deep Archive Access (from 180 days, up to 730). Objects in those two tiers must be restored before reading, so enable them only for workloads that can tolerate that. Objects under 128 KB are never monitored or tiered and are always billed at the Frequent Access rate. You pay a small monthly monitoring and automation fee per object instead of retrieval fees.

# Make Intelligent-Tiering the class for new uploads of covers
aws s3 cp cover.jpg s3://$BUCKET/covers/9780134685991.jpg --storage-class INTELLIGENT_TIERING

# Opt in to the two archive tiers for the "exports/" prefix
cat > tiering.json <<'JSON'
{
  "Id": "archive-exports",
  "Status": "Enabled",
  "Filter": { "Prefix": "exports/" },
  "Tierings": [
    { "Days": 90,  "AccessTier": "ARCHIVE_ACCESS" },
    { "Days": 180, "AccessTier": "DEEP_ARCHIVE_ACCESS" }
  ]
}
JSON
aws s3api put-bucket-intelligent-tiering-configuration \
  --bucket $BUCKET --id archive-exports \
  --intelligent-tiering-configuration file://tiering.json

Restoring archived objects

An object in Glacier Flexible Retrieval, Deep Archive, or an Intelligent-Tiering archive tier returns InvalidObjectState on GET until you restore a temporary copy. The restore tier trades speed for price (Expedited, Standard or Bulk for Flexible Retrieval; Standard or Bulk for Deep Archive).

aws s3api restore-object \
  --bucket $BUCKET --key exports/2024-orders.csv.gz \
  --restore-request '{"Days": 7, "GlacierJobParameters": {"Tier": "Standard"}}'

# Restore="ongoing-request=\"false\"" in the response means the copy is ready
aws s3api head-object --bucket $BUCKET --key exports/2024-orders.csv.gz --query Restore

Derived from aws s3api restore-object.

The standalone Amazon Glacier vault service (the one with vaults and archive IDs that older books describe) closed to new customers on 2025-12-15; existing vault data stays accessible (see the AWS lifecycle changes page). For new work use the S3 Glacier storage classes above, which keep the objects visible in your bucket.

Lifecycle rules

A lifecycle configuration is a list of rules, each with a filter (prefix, tags, object size) and actions: transition to a cheaper class after N days, expire (delete) after N days, expire noncurrent versions, remove expired delete markers, and abort incomplete multipart uploads. S3 evaluates them once a day, so an action happens within a day or so of the due date. Transitions run only "downhill" (Standard to Standard-IA after at least 30 days, and so on), and by default objects smaller than 128 KB are not transitioned to the IA and Glacier Instant classes.

flowchart LR U["Upload"] --> S["Standard (day 0)"] S -->|"30 days"| IA["Standard-IA"] IA -->|"90 days"| GIR["Glacier Instant Retrieval"] GIR -->|"180 days"| GFR["Glacier Flexible Retrieval"] GFR -->|"365 days"| DA["Glacier Deep Archive"] DA -->|"2555 days"| X["Expire"] S -.->|"delete before 30 days"| Early["Billed for 30 days minimum"] IA -.->|"restore or GET"| S I["Intelligent-Tiering"] -->|"30 days no access"| IIA["Infrequent tier"] IIA -->|"90 days no access"| AIA["Archive Instant tier"] AIA -->|"GET"| I

The rule set below is what Bookshelf applies to the uploads bucket: temporary uploads expire after a week, uploads move to Standard-IA after 30 days and Glacier Instant Retrieval after 90, old versions are removed after 90 days, and failed multipart uploads are cleaned up after 7 days.

cat > lifecycle.json <<'JSON'
{
  "Rules": [
    {
      "ID": "abort-incomplete-multipart",
      "Status": "Enabled",
      "Filter": { "Prefix": "" },
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
    },
    {
      "ID": "expire-tmp",
      "Status": "Enabled",
      "Filter": { "Prefix": "tmp/" },
      "Expiration": { "Days": 7 }
    },
    {
      "ID": "age-uploads",
      "Status": "Enabled",
      "Filter": { "Prefix": "uploads/" },
      "Transitions": [
        { "Days": 30, "StorageClass": "STANDARD_IA" },
        { "Days": 90, "StorageClass": "GLACIER_IR" }
      ]
    },
    {
      "ID": "trim-old-versions",
      "Status": "Enabled",
      "Filter": { "Prefix": "" },
      "NoncurrentVersionExpiration": { "NoncurrentDays": 90, "NewerNoncurrentVersions": 3 },
      "Expiration": { "ExpiredObjectDeleteMarker": true }
    }
  ]
}
JSON
aws s3api put-bucket-lifecycle-configuration \
  --bucket $BUCKET --lifecycle-configuration file://lifecycle.json
aws s3api get-bucket-lifecycle-configuration --bucket $BUCKET

In the console, open the bucket, choose Management → Lifecycle rules → Create lifecycle rule, scope it by prefix or tag, tick the actions (Transition current versions, Expire current versions, Permanently delete noncurrent versions, Delete expired object delete markers or incomplete multipart uploads) and enter the days; the console warns about costs when a rule moves many small objects. A lifecycle transition is a billed request, and objects deleted or transitioned before a class’s minimum duration are still charged for the full minimum. Do not stack a Standard-IA transition on objects that will be deleted within 30 days.

Where the choice is not obvious, S3 Storage Class Analysis observes access patterns for a prefix and suggests when to transition from Standard to Standard-IA, and Intelligent-Tiering removes the guesswork altogether at the cost of the monitoring fee.

Versioning and MFA delete

With versioning on, every PUT creates a new version with its own version ID, and a DELETE without a version ID only adds a delete marker; the previous versions stay and are billed. That makes accidental overwrites and deletions recoverable and is a prerequisite for replication and Object Lock. Versioning can be suspended but never turned fully off. A bucket is in one of three states: unversioned, enabled or suspended.

aws s3api list-object-versions --bucket $BUCKET --prefix covers/9780134685991.jpg \
  --query '[Versions[].[VersionId,IsLatest,LastModified], DeleteMarkers[].[VersionId,IsLatest]]'

# Restore a deleted object: remove its delete marker
aws s3api delete-object --bucket $BUCKET --key covers/9780134685991.jpg --version-id "$DELETE_MARKER_ID"

# Read an old version
aws s3api get-object --bucket $BUCKET --key covers/9780134685991.jpg --version-id "$VERSION_ID" old.jpg

MFA delete additionally requires a one-time code from the bucket owner’s root-account MFA device to change the versioning state or permanently delete a version. It can only be enabled by the root user with the CLI or API, which is why most teams rely on IAM policies, Object Lock and lifecycle rules (see the security page) instead.

Replication

Replication copies new objects automatically and asynchronously to another bucket. Cross-Region Replication (CRR) targets a bucket in another Region (disaster recovery, lower latency for remote users, compliance); Same-Region Replication (SRR) targets a bucket in the same Region (log aggregation, separating production and test data, a copy in a different account). Both buckets need versioning enabled, S3 needs an IAM role it can assume to read the source and write the destination, and only objects created after the rule exists are replicated by default. Existing objects are copied with S3 Batch Replication, which can also retry objects whose replication failed.

S3 Replication Time Control (RTC) adds a service-level objective: 99.99 percent of objects replicated within 15 minutes, with metrics and event notifications for the exceptions. It is billed per GB replicated on top of normal replication charges. Delete markers are replicated only if you enable DeleteMarkerReplication, and deleting a specific version is never replicated, so a malicious delete in the source does not propagate.

export DEST_BUCKET=bookshelf-uploads-dr-$ACCOUNT_ID   # created in another Region with versioning on
export REPLICATION_ROLE_ARN=arn:aws:iam::$ACCOUNT_ID:role/bookshelf-s3-replication

cat > replication.json <<JSON
{
  "Role": "$REPLICATION_ROLE_ARN",
  "Rules": [
    {
      "ID": "uploads-to-dr",
      "Status": "Enabled",
      "Priority": 1,
      "Filter": { "Prefix": "uploads/" },
      "DeleteMarkerReplication": { "Status": "Disabled" },
      "Destination": {
        "Bucket": "arn:aws:s3:::$DEST_BUCKET",
        "StorageClass": "STANDARD_IA",
        "ReplicationTime": { "Status": "Enabled", "Time": { "Minutes": 15 } },
        "Metrics": { "Status": "Enabled", "EventThreshold": { "Minutes": 15 } }
      }
    }
  ]
}
JSON
aws s3api put-bucket-replication --bucket $BUCKET --replication-configuration file://replication.json

The unquoted JSON heredoc delimiter above lets the shell expand the two variables. The role’s trust policy lets s3.amazonaws.com assume it, and its permissions policy needs the following; replace the bucket names with yours.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadSourceConfiguration",
      "Effect": "Allow",
      "Action": ["s3:GetReplicationConfiguration", "s3:ListBucket"],
      "Resource": "arn:aws:s3:::bookshelf-uploads-111122223333"
    },
    {
      "Sid": "ReadSourceObjects",
      "Effect": "Allow",
      "Action": [
        "s3:GetObjectVersionForReplication",
        "s3:GetObjectVersionAcl",
        "s3:GetObjectVersionTagging"
      ],
      "Resource": "arn:aws:s3:::bookshelf-uploads-111122223333/*"
    },
    {
      "Sid": "WriteDestination",
      "Effect": "Allow",
      "Action": ["s3:ReplicateObject", "s3:ReplicateDelete", "s3:ReplicateTags"],
      "Resource": "arn:aws:s3:::bookshelf-uploads-dr-111122223333/*"
    }
  ]
}

Derived from S3 User Guide — Setting up permissions for replication. If either bucket uses SSE-KMS the role also needs kms:Decrypt on the source key and kms:Encrypt on the destination key, covered in the security page.

In the console: Management → Replication rules → Create replication rule, choose the scope, the destination bucket (this account or another), let the console create the IAM role, and tick Replication Time Control and Replication metrics if you need them. The console offers to run a one-time Batch Replication job for existing objects.

Event notifications

A bucket can emit events (s3:ObjectCreated:*, s3:ObjectRemoved:*, s3:Replication:*, lifecycle and restore events) to a Lambda function, an SQS queue, an SNS topic or Amazon EventBridge. Bookshelf’s thumbnailer Lambda function runs on s3:ObjectCreated:* for the covers/ prefix (see AWS Lambda and Lambda: Event-Driven and Serverless Apps for the function and its execution role). Notifications are delivered at least once, so the handler must be idempotent.

cat > notification.json <<JSON
{
  "LambdaFunctionConfigurations": [
    {
      "Id": "thumbnail-new-covers",
      "LambdaFunctionArn": "arn:aws:lambda:$AWS_REGION:$ACCOUNT_ID:function:thumbnailer",
      "Events": ["s3:ObjectCreated:*"],
      "Filter": { "Key": { "FilterRules": [
        { "Name": "prefix", "Value": "covers/" },
        { "Name": "suffix", "Value": ".jpg" } ] } }
    }
  ]
}
JSON

# S3 must be allowed to invoke the function before the configuration is accepted
aws lambda add-permission --function-name thumbnailer --statement-id s3-invoke \
  --action lambda:InvokeFunction --principal s3.amazonaws.com \
  --source-arn arn:aws:s3:::$BUCKET --source-account $ACCOUNT_ID

aws s3api put-bucket-notification-configuration \
  --bucket $BUCKET --notification-configuration file://notification.json

Derived from S3 User Guide — Amazon S3 Event Notifications and aws s3api put-bucket-notification-configuration. Do not write results back to the same prefix a notification watches, or the function triggers itself in a loop. EventBridge delivery (aws s3api put-bucket-notification-configuration with an an empty EventBridgeConfiguration object) adds filtering, multiple targets and replay at slightly higher latency.

Presigned URLs

A presigned URL is an ordinary S3 URL with a signature, expiry and the signer’s permissions baked into the query string. Whoever holds it can perform exactly one operation on exactly one object until it expires, without AWS credentials. Bookshelf uses them so that a browser uploads a cover directly to S3 and a reader downloads a PDF without the catalog-api proxying the bytes: the API authenticates the user, then returns a short-lived URL.

sequenceDiagram participant B as Browser participant A as catalog-api participant S as S3 bucket B->>A: POST /covers (authenticated) A->>A: sign PutObject for covers/isbn.jpg, 10 minutes A-->>B: presigned PUT URL B->>S: PUT cover bytes to the URL S-->>B: 200 OK S-->>A: ObjectCreated event (optional, via Lambda or SQS)

The URL’s power equals the signer’s permissions at the time of use, and it stops working when the signing credentials expire even if the URL’s own expiry is later. A URL signed with a role session’s temporary credentials is therefore valid for at most the remaining session lifetime (at most 12 hours for an assumed role, 6 hours for an instance profile, and the Lambda or task credential window in practice). To get the full 7 days that Signature Version 4 allows, sign with an IAM user’s long-term access keys, which is the opposite of best practice, so keep URLs to minutes.

In the AWS Management Console

Select the object and choose Object actions → Share with a presigned URL, enter the time interval (up to 12 hours in the console), and choose Create presigned URL. The URL is copied to the clipboard.

With the AWS CLI

# Download URL, valid for 10 minutes (the CLI presigns GET only)
aws s3 presign s3://$BUCKET/uploads/guide.pdf --expires-in 600

Derived from aws s3 presign and the S3 User Guide — Sharing objects with presigned URLs. aws s3 presign can only create download URLs; for upload URLs use an SDK (below).

Application code

The recommended pattern is the same as everywhere on AWS: no keys in code. The catalog-api runs with a task role (see ECS and Fargate) and the SDK finds its credentials through the default credential provider chain. The role needs only what the code uses; this policy grants the application read, write and list access to the uploads bucket and nothing else.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListUploadsBucket",
      "Effect": "Allow",
      "Action": ["s3:ListBucket", "s3:GetBucketLocation"],
      "Resource": "arn:aws:s3:::bookshelf-uploads-111122223333"
    },
    {
      "Sid": "ReadWriteUploadObjects",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:AbortMultipartUpload",
        "s3:ListBucketMultipartUploads",
        "s3:ListMultipartUploadParts"
      ],
      "Resource": "arn:aws:s3:::bookshelf-uploads-111122223333/*"
    }
  ]
}

Derived from S3 User Guide — How Amazon S3 works with IAM and the Service Authorization Reference for Amazon S3. Bucket-level actions go on the bucket ARN and object-level actions on the /* ARN; mixing them up is the classic cause of AccessDenied. If the bucket uses SSE-KMS the role also needs kms:GenerateDataKey and kms:Decrypt on the key (see the security page). IAM policy evaluation itself is in Identity and Access.

Java with the AWS SDK for Java 2.x

package com.example.bookshelf.storage;

import java.net.URL;
import java.nio.file.Path;
import java.time.Duration;

import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.StorageClass;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
import software.amazon.awssdk.transfer.s3.S3TransferManager;
import software.amazon.awssdk.transfer.s3.model.UploadFileRequest;

public class CoverStorage implements AutoCloseable {

    private final String bucket;
    private final S3Client s3 = S3Client.create();          // default credential chain and Region
    private final S3Presigner presigner = S3Presigner.create();
    private final S3TransferManager transfers = S3TransferManager.builder()
            .s3Client(S3AsyncClient.crtBuilder().build())   // CRT client: parallel multipart, needs aws-crt
            .build();

    public CoverStorage(String bucket) {
        this.bucket = bucket;
    }

    public void putCover(String isbn, byte[] jpeg) {
        s3.putObject(PutObjectRequest.builder()
                        .bucket(bucket)
                        .key("covers/" + isbn + ".jpg")
                        .contentType("image/jpeg")
                        .storageClass(StorageClass.INTELLIGENT_TIERING)
                        .build(),
                RequestBody.fromBytes(jpeg));
    }

    public void uploadLargeFile(String key, Path file) {
        // Multipart, parallel and retried automatically
        transfers.uploadFile(UploadFileRequest.builder()
                        .putObjectRequest(r -> r.bucket(bucket).key(key))
                        .source(file)
                        .build())
                .completionFuture().join();
    }

    public URL presignedDownload(String key) {
        return presigner.presignGetObject(GetObjectPresignRequest.builder()
                        .signatureDuration(Duration.ofMinutes(10))
                        .getObjectRequest(GetObjectRequest.builder().bucket(bucket).key(key).build())
                        .build())
                .url();
    }

    public URL presignedUpload(String isbn) {
        return presigner.presignPutObject(PutObjectPresignRequest.builder()
                        .signatureDuration(Duration.ofMinutes(10))
                        .putObjectRequest(PutObjectRequest.builder()
                                .bucket(bucket)
                                .key("covers/" + isbn + ".jpg")
                                .contentType("image/jpeg")   // the browser must send the same header
                                .build())
                        .build())
                .url();
    }

    @Override
    public void close() {
        transfers.close();
        presigner.close();
        s3.close();
    }
}

Derived from the AWS SDK for Java 2.x Developer Guide — Amazon S3 examples, the S3 Transfer Manager and presigned URLs. Create clients once and reuse them; they are thread-safe and hold connection pools. The presigned PUT only works if the browser sends exactly the signed headers (here Content-Type).

Spring Cloud AWS

With Spring Cloud AWS the starter io.awspring.cloud:spring-cloud-aws-starter-s3 auto-configures an S3Client and an S3Template, and reads the Region and credentials from the standard sources (spring.cloud.aws.region.static is a local override; in ECS the task role is used automatically). The S3Template hides streams and metadata, and signs URLs. The wiring, the Resource abstraction and testing without AWS are in File Storage & Object Stores; the two snippets below show only what is S3-specific.

@Service
public class CoverService {

    private final S3Template s3Template;
    private final String bucket;

    public CoverService(S3Template s3Template,
                        @Value("${bookshelf.uploads-bucket}") String bucket) {
        this.s3Template = s3Template;
        this.bucket = bucket;
    }

    public void save(String isbn, InputStream data, String contentType) {
        s3Template.upload(bucket, "covers/" + isbn + ".jpg", data,
                ObjectMetadata.builder().contentType(contentType).build());
    }

    public URL downloadUrl(String isbn) {
        return s3Template.createSignedGetURL(bucket, "covers/" + isbn + ".jpg", Duration.ofMinutes(10));
    }
}

Python with boto3

Python is handy for operational scripts and Lambda functions. generate_presigned_url and upload_file (which uses a transfer manager with multipart) parallel the Java calls; credentials again come from the default chain.

import boto3

s3 = boto3.client("s3")
bucket = "bookshelf-uploads-111122223333"

s3.upload_file("cover.jpg", bucket, "covers/9780134685991.jpg",
               ExtraArgs={"ContentType": "image/jpeg", "StorageClass": "INTELLIGENT_TIERING"})

url = s3.generate_presigned_url(
    "get_object",
    Params={"Bucket": bucket, "Key": "uploads/guide.pdf"},
    ExpiresIn=600,
)

Performance

S3 scales request rate per prefix: at least 3,500 PUT, COPY, POST or DELETE and 5,500 GET or HEAD requests per second for each prefix in a bucket, with no limit on the number of prefixes. Throughput ramps up gradually, and during the ramp you may see 503 Slow Down responses that the SDKs retry with backoff. Older guidance to randomize key prefixes with hashes no longer applies; spread hot traffic over several prefixes only when one prefix saturates.

  • Parallelize: many concurrent requests, and multipart upload or ranged GETs for large objects. The CRT-based S3 client and the transfer managers do this and can approach the host’s network limit.

  • Byte-range fetches: aws s3api get-object --range bytes=0-1048575 reads part of an object, which lets a client fetch pieces in parallel or read only a file’s footer.

  • Keep traffic in-Region and use a gateway VPC endpoint for private subnets (see the next section) to avoid NAT gateway data charges.

  • Cache hot objects with CloudFront (CloudFront and CDN Delivery) or ElastiCache, and use Transfer Acceleration for far-away uploaders.

  • Mountpoint for Amazon S3 is an open-source client that mounts a bucket as a local file system on Linux (EC2, EKS through the CSI driver) for read-heavy workloads such as ML training. It supports sequential writes of new objects but not in-place edits or renames; for full file semantics use S3 Files or EFS instead.

  • Directory buckets with Express One Zone give consistent single-digit millisecond latency for the few workloads that need it.

Networking

S3 is a regional service reached over public endpoints (s3.<region>.amazonaws.com and <bucket>.s3.<region>.amazonaws.com) with TLS, and it supports IPv6 on dual-stack endpoints. Workloads in private subnets do not need an internet path: a gateway VPC endpoint for S3 adds a route-table entry that sends S3 traffic over the AWS network at no hourly or per-GB charge, and an endpoint policy can restrict which buckets and actions the VPC may use. An interface endpoint (AWS PrivateLink) additionally gives S3 private IP addresses reachable from on-premises networks over VPN or Direct Connect. A bucket policy can then require traffic to come through a specific endpoint with the aws:SourceVpce condition key (see the security page). Virtual networks and routing are covered in AWS Global Infrastructure for the geography and in the networking pages of this section.

export VPC_ID=vpc-0abc1234def567890     # placeholder
export ROUTE_TABLE_ID=rtb-0abc1234def567890
aws ec2 create-vpc-endpoint \
  --vpc-id $VPC_ID \
  --vpc-endpoint-type Gateway \
  --service-name com.amazonaws.$AWS_REGION.s3 \
  --route-table-ids $ROUTE_TABLE_ID

Derived from aws ec2 create-vpc-endpoint and Gateway endpoints for Amazon S3. In the console use VPC → Endpoints → Create endpoint, filter for the service com.amazonaws.us-east-1.s3 of type Gateway, and select the route tables of your private subnets.

Observability and data management

  • CloudWatch metrics: daily storage metrics are free; request metrics (per bucket, prefix or tag) and replication metrics are optional and billed.

  • Server access logs and CloudTrail data events record who read or wrote which object; data events are billed per event, so scope them to sensitive buckets.

  • S3 Storage Lens is a dashboard of usage and activity metrics across all accounts and buckets in an organization, with recommendations such as buckets without lifecycle rules or with many incomplete multipart uploads. The free tier shows summary metrics; the advanced tier adds prefix-level metrics and retains history.

  • S3 Inventory writes a daily or weekly CSV, ORC or Parquet report of all objects and their metadata, queryable with Athena, which is far cheaper than listing a huge bucket.

  • S3 Metadata keeps a queryable table of object metadata that updates continuously (see the bucket types above).

  • S3 Batch Operations applies one action (copy, tag, restore, invoke Lambda, replicate) to a billion objects listed in an inventory report.

  • Cost allocation tags on buckets and objects split the bill by project in Cost Explorer.

aws s3control list-storage-lens-configurations --account-id $ACCOUNT_ID
aws s3api put-bucket-inventory-configuration \
  --bucket $BUCKET --id weekly-inventory \
  --inventory-configuration '{
    "Id": "weekly-inventory",
    "IsEnabled": true,
    "IncludedObjectVersions": "Current",
    "Schedule": { "Frequency": "Weekly" },
    "Destination": { "S3BucketDestination": {
      "Bucket": "arn:aws:s3:::bookshelf-inventory-111122223333",
      "Format": "Parquet" } }
  }'

Derived from S3 User Guide — Assessing your storage activity and usage with S3 Storage Lens and aws s3api put-bucket-inventory-configuration. The destination bucket needs a bucket policy that lets S3 write the report.

Pricing model

Prices change and differ by Region, so this page lists the drivers rather than numbers; use the S3 pricing page and the AWS Pricing Calculator (pricing checked 2026-10-01).

  • Storage, per GB-month and per class. Prices fall from Standard through IA to Glacier Deep Archive. Versions, delete markers' keys, incomplete multipart parts and replicas all count.

  • Requests, per thousand or million, with PUT, COPY, POST and LIST costing more than GET and HEAD, and the cheaper classes costing more per request. Lifecycle transitions are billed as requests.

  • Retrieval, per GB, for Standard-IA, One Zone-IA and the Glacier classes, plus restore request fees for the archive classes.

  • Minimum durations and minimum object sizes (30, 90 or 180 days; 128 KB) for the IA and Glacier classes: early deletion and tiny objects cost more than they save.

  • Monitoring and automation, per object, for Intelligent-Tiering (objects under 128 KB are exempt).

  • Data transfer: inbound is free, in-Region transfer to EC2 or through a gateway endpoint is free, transfer out to the internet and between Regions is billed per GB (CloudFront has a different, usually lower rate and a free allowance; see the CloudFront page). Transfer Acceleration adds a per-GB charge.

  • Replication: the destination storage and requests, inter-Region transfer for CRR, and an RTC per-GB fee.

  • Management features: Storage Lens advanced metrics, Inventory and Batch Operations per object listed, CloudWatch request metrics, CloudTrail data events, and KMS requests when you use SSE-KMS (S3 Bucket Keys reduce them).

  • Free Tier: new accounts get a limited allowance of Standard storage, requests and data transfer under the current Free Tier rules; check the pricing page for the exact terms.

The practical levers are an AbortIncompleteMultipartUpload rule on every bucket, noncurrent-version expiration on every versioned bucket, Intelligent-Tiering or lifecycle transitions for aging data, a gateway endpoint to keep traffic off the NAT gateway, and CloudFront in front of public content.

Limits and recent changes

  • Object size: 50 TB maximum; 5 GB maximum per single PUT; 10,000 parts per multipart upload.

  • Request rates scale per prefix (above); metadata is limited to 2 KB for user-defined headers and 10 tags per object.

  • Key length is at most 1,024 bytes of UTF-8. Bucket names are 3 to 63 characters.

  • Default quota of 10,000 general purpose buckets per account, adjustable through Service Quotas.

  • 2023-01: SSE-S3 default encryption on all new objects. 2023-04: Block Public Access on and ACLs disabled by default on new buckets. 2025: conditional writes (If-None-Match, If-Match), 50 TB objects, S3 Tables, S3 Vectors preview. 2025-12: S3 Vectors generally available (announcement); the standalone Amazon Glacier vault service closed to new customers. 2025-11-07: S3 Object Lambda closed to new customers. 2026-03: account regional namespace. 2026-04: SSE-C blocked by default on new buckets; S3 Files.

  • Older guides and books still show public-ACL and public-bucket-policy static sites, the aws s3 mb plus --acl public-read pattern, gp2 volume comparisons, "eventual consistency" caveats, hashed key prefixes, and the Glacier vault API. The current approach is private buckets, bucket policies or IAM roles, and CloudFront with Origin Access Control.

Troubleshooting

Symptom Likely cause What to check

AccessDenied (403) on GET, PUT or LIST

The IAM policy or bucket policy does not allow the action, the action is on the wrong ARN (bucket versus /*), an explicit Deny or Service Control Policy applies, Block Public Access blocks the request, or the object is encrypted with a KMS key the caller cannot use.

Run aws sts get-caller-identity; test with the IAM policy simulator; compare bucket and object ARNs; check bucket policy Deny statements; for SSE-KMS check the key policy. Remember S3 returns AccessDenied rather than 404 for a missing key when the caller lacks s3:ListBucket.

NoSuchBucket or 301 PermanentRedirect

The bucket name is wrong, deleted, or in another Region than the endpoint used.

Run aws s3api get-bucket-location --bucket $BUCKET and set --region or AWS_REGION accordingly.

BucketAlreadyExists (409)

The global-namespace name is already used by another account.

Pick another name with a random or account suffix, or use the account regional namespace.

BucketAlreadyOwnedByYou (409)

You already own a bucket with that name.

aws s3api head-bucket; reuse it, or choose a new name.

BucketNotEmpty on delete

The bucket still has objects, versions, delete markers or in-progress multipart uploads.

Follow the Clean up steps; aws s3 rb --force does not remove versions.

InvalidObjectState on GET

The object is in an archive class or archive tier.

aws s3api restore-object, then wait until head-object shows the restore is complete.

NoSuchKey

The key is misspelled, has a leading slash, was deleted, or you are reading the wrong version.

aws s3api list-objects-v2 --prefix, and list-object-versions for versioned buckets.

503 Slow Down

Request rate on a prefix grew faster than S3 could scale it.

Retry with exponential backoff (SDKs do), spread load across prefixes, and ramp traffic up gradually.

412 PreconditionFailed on PUT

A conditional write found the key already present or the ETag changed.

Expected under contention; read the current object and retry deliberately.

Presigned URL returns SignatureDoesNotMatch

The client sent different headers than were signed (for example another Content-Type), a different Region or a modified query string.

Sign with the same headers and Region the client will use; sign with S3Presigner configured for the bucket’s Region.

Presigned URL returns ExpiredToken or Request has expired

The URL expired, or the temporary credentials that signed it did.

Shorten the intended use time, regenerate per request, and remember the role session limits.

Lifecycle rule did nothing

Rules run asynchronously about once a day; the filter does not match; the minimum-size or minimum-age rule blocks a transition; the object is under 128 KB.

aws s3api get-bucket-lifecycle-configuration, then head-object to see storage class and expiry (Expiration header).

Replication shows FAILED or objects missing in the destination

Versioning is off on one bucket, the role lacks permissions, KMS permissions are missing, or the object predates the rule.

head-object shows ReplicationStatus; run S3 Batch Replication for older objects; check the role policy.

Unexpected cost

Incomplete multipart uploads, old noncurrent versions, many tiny objects in IA classes, NAT gateway data charges, or CloudTrail data events.

Storage Lens, Cost Explorer filtered by usage type, and a lifecycle review.

Clean up

Bookshelf buckets are versioned, so aws s3 rb --force is not enough: it deletes current objects (adding delete markers) but leaves every version and delete marker behind, and delete-bucket then fails with BucketNotEmpty. Delete in dependency order: replication and notification configuration first (so nothing writes while you clean), then versions and delete markers, then in-progress multipart uploads, then the bucket.

# 1. Stop writers: remove replication and notifications
aws s3api delete-bucket-replication --bucket $BUCKET
aws s3api put-bucket-notification-configuration --bucket $BUCKET --notification-configuration '{}'

# 2. Delete every object version and delete marker, 1000 at a time
for kind in Versions DeleteMarkers; do
  while : ; do
    BATCH=$(aws s3api list-object-versions --bucket "$BUCKET" --max-items 1000 \
      --query "{Objects: $kind[].{Key: Key, VersionId: VersionId}}" --output json)
    [ "$(echo "$BATCH" | jq '(.Objects // []) | length')" -eq 0 ] && break
    aws s3api delete-objects --bucket "$BUCKET" \
      --delete "$(echo "$BATCH" | jq -c '{Objects: .Objects, Quiet: true}')" > /dev/null
  done
done

# 3. Abort leftover multipart uploads
aws s3api list-multipart-uploads --bucket $BUCKET \
  --query 'Uploads[].[Key,UploadId]' --output text \
  | while read -r KEY UPLOAD_ID; do
      aws s3api abort-multipart-upload --bucket $BUCKET --key "$KEY" --upload-id "$UPLOAD_ID"
    done

# 4. Delete the bucket (and the DR bucket the same way)
aws s3api delete-bucket --bucket $BUCKET

In the console, select the bucket and choose Empty, confirm by typing permanently delete, then choose Delete and type the bucket name; Empty removes all versions and delete markers. If you used a lifecycle rule to expire everything, the bucket empties itself within a day or two. Also delete the replication IAM role, the Lambda permission statement, any gateway endpoint created only for this page, the Inventory destination bucket, and any S3 Tables or Vectors buckets you tried. Objects under Object Lock retention or legal hold cannot be deleted until the hold ends.