S3
LocalEmu ships a custom Python implementation of S3, not a thin wrapper around the upstream Moto backend. Bucket and object state lives in EphemeralS3ObjectStore: small objects stay in memory, larger objects spill to disk above 512 KiB, and everything persists across restarts when PERSISTENCE=1. Multipart uploads, versioning, replication, lifecycle, presigned URLs (SigV2 and SigV4), and event notifications to Lambda, SQS, SNS, and EventBridge all work end-to-end against the AWS CLI, boto3, and Terraform.
Coverage at the operation level: see the S3 coverage matrix for which of the 112 S3 operations are implemented.
Quick start
$ awsemu s3 mb s3://uploads
make_bucket: uploads
$ echo "hello LocalEmu" > /tmp/hello.txt
$ awsemu s3 cp /tmp/hello.txt s3://uploads/hello.txt
upload: ../../tmp/hello.txt to s3://uploads/hello.txt
$ awsemu s3 ls s3://uploads/
2026-05-19 09:14:22 15 hello.txt
$ awsemu s3 cp s3://uploads/hello.txt -
hello LocalEmu The awsemu wrapper pins the endpoint to http://localhost:4566 and injects the canonical example credentials, so the standard AWS CLI verbs reach LocalEmu directly. boto3, the JavaScript and Go SDKs, Terraform, the AWS CDK, and Pulumi all connect with only the endpoint URL changed.
Endpoint addressing
Both URL styles work against the same gateway. boto3 and the AWS CLI auto-detect, so most users never think about this. Path-style is the safer default for local development because it avoids DNS configuration for per-bucket subdomains.
# Path-style (default): the bucket name is in the URL path
$ curl http://s3.localhost:4566/uploads/hello.txt
hello LocalEmu
# Virtual-host style: the bucket name is the subdomain
$ curl http://uploads.s3.localhost:4566/hello.txt
hello LocalEmu Bucket name validation follows the AWS rules: 3 to 63 characters, lowercase letters, digits, hyphens, and dots. Detection lives at services/s3/utils.py:544 for virtual-host parsing and :590 for path-style.
Storage backend
Object bodies go through EphemeralS3ObjectStore, defined in services/s3/storage/ephemeral.py. Each stored object is a SpooledTemporaryFile: the first 512 KiB stay in memory, and once an object grows past that threshold the body is written to disk under /tmp/localemu-s3-storage. This keeps small-object workloads (config files, JSON payloads, image thumbnails) entirely in RAM while large objects (videos, archives) do not bloat the process.
When PERSISTENCE=1 is set, the on-disk spill directory and the in-memory state are saved and restored across restarts. Without persistence, the storage directory is cleared on shutdown.
Configuration
| Variable | Default | Purpose |
|---|---|---|
BUCKET_MARKER_LOCAL | hot-reload | Magic bucket name that hot-reloads objects from a local filesystem path instead of returning the stored body. Useful for Lambda code under active development. |
S3_SKIP_SIGNATURE_VALIDATION | true | When true (the default), presigned URL signatures are accepted without HMAC verification. Set to false to enforce SigV4 signature checks against the IAM access keys. |
S3_SKIP_KMS_KEY_VALIDATION | true | When true (the default), SSE-KMS does not check that the referenced KMS key actually exists. Set to false for stricter behaviour. |
S3_STRICT_PRESIGNED_VALIDATION | false | When true, presigned URLs whose access key is not registered in IAM are rejected outright instead of accepted with default credentials. |
DISABLE_CUSTOM_CORS_S3 | false | When true, disables the custom S3 CORS handler. Use only if you need the request to pass through to a downstream handler unmodified. |
Defaults and limits
| Limit | Value | Source |
|---|---|---|
| Min multipart part size (except last) | 5 MiB | services/s3/constants.py:14 |
| Max parts per multipart upload | 10,000 | services/s3/provider.py:1161 |
Default ListObjects page size | 1,000 | services/s3/provider.py:1801 |
| Bucket name length | 3 - 63 chars | services/s3/utils.py:94 |
| Object key length | 1,024 bytes (UTF-8) | services/s3/validation.py:432 |
| In-memory spool threshold | 512 KiB | services/s3/storage/ephemeral.py:32 |
| Streaming chunk size | 64 KiB | services/s3/constants.py:114 |
| Storage classes accepted | STANDARD, STANDARD_IA, GLACIER, GLACIER_IR, REDUCED_REDUNDANCY, ONEZONE_IA, INTELLIGENT_TIERING, DEEP_ARCHIVE | services/s3/constants.py:46-55 |
There is no enforced upper bound on object size. Large objects are limited only by available disk on the host. Default bucket encryption, when the user enables SSE without specifying parameters, is AES256 with BucketKeyEnabled=False (see services/s3/constants.py:116-121).
Features supported
| Feature | Notes |
|---|---|
| Multipart upload | Create, Upload, UploadPartCopy, Complete, Abort, ListParts. |
| Versioning | Enable and Suspend, list versions, delete markers, version-aware GET / DELETE. |
| Lifecycle | Rule CRUD and forward-going expiration. Existing objects are not retroactively swept (see Caveats). |
| Replication | Same-region and cross-region. |
| SSE-S3, SSE-KMS | Full. SSE-KMS key existence is checked when S3_SKIP_KMS_KEY_VALIDATION=false. |
| SSE-C | Partial. The customer-provided key is accepted in the request and the API returns the success shape, but the stored body is the plaintext. Do not use SSE-C in LocalEmu to test encryption behaviour. |
| Object Lock | Retention modes (GOVERNANCE, COMPLIANCE) and legal hold, on the bucket and on individual objects. |
| Bucket policy, ACLs, CORS | Full CRUD. CORS preflight handled by the custom S3 CORS handler. |
| Static website hosting | Index document, error document, redirects, routing rules. |
| Event notifications | Fan-out to Lambda, SQS, SNS, EventBridge. See next section. |
| Presigned URLs (SigV2 and SigV4) | GET and PUT. Signature verification is governed by S3_SKIP_SIGNATURE_VALIDATION. |
| Intelligent-Tiering, Inventory, Analytics, Metrics | Configuration CRUD. The metrics, analytics, and inventory reports are not produced. |
| Object tagging | Put, Get, Delete on object and bucket tags. |
| Public Access Block | Bucket-level CRUD and enforcement. |
| Bucket logging, Ownership controls, Requester Pays | Configuration CRUD. RequestCharged response param is not yet populated. |
| CopyObject | Intra-bucket and cross-bucket. UploadPartCopy for chunked copies. |
| POST object form upload | Accepted. Policy expiration is checked; full signature and policy-rule validation are partial (see Caveats). |
Event notifications
All four AWS event-notification targets fan out from the same notification dispatcher (services/s3/notifications.py):
- •Lambda: asynchronous invocation with
InvocationType=Event, payload shaped as the S3 event record AWS publishes. Implementation:notifications.py:565-620. - •SQS:
SendMessageagainst the configured queue with the S3 event JSON as the body. Implementation:notifications.py:390-480. - •SNS:
Publishagainst the configured topic, fanning out to subscribed endpoints. Implementation:notifications.py:482-560. - •EventBridge:
PutEventsagainst the default bus with detail-typeObject Created,Object Deleted, etc. Implementation:notifications.py:633-749.
A single PUT can deliver to multiple targets at once when the bucket is configured with multiple destinations. Delivery happens synchronously inside the request handler, so a downstream consumer that fails will surface in the same call.
Presigned URLs
Both SigV2 and SigV4 presigned URLs are accepted. The signing happens client-side as usual (boto3, the AWS CLI, the SDK in your language); LocalEmu validates the URL on the receive side. By default S3_SKIP_SIGNATURE_VALIDATION=true short-circuits the HMAC verification, which makes the URL behave like a bearer token bound to the bucket, key, and expiry. Set the variable to false to enforce real SigV4 verification against your registered IAM access keys, and set S3_STRICT_PRESIGNED_VALIDATION=true to reject URLs whose access key is unknown to IAM.
Validation entrypoints: validate_presigned_url_s3 (SigV2) and validate_presigned_url_s3v4 (SigV4) in services/s3/presigned_url.py.
Examples
Multipart upload
# The high-level CLI auto-multiparts any file > 8 MB.
$ dd if=/dev/urandom of=/tmp/big.bin bs=1M count=64
$ awsemu s3 cp /tmp/big.bin s3://uploads/big.bin
upload: ../../tmp/big.bin to s3://uploads/big.bin
# Low-level: create, upload parts, complete.
$ UPLOAD_ID=$(awsemu s3api create-multipart-upload \
--bucket uploads --key archive.zip \
--query UploadId --output text)
$ ETAG1=$(awsemu s3api upload-part \
--bucket uploads --key archive.zip \
--upload-id $UPLOAD_ID --part-number 1 \
--body part1.bin --query ETag --output text)
$ awsemu s3api complete-multipart-upload \
--bucket uploads --key archive.zip \
--upload-id $UPLOAD_ID \
--multipart-upload "Parts=[{ETag=$ETAG1,PartNumber=1}]" Versioning and delete markers
$ awsemu s3api put-bucket-versioning \
--bucket uploads \
--versioning-configuration Status=Enabled
$ echo "v1" | awsemu s3 cp - s3://uploads/notes.txt
$ echo "v2" | awsemu s3 cp - s3://uploads/notes.txt
$ awsemu s3 rm s3://uploads/notes.txt
$ awsemu s3api list-object-versions --bucket uploads --prefix notes.txt
{
"Versions": [
{"Key": "notes.txt", "VersionId": "v8...", "IsLatest": false, "Size": 3},
{"Key": "notes.txt", "VersionId": "v7...", "IsLatest": false, "Size": 3}
],
"DeleteMarkers": [
{"Key": "notes.txt", "VersionId": "v9...", "IsLatest": true}
]
} Wire S3 events to a Lambda function
# Create the Lambda function.
$ awsemu lambda create-function \
--function-name on-upload \
--runtime python3.12 \
--role arn:aws:iam::000000000000:role/lambda-role \
--handler handler.lambda_handler \
--zip-file fileb://handler.zip
# Wire S3 -> Lambda for ObjectCreated:*.
$ awsemu s3api put-bucket-notification-configuration \
--bucket uploads \
--notification-configuration '{
"LambdaFunctionConfigurations": [{
"LambdaFunctionArn": "arn:aws:lambda:us-east-1:000000000000:function:on-upload",
"Events": ["s3:ObjectCreated:*"]
}]
}'
$ awsemu s3 cp /tmp/hello.txt s3://uploads/triggered.txt
# Lambda was invoked asynchronously. Inspect its logs:
$ awsemu logs tail /aws/lambda/on-upload --follow Presigned PUT URL
# Generate a presigned PUT URL valid for 1 hour.
$ URL=$(awsemu s3 presign \
s3://uploads/from-the-web.txt \
--expires-in 3600)
$ echo "$URL"
http://s3.localhost:4566/uploads/from-the-web.txt?AWSAccessKeyId=...&Expires=...&Signature=...
# Upload directly with curl, no AWS credentials needed by the uploader.
$ curl -X PUT --upload-file /tmp/hello.txt "$URL" Known limitations
Eighteen of the 112 S3 operations are not implemented and return NotImplementedException (501). Grouped by feature area:
- •Directory buckets (S3 Express):
CreateSession,ListDirectoryBuckets. - •S3 Metadata service:
CreateBucketMetadataConfiguration,CreateBucketMetadataTableConfiguration,DeleteBucketMetadataConfiguration,DeleteBucketMetadataTableConfiguration,GetBucketMetadataConfiguration,GetBucketMetadataTableConfiguration,UpdateBucketMetadataInventoryTableConfiguration,UpdateBucketMetadataJournalTableConfiguration. - •ABAC:
GetBucketAbac,PutBucketAbac. - •Deprecated APIs:
GetBucketLifecycle,PutBucketLifecycle,GetBucketNotification,PutBucketNotification. Use the*Configurationvariants. - •S3 Select:
SelectObjectContentreturns 501. SQL-over-S3 is not part of LocalEmu yet. - •Misc:
RenameObject,UpdateObjectEncryption,WriteGetObjectResponse(full Object Lambda pipeline).
Behavioural caveats on otherwise-implemented features:
- •SSE-C bodies are not encrypted at rest. The customer-provided key flow is accepted at the API surface so client code can be exercised, but the stored body is the plaintext (
provider.py:815). - •Lifecycle rules apply going forward only. Adding a rule does not retroactively expire existing objects (
provider.py:951,1105,1257). - •POST object form upload checks the policy expiration but does not yet validate the signature or enforce the policy field rules beyond that (
provider.py:4660-4661). - •Precondition writes on versioned buckets are not yet supported. Multiple concurrent writes to the same key with
If-Matchconditions are not serialised per version (provider.py:355). - •Requester Pays headers and
RequestChargedresponse parameters are not populated yet, even when a bucket has Requester Pays enabled. - •Inventory, Analytics, and Metrics configurations CRUD works, but the reports themselves (CSV / ORC / Parquet outputs to a destination bucket) are not produced.
The exact set of operations and their status is in the S3 coverage matrix.