5.9.6. S3-Compatible Storage
Table of Contents
Why S3-Compatible Storage Matters
Most beginner projects store files on the local disk of the server. This is fine for experiments, but it quickly breaks in real applications:
- You cannot easily scale to multiple servers.
- You lose files when the server is destroyed or redeployed.
- Backups and durability become hard to manage.
Object storage solves these problems by giving you a separate, durable place to store files. The most famous example is Amazon S3. Many other services implement the same API, which is why we talk about S3-compatible storage.
In this chapter you will learn what S3-compatible storage is, how it is different from normal file storage, and how to use it in a backend application.
What “S3-Compatible” Means
The S3 API
S3 exposes a simple HTTP-based API with operations such as:
- Create a bucket
- Upload an object
- Download an object
- List objects
- Delete objects
When a provider is S3-compatible, it means:
- It supports the same HTTP endpoints and operations as S3, or almost the same.
- It understands S3-style authorization headers, like AWS Signature v4.
- Libraries written for S3 (like
boto3in Python) can usually connect with only configuration changes.
Examples of S3-compatible providers:
| Provider | Typical Use |
|---|---|
| Amazon S3 | Original S3, part of AWS |
| MinIO | Self-hosted S3-compatible server |
| DigitalOcean Spaces | Managed S3-compatible object storage |
| Backblaze B2 | Cheap long-term storage |
| Wasabi | Low-cost S3-compatible storage |
You can usually switch providers with no application code changes, only environment variables like endpoint URLs or credentials.
Important:
S3-compatible means “same API style”, not “identical behavior”.
Always check:
- Region names and endpoints,
- How public URLs are formed,
- Limits, pricing, and performance.
Buckets and Objects
Core Concepts
Object storage does not use folders in the same way as a normal filesystem. It uses two main concepts:
- Bucket: A top-level container for files.
- Object: A file stored inside a bucket, identified by a unique key.
You can think of a bucket as a drive, and the object key as the path on that drive.
Example:
- Bucket:
my-app-uploads - Object key:
avatars/user_123.png
There are no real nested directories. The key is just a string. Providers may simulate folders by treating / characters in keys as path separators.
Example keys in one bucket:
| Key | What it looks like |
|---|---|
avatars/user_1.png | Avatar of user 1 |
avatars/user_2.png | Avatar of user 2 |
documents/invoices/2024-01.pdf | Invoice of January 2024 |
logs/2024/08/28/log.txt | Log file for specific date |
To your application, each of these is just an object identified by its key string.
How S3-Compatible Storage Differs from Local Files
File System vs Object Storage
Typical differences:
| Feature | Local File System | S3-Compatible Storage |
|---|---|---|
| Structure | Directories and files | Buckets and object keys |
| Operations | open, read, write, os.* | HTTP API (PUT, GET, DELETE, etc.) |
| Latency | Very low | Higher (network request) |
| Shared across servers | Hard without network filesystems | Easy, all servers talk to same endpoint |
| Durability & backups | Up to you | Handled by the provider |
| Scaling | You manage disks | Provider scales capacity & throughput |
In object storage you cannot:
- Append to a file.
- Modify bytes in the middle of a file.
Instead, you upload the entire object again. For logs and similar use cases you usually create new objects instead of appending.
Basic Operations with S3-Compatible Storage
You will usually use a client library instead of crafting HTTP requests by hand. In Python, the standard choice for S3 APIs is boto3.
Below we show general patterns. The exact code examples use AWS-style syntax, but you can connect to any S3-compatible endpoint by changing configuration.
Connecting to an S3-Compatible Endpoint
You usually need:
endpoint_url(domain of the service),- Access key ID,
- Secret access key,
- Region (sometimes optional for non-AWS providers),
- Bucket name.
Example configuration using environment variables:
export S3_ENDPOINT_URL="https://nyc3.digitaloceanspaces.com"
export S3_ACCESS_KEY_ID="your-access-key"
export S3_SECRET_ACCESS_KEY="your-secret-key"
export S3_REGION="nyc3"
export S3_BUCKET="my-app-uploads"Python client setup:
import os
import boto3
session = boto3.session.Session()
s3 = session.client(
service_name="s3",
endpoint_url=os.environ["S3_ENDPOINT_URL"],
aws_access_key_id=os.environ["S3_ACCESS_KEY_ID"],
aws_secret_access_key=os.environ["S3_SECRET_ACCESS_KEY"],
region_name=os.environ.get("S3_REGION"),
)
BUCKET_NAME = os.environ["S3_BUCKET"]Uploading an Object
Example: upload a user avatar.
def upload_avatar(user_id: int, file_bytes: bytes, content_type: str) -> str:
key = f"avatars/user_{user_id}.png"
s3.put_object(
Bucket=BUCKET_NAME,
Key=key,
Body=file_bytes,
ContentType=content_type,
)
return key
Here key is what you store in your database to remember where the avatar lives.
Example DB record:
| user_id | avatar_key |
|---|---|
| 1 | avatars/user_1.png |
| 2 | avatars/user_2.png |
Downloading an Object
To read the bytes back:
def download_avatar(key: str) -> bytes:
response = s3.get_object(Bucket=BUCKET_NAME, Key=key)
data = response["Body"].read()
return dataDeleting an Object
def delete_avatar(key: str) -> None:
s3.delete_object(Bucket=BUCKET_NAME, Key=key)Listing Objects
You can list all objects with a given prefix, which behaves like listing a folder.
def list_user_avatars() -> list[str]:
response = s3.list_objects_v2(
Bucket=BUCKET_NAME,
Prefix="avatars/",
)
contents = response.get("Contents", [])
return [obj["Key"] for obj in contents]If your bucket has:
avatars/user_1.pngavatars/user_2.pngdocuments/invoices/2024-01.pdf
The function will return only the two avatar keys.
Public URLs and Permissions
Controlling Who Can Access Objects
There are two main access patterns:
- Public objects
Anyone with the URL can download the file. You usually use this for public assets, for example product images. - Private objects
Only authenticated requests or presigned URLs can access the file. You use this for user-specific or sensitive data.
Security for S3-compatible storage has two layers:
- Bucket policies / ACLs decide defaults (public or private, who can read/write).
- Application logic decides which keys you create and who gets their URLs.
Public Objects
Some providers allow you to make an entire bucket or specific prefixes public. Then, each object can be accessed by a predictable URL.
Common patterns:
- Path-style:
https://<endpoint>/<bucket>/<key> - Virtual-hosted-style:
https://<bucket>.<endpoint>/<key>
Example:
- Endpoint:
https://files.example-cdn.com - Bucket:
my-app-uploads - Key:
avatars/user_1.png
Public URL might look like:
https://my-app-uploads.files.example-cdn.com/avatars/user_1.png
orhttps://files.example-cdn.com/my-app-uploads/avatars/user_1.png
Presigned URLs
Presigned URLs allow temporary access to a private object without sharing credentials. This is very useful in backend development.
Two common use cases:
- Your backend gives the client a presigned download URL for a file.
- Your backend gives the client a presigned upload URL to upload directly to storage.
Presigned Download URL Example
def create_presigned_download_url(key: str, expires_in: int = 3600) -> str:
url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": BUCKET_NAME, "Key": key},
ExpiresIn=expires_in,
)
return url
The client can use the returned url with a normal HTTP GET to download the file for the next 1 hour.
Presigned Upload URL Example
def create_presigned_upload_url(
key: str, content_type: str, expires_in: int = 3600
) -> dict:
url = s3.generate_presigned_url(
"put_object",
Params={
"Bucket": BUCKET_NAME,
"Key": key,
"ContentType": content_type,
},
ExpiresIn=expires_in,
)
return {"url": url, "key": key}
The client can then perform an HTTP PUT to that URL with the file body, and the object will be stored under key in your bucket.
Security rule:
Never trust the path or file names chosen by the client.
Your backend should generate the object key and control which prefixes the client can use.
Integrating S3-Compatible Storage with a Backend API
Typical Flow for File Uploads
A common pattern for modern backends:
- Client asks your API for a presigned upload URL.
- API:
- Generates a safe key,
- Creates a presigned upload URL,
- Returns URL and key to client.
- Client uploads the file directly to S3-compatible storage using the URL.
- Client notifies your API that upload is finished and sends the key.
- API stores the key in the database.
This pattern avoids sending large files through your application server, which:
- Reduces CPU and bandwidth usage,
- Makes scaling easier,
- Limits how long your endpoints stay busy.
Example JSON API Contract
| Step | Request from client | Response from backend |
|---|---|---|
| 1 | POST /files/avatar/upload-url (no file body) | { "upload_url": "...", "key": "avatars/user_123/abc123.png" } |
| 2 | PUT upload_url with file bytes | S3-compatible service returns 200 OK |
| 3 | POST /users/me/avatar with { "key": "avatars/user_123/abc123.png" } | Backend stores key for the user and returns updated user |
Serving Files
To serve files to users you have options:
- Direct S3 URLs:
You show either public object URLs or presigned URLs. - Proxy through backend (simple but less scalable):
Your backend downloads the file and streams it to the client.
Suitable for small projects or when you need strong access control but do not want presigned URLs yet.
Naming and Organizing Object Keys
Good Key Design
Key design affects:
- How easily you can find related files.
- How you clean up data when a user or resource is deleted.
- How you handle name conflicts.
Useful patterns:
- Prefix by resource type
avatars/user_123.pngproducts/53/images/main.jpgorders/2024/08/28/order_987.pdf- Include IDs and random parts
To avoid collisions and make guessing harder:
avatars/user_123/6f1a2b5c.pnguploads/2024/08/28/uuid-4c1e.../file.pdf
- Avoid user-controlled paths
Do not let clients send ../../secret or entire folder names.
Generate keys on the server.
Example Key Strategy
For a user uploads feature:
- Avatars:
avatars/{user_id}/{uuid4()}.png - Other files:
user-files/{user_id}/{date}/{uuid4()}_{original_filename}
Example resulting keys:
| Purpose | Key example |
|---|---|
| Avatar | avatars/42/3e7ad2ed-09cc-4e3e-8c38-d36f40c8cbd9.png |
| Document | user-files/42/2024-08-28/1a2b3c4d_invoice_march.pdf |
Local Development with MinIO
Why MinIO
Using real cloud storage during development can be:
- Slow.
- Costly.
- Hard to use offline.
MinIO is an S3-compatible storage server you can run locally, for example with Docker. Your application connects in the same way as with real S3, but to localhost.
Mini example docker-compose.yml:
version: "3.8"
services:
minio:
image: minio/minio
command: server /data
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin123
ports:
- "9000:9000" # API endpoint
- "9001:9001" # Web console
volumes:
- ./minio-data:/dataThen set environment variables for your backend:
export S3_ENDPOINT_URL="http://localhost:9000"
export S3_ACCESS_KEY_ID="minioadmin"
export S3_SECRET_ACCESS_KEY="minioadmin123"
export S3_REGION="us-east-1"
export S3_BUCKET="my-app-uploads"You create the bucket once using MinIO’s web console or a script, and your code stays mostly the same as with real S3.
Common Pitfalls and Best Practices
Common Pitfalls
| Pitfall | Problem | Better approach |
|---|---|---|
| Hardcoding bucket and endpoint in code | Hard to change per environment | Use environment variables and config files |
| Using user-provided filenames as keys | Name collisions, path tricks, information disclosure | Generate safe keys, store original name separately if needed |
| Returning internal object keys directly | Leaks bucket structure and provider info | Use public / CDN URLs or presigned URLs |
| Uploading via backend server for all files | High load, slow responses | Use presigned upload URLs for large files |
| Mixing public and private data in same path | Hard to write security policies | Use separate prefixes or even separate buckets |
Best Practices
Key best practices:
- Use environment variables for all S3-compatible configuration.
- Treat object keys as sensitive references, not as public identifiers.
- Prefer presigned URLs for protected downloads and direct client uploads.
- Design predictable key structures that are easy to clean up per user or resource.
Additional recommendations:
- Validate file type and size before generating upload URLs, or after upload completion.
- Consider a CDN in front of S3-compatible storage for faster global delivery.
- Regularly clean unused files, for example orphaned uploads that are never attached to a database record.
- Log storage operations for debugging and audit, especially deletions.
Summary
You have learned:
- What S3-compatible storage is and why it is useful for backends.
- How buckets and object keys work.
- How to upload, download, delete, and list objects with a client library.
- How to use public URLs and presigned URLs in a secure way.
- How to design good key naming schemes and integrate object storage into an API.
- How to use MinIO for local development with the same S3-style API.
With these concepts, you can now replace simple local file storage with scalable and robust S3-compatible storage in your backend applications.
Views: 7
KAHIBARO