S3-Compatible Object Storage: How It Works and How to Choose a Provider
"S3" names three separate things: a product, a storage model, and an API. This guide separates them — how buckets actually behave, what "S3-compatible" guarantees, where that compatibility runs out, and the axes worth comparing providers on.
"Just put it in S3" is the advice everyone gives the moment an app starts handling files, and it's usually the right advice. What makes it confusing is that the letter-and-number names three separate things at once: Amazon's product, an object storage model that has very little in common with a filesystem, and an HTTP API that a dozen other providers now implement. When a storage service calls itself "S3-compatible," only the third of those is a promise, and it's worth knowing exactly what that promise covers before you build on it.
This guide separates the three. The model comes first: what a bucket actually is, and how it behaves differently from the disk you're used to. Then the API, including the specific places where compatibility stops being a guarantee. The last part is the comparison itself, the axes worth checking before you commit data to a provider, most of which never appear on a pricing page. The money side has an article of its own, so the numbers here stay light.
What object storage actually is
There are three broad ways to keep bytes on someone else's hardware, and they differ in what you address rather than in how much they hold. Block storage hands you a raw device: numbered blocks, a filesystem laid on top, one machine attached at a time. That's what a database wants, because Postgres needs to write four kilobytes at a precise offset and know it landed. File storage hands you a directory tree over a network protocol like NFS, so several machines can share the same paths and the usual open-seek-write semantics still apply.
Object storage hands you neither. There's no device, no tree, and no filesystem underneath. What you get instead is a bucket holding a flat set of keys, addressed over an HTTP API: you PUT an object under a key, you GET it back, you DELETE it. The object is the whole unit. You don't open it, you don't seek inside it, and you don't hold a handle to it. What you get in exchange is a namespace that keeps growing without anyone tuning it, reachable from any machine that has credentials and a network route.
| Block | File | Object | |
|---|---|---|---|
| What you address | Numbered blocks on a device | Paths in a directory tree | Keys in a flat bucket |
| How you reach it | Attached to one machine | Network file protocol (NFS, SMB) | HTTP API from anywhere |
| Changing part of the data | Write blocks in place | Seek and write in place | Replace the whole object |
| Concurrent writers | One machine at a time | Shared, with locking | Last write wins, no locks |
| Fits | Database files, VM disks | Shared working directories | Uploads, media, backups, static assets |
The row that decides most architectures is the third one. Because an object is replaced rather than edited, object storage is a poor home for anything that mutates constantly in small pieces, which is why a database data directory belongs on a block device and never in a bucket. It's an excellent home for anything written once and read many times, which describes user uploads, images, video, build artifacts, exports, and backups.
How a bucket really behaves
Most surprises with object storage come from carrying filesystem instincts into it. Four behaviours account for nearly all of them.
The folders are a trick of the light
A key like `uploads/2026/photo.jpg` looks like a path, but it isn't one. It's a single flat string, and there is no directory object anywhere behind it. Clients fake the hierarchy at listing time: you ask for keys with a given prefix and pass `/` as a delimiter, and the API groups everything below the next slash into "common prefixes" that your file browser draws as folders. Nothing on the storage side knows those folders exist. So creating a deeply nested structure costs nothing, while "renaming a folder" means copying every object under the old prefix to a new key and deleting the originals, with no atomicity across the batch.
Objects are written whole
The S3 API has no append and no partial write. You cannot open an object, jump to byte 900,000, and patch it. Changing one byte means uploading the entire object again under the same key. Multipart upload looks like an exception and isn't: it splits a large transfer into parts for reliability and parallelism, then assembles them into one object when you complete it. In the S3 user guide at the time of writing, a single object can reach 5 TB, a single PUT tops out at 5 GB, and multipart is the recommended route somewhere above 100 MB. Design accordingly: append-to-a-logfile patterns become write-a-new-object-per-batch patterns.
Metadata rides along with the bytes
Every object carries HTTP headers you set at upload time: content type, cache control, content disposition, plus custom keys of your own. This is why a bucket can serve a website directly, and it's also the source of a classic first-day bug. Upload a PNG without a content type and the object gets a generic default, so the browser downloads it instead of rendering it. Whatever headers you set at upload are the headers the storage hands back when someone fetches that key, which makes them worth setting deliberately.
Listing is an API call, not `ls`
`ListObjectsV2` returns keys in pages, capped at 1,000 per response by default on AWS as of writing, in lexicographic order, with a continuation token for the next page. Listing a prefix that holds a million keys is a thousand round trips, and there is no "sort by date" or "find files bigger than 10 MB" in the protocol. The practical rule follows from that: keep the index in your database and the bytes in the bucket. A page that renders a user's uploads should query Postgres for rows and fetch objects by key, never walk the bucket to find out what's in it.
What "S3-compatible" actually means
Amazon launched S3 in 2006, and its HTTP interface became the shape everyone else built to. Today MinIO, Ceph's object gateway, Cloudflare R2, Backblaze B2, Wasabi, and Runsite all speak some version of it. "Compatible" refers to the wire protocol: REST over HTTP, buckets and keys, request signing with Signature Version 4, and the standard set of operations. In practice the operations that carry almost every application are a short list:
- `PutObject`, `GetObject`, `HeadObject`, `DeleteObject` — the whole read-write path for one key.
- `ListObjectsV2` — paginated listing with prefix and delimiter, the thing that draws your folders.
- `CreateMultipartUpload` and friends — large uploads split into parts, which the SDK usually handles for you.
- `CopyObject` — server-side copy, so moving data between keys doesn't travel through your app.
- Presigned URLs — a signed, time-limited link that lets a browser read or write one key without your credentials.
Because the protocol is the standard, the configuration surface for switching providers is tiny. An S3 client needs an endpoint, a region string, an access key, and a secret. Everything after that is the same code you already wrote:
import os
import boto3
# The endpoint is the only line that changes between providers.
s3 = boto3.client(
"s3",
endpoint_url=os.environ["S3_ENDPOINT"],
aws_access_key_id=os.environ["S3_ACCESS_KEY"],
aws_secret_access_key=os.environ["S3_SECRET_KEY"],
)
s3.upload_file("local.jpg", "my-bucket", "uploads/photo.jpg")One detail catches people on day one: addressing style. AWS prefers virtual-hosted-style URLs, where the bucket is a subdomain (`my-bucket.endpoint/key`), and has been steering new buckets away from the older path-style form (`endpoint/my-bucket/key`). Plenty of compatible providers default to path-style, or support both. Every SDK has a switch for it — `addressing_style` in boto3's client config, `forcePathStyle` in the JavaScript SDK — and setting it wrong produces a DNS or 404 error that looks nothing like a configuration problem.
Where compatibility frays
The core verbs above are safe to assume. Beyond them, S3 has grown twenty years of features, and a compatible provider implements the subset it chose to implement. This is the honest boundary of the compatibility claim, and it's where migrations go wrong. The features that vary most:
- Versioning and object lock. Keeping old versions of a key, and write-once retention for compliance, are common gaps.
- Lifecycle rules. Automatic expiry after N days, or transition to a colder tier, may be absent, partial, or configured somewhere other than the API.
- Storage classes. Infrequent-access and archive tiers are an AWS pricing construct as much as a technical one; many providers have a single class.
- Bucket policies and ACLs. Fine-grained, condition-based access rules are the least portable part of S3. Most compatible stores offer simpler public/private buckets and scoped keys instead.
- Event notifications. Firing a webhook or queue message on upload is widely supported and widely different.
- Server-side encryption options. Encryption at rest is near-universal; customer-managed keys and KMS integration are not.
- Replication and consistency. S3 has offered strong read-after-write consistency since December 2020. Compatible stores usually match it, but it's worth confirming rather than assuming, along with whatever cross-region replication they do or don't do.
None of that is a reason to distrust the compatibility claim. It's a reason to check it against your own workload rather than against the feature matrix in the abstract, and checking is an evening's work with a real bucket. Run these seven and you'll know:
- Upload a file large enough to trigger your SDK's automatic multipart threshold, then download it and compare checksums.
- Generate a presigned GET and a presigned PUT, confirm both work from outside your network, and confirm they stop working after the expiry.
- List a prefix holding a few thousand keys. Check that pagination and delimiter grouping behave, and time it.
- Set content type and cache control at upload, fetch the object over plain HTTP, and confirm the headers come back on the response.
- Exercise the specific features you depend on: versioning, lifecycle expiry, a CORS preflight from your real browser origin.
- Server-side copy an object, then delete several keys in one request, and check that the error codes your code branches on are the ones you get.
- Point your existing integration test suite at the endpoint and let it run.
If all seven pass, portability is real for your workload: your application code is the same code, and the provider is a configuration value rather than an architectural commitment. If one fails, you've found it now, on a test bucket, instead of during a migration weekend.
How do you choose an object storage provider?
With the model and the compatibility question settled, comparing providers comes down to a handful of axes. Most of them aren't on the pricing page, and the answers are usually a support ticket or a documentation search away.
| Axis | What to ask | Why it decides things |
|---|---|---|
| Billing shape | Which of storage, transfer, and requests are metered, and at what rate? | Two providers with the same per-GB storage price can differ by orders of magnitude on the invoice if one meters egress. The arithmetic is in S3 egress fees, and what the zero-egress providers charge instead is in S3 alternatives without egress fees. |
| Region and residency | Where do objects physically sit, and does anything replicate outside that region? | Decides your legal exposure and your latency. If you serve EU users, see where to store EU user data. |
| Durability | What redundancy stands behind the number, and across how many failure domains? | A durability figure without a described mechanism is marketing. The mechanism is the claim. |
| API surface | Which features beyond the core verbs are implemented? | The checklist above, answered before migration rather than during it. |
| Public delivery | Are public buckets CDN-backed, and can you control cache headers? | Determines whether serving assets straight from the bucket is viable or needs a CDN in front. |
| Access model | Presigned URLs, key scoping, rotation without downtime, CORS configuration? | This is how you let browsers touch storage without handing out credentials. |
| Limits | Maximum object size, multipart threshold, request rate caps? | Large media and high-throughput pipelines run into these first. |
| Exit | What does it cost, and how long does it take, to copy everything out? | The cost of leaving is part of the cost of arriving. |
Durability numbers are design targets, not promises
The famous "eleven nines" is AWS's stated design target for S3, not a service-level guarantee, and other providers quote figures built on their own assumptions. Treat any durability number as an invitation to ask what redundancy sits behind it, and keep an independent copy of anything you couldn't recreate.
Where object storage fits in your stack
The reason most teams arrive here at all is the container filesystem. On a modern platform, the disk your app writes to belongs to the container, and the container is replaced on every deploy. Files written at 10am are gone at 10:05 when you ship a fix. Run two instances and they don't even see each other's writes. Anything a user uploaded has to live somewhere that outlives a single container and is visible to all of them, which is the job a bucket exists to do.
The pattern that follows is a clean split. The row goes in the database: an id, the owner, the object key, the size, the content type, the timestamp, whatever your app needs to filter and sort on. The bytes go in the bucket under a key you generate on the server, ideally one that's stable and hard to guess. Your app queries Postgres to decide what exists and fetches from storage to serve it. The database is the index; the bucket is the payload.
Delivery splits the same way. Assets that are identical for everyone — images on a marketing page, a JavaScript bundle, a public download — live in a public bucket behind a CDN, cached at the edge and served without your app in the path. Files that belong to one user stay in a private bucket and get handed out as short-lived presigned URLs, so the browser talks to storage directly while your application keeps the credentials. Backups and archives are the third resident: write-once, read-rarely, exactly the shape object storage was built for, and the reason a scheduled job can automate PostgreSQL backups to S3 into a bucket you control.
How Runsite handles it
For a concrete reading on those axes, S3-compatible object storage on Runsite implements the standard API, so the AWS SDK, boto3, aws-cli, or any S3 client works with the endpoint and keys swapped and nothing else touched. Objects go up to 5 TB, with multipart recommended above 100 MB, and there's no cap on how much you store. Buckets are public or private; access keys carry read, write, delete, or admin scopes and rotate without downtime; presigned URLs and CORS configuration cover browser access. Public buckets are served through a global CDN with automatic cache headers, and the dashboard reports storage, transfer, and request counts.
On the billing axis, the shape is deliberately short: transfer isn't metered at all, in or out, so the only variable on the invoice is gigabytes stored, at €0.025 per GB per month after 5 GB free as of writing. That collapses the invoice to one predictable line, and the reasoning behind that pricing model is unpacked in S3 egress fees. On the residency axis, objects sit in a Frankfurt, Germany region, encrypted at rest with AES-256 and in transit with TLS, with no replication to non-EU regions and a signed GDPR Data Processing Agreement on every plan, which is the ground where to store EU user data covers in full.
The short version
Object storage is a different data model, not a cheaper disk: a flat set of keys, objects written whole, metadata carried with the bytes, and listing that costs an API call. "S3-compatible" is a real and useful guarantee about the wire protocol, which is what makes your application code portable, but it covers the core verbs rather than the long tail of versioning, lifecycle, policies, and events, so verify the features you actually depend on against a live bucket. After that, providers differ on billing shape, region, durability mechanism, API coverage, delivery, access model, limits, and the cost of leaving. If the model fits what you're building, the quickest way to check compatibility is to point your existing S3 client at a real bucket: 5 GB is free, which is enough to run every item on the checklist above.