---
title: S3 compatibility
path: compatibility
status: published
---

# S3 compatibility

No S3-compatible service implements the whole API. This page says what was
tested, what is missing, and where a feature is present but behaves differently
enough to matter.

Verified 2026-08-08 against the live `nlsouth-std` cluster through the public
endpoint, with a fixed workload per client.

## Clients

| Client | Version | Result |
|---|---|---|
| boto3 / botocore | 1.43.67 | ✅ all core operations |
| aws-cli v2 | 2.36.19 | ✅ `cp` both ways, `ls`, 12 MB multipart |
| rclone | 1.75.0 | ✅ `copy` both ways, `ls`, `check` |
| MinIO client (`mc`) | 2025-08-13 | ✅ `cp` both ways, `ls` |
| restic | 0.19.1 | ✅ `init`, `backup`, `check`, `restore` |
| Terraform S3 backend | 1.15.8 | ⚠️ state works, **locking does not** — see below |
| s3fs, Cyberduck, Veeam | — | not yet tested |

restic's `check` verifies repository integrity, so that is a real backup-target
result rather than a byte round trip.

## ⚠️ Terraform: `use_lockfile` gives you no locking

Terraform 1.10+ implements S3 state locking with a **conditional write** — a
`PUT` with `If-None-Match: *` that is supposed to fail if the lock object
already exists.

**Storey does not enforce write-side conditionals.** A conditional `PUT` against
an existing key succeeds and overwrites it. Read-side conditionals are honoured
correctly; it is specifically the write path.

This fails **silently**. `terraform apply` with `use_lockfile = true` completes
without error, so a team believes state is locked when nothing is enforcing it,
and two concurrent applies can both "acquire" the lock and corrupt state.

**Do not rely on `use_lockfile` with Storey.** Serialise applies in CI, or use a
separate lock backend. State storage itself works correctly.

We would rather tell you this than let you find it after a corrupted state file.

## Core operations — verified

| Capability | Notes |
|---|---|
| PUT / GET / HEAD / DELETE object | |
| Ranged GET | HTTP 206, byte range exact |
| Multipart upload | Initiate, part, complete, abort |
| `ListObjects` v1 and v2 | |
| Prefix and delimiter listing | `CommonPrefixes` correct |
| Batch delete | Up to 1000 keys |
| Server-side copy | Within one region |
| Presigned URLs (SigV4) | Fetched anonymously over HTTPS |
| Path-style addressing | |
| Virtual-host addressing | `<bucket>.api.<cluster>…`, wildcard certificate |
| CORS configuration | Rules round-trip |
| Static website hosting | Requires the website flag and a global alias |
| SSE-C | Genuinely encrypts — GET without the key is refused |

**`CreateBucket` over S3 is refused.** Buckets carry a region, a tier, quotas
and an entitlement check, none of which `CreateBucket` can express. Use the
console or the management API.

## Not available

| Feature | Notes |
|---|---|
| Object versioning | The most commonly assumed feature. It is not there. |
| Object Lock / WORM / legal hold | Not available |
| Bucket policies (JSON) | Use grants instead |
| Object and bucket tagging | Blocks tag-driven cost allocation |
| Event notifications | Not available |
| Replication configuration | By design — regions are independent |
| STS / temporary credentials | Use presigned URLs |
| Requester pays, transfer acceleration, archive classes | Out of scope |

Versioning and Object Lock together mean Storey is **not** a ransomware-resistant
backup target today. If that is your requirement, say so before building.

## Present but partial

| Feature | Behaviour |
|---|---|
| Lifecycle rules | Configuration is accepted and reads back intact. Whether expiry actually executes over time is **not yet verified** — do not depend on it deleting anything. |
| Bucket / object ACLs | Read-only. `GetBucketACL` returns a synthetic owner grant; setting ACLs is not supported. Tools that read ACLs work; tools that set them fail. |
| SSE-S3 / SSE-KMS | Not yet tested |
| SigV2 | Not tested — no modern client offers it |

## Differences from AWS S3 worth knowing

**Bucket names are tenant-scoped.** Yours do not collide with anyone else's, so
the usual globally-unique naming games are unnecessary.

**The region string includes the tier** — `nlsouth-std`, not `nlsouth`. It
identifies the cluster and is part of the SigV4 signature; getting it wrong
produces `SignatureDoesNotMatch`.

**No dots in bucket names.** A dot breaks TLS certificate matching for
virtual-host addressing.

**Website hosting does not open the S3 path.** Enabling it exposes objects on
the website hostname only; the API path stays authenticated. The two surfaces
cannot be confused for each other.
