Skip to content

S3 compatibility

Served by default on 127.0.0.1:9000 when the port is free; opt out with --no-s3; per-tenant signed access keys with SigV4 verification. The adapter speaks the S3 REST wire protocol through s3s, so official S3 SDKs and tools connect with an endpoint override and no Nimbus-specific client code. Anything not listed on this page should be assumed unsupported.

The S3 adapter is default-on but bounded. It implements object-level reads and writes, listing, and multipart upload against implicit, per-tenant buckets. It does not implement bucket lifecycle, object copying, batch delete, tagging, ACLs, or versioning. Object integrity covers CRC64NVME and Content-MD5 only. See Not implemented for the full exclusion list.

The listener is served by default. It binds 127.0.0.1:9000 when that port is free.

BehaviorRule
Default bind127.0.0.1:9000
Port busy, no --s3-portThe listener is skipped with a startup warning; the rest of the server starts
--s3-port <port> on a busy portHard startup error
--no-s3Disables the listener
--no-s3 with --s3-port or --s3-access-keyRejected as a conflicting configuration
FlagDefaultWhat it does
--no-s3listener onDisables the S3 listener.
--s3-port <port>9000 when freePins an explicit port. A busy explicit port is a hard error instead of a skip.
--s3-host <host>127.0.0.1Host interface. A non-loopback host requires --allow-network.
--s3-access-key KEY_ID:SECRET:TENANTgenerated keySigned access-key binding (repeatable). Also read from NIMBUS_S3_ACCESS_KEYS (comma-separated) when no flag is given.

The S3 adapter uses its own credential registry. It never reuses the DynamoDB or RESP KV credentials. With no bindings supplied, a generated wire-credential key is bound to the default tenant and persisted with the other generated wire credentials in the data directory. Supplying one or more bindings replaces the generated key entirely. A binding whose tenant begins with the reserved _nimbus prefix is rejected at startup. The listener refuses to start with an empty access-key registry.

Every request must be signed with AWS Signature Version 4. Signature verification, canonicalization, and REST/XML response shaping are handled by s3s.

PropertyBehavior
SignatureSigV4 verified against the registered secret for the request’s access key ID
Unsigned requestRejected with AccessDenied
Unknown access key IDRejected with InvalidAccessKeyId
Presigned URLsSigV4 query-parameter presigned GET URLs are accepted
RegionAny region string; it is part of the SigV4 credential scope and selects nothing
Access key → tenantEach access key ID resolves to exactly one tenant; the request operates only in that tenant’s object store

The listener serves plain HTTP; a non-loopback deployment needs TLS terminated in front of it.

OperationRequest surfaceResponse surfaceNotes
PutObjectbody, Content-Type, user metadata (x-amz-meta-*), Content-MD5, x-amz-checksum-crc64nvme, Content-Length, If-Match, If-None-MatchETag, x-amz-checksum-crc64nvme, sizeContent-MD5 and CRC64NVME are verified against the received bytes; Content-Length, if sent, must match. Preconditions are checked against the current object
GetObjectRange, If-Match, If-None-Match, If-Modified-Since, If-Unmodified-Sincebody, ETag, Content-Type, Last-Modified, user metadata, Accept-RangesA Range request returns 206 Partial Content with Content-Range. If-None-Match/If-Modified-Since yield 304 Not Modified; a failed If-Match/If-Unmodified-Since yields 412 Precondition Failed
HeadObjectRange, same preconditions as GetObjectheaders only: Content-Length, ETag, Content-Type, Last-Modified, user metadataRange reports the selected length and Content-Range
DeleteObjectbucket, keyemptySucceeds whether or not the key existed
ListObjectsV2prefix, delimiter, max-keys, continuation-token, start-afterContents, CommonPrefixes, KeyCount, IsTruncated, NextContinuationTokenmax-keys defaults to 1000 and is clamped to 1000. delimiter rolls up CommonPrefixes

Buckets are implicit. There is no bucket lifecycle operation: a PutObject writes an object under its bucket and key directly, and the bucket name is a namespace within the tenant.

OperationRequest surfaceResponse surfaceNotes
CreateMultipartUploadbucket, key, Content-Type, user metadataUploadId
UploadPartUploadId, PartNumber, body, Content-MD5, x-amz-checksum-crc64nvmeETag, x-amz-checksum-crc64nvmePartNumber must be 1–10000. Re-uploading a part number replaces it
CompleteMultipartUploadUploadId, ordered Part list with PartNumber and optional ETagETag, LocationParts must be strictly ascending; a supplied per-part ETag must match; completion requires at least one part
AbortMultipartUploadUploadIdemptyReleases the uploaded part bytes

The completed-object ETag is the standard multipart form: the MD5 of the concatenated part MD5s, suffixed with the part count.

ChecksumBehavior
Content-MD5Verified against the received bytes; a mismatch is rejected
CRC64NVME (x-amz-checksum-crc64nvme, header or trailer)Verified against the received bytes; returned on PutObject, UploadPart, GetObject, and HeadObject
CRC32, CRC32C, SHA1, SHA256Rejected — a request carrying one of these checksum headers, or x-amz-sdk-checksum-algorithm naming one, fails with an error naming the algorithm

Whole-object ETag values are the MD5 of the object bytes. Object bytes are stored in the tenant’s content-addressed blob plane; object metadata (bucket, key, size, ETag, checksums, content type, user metadata) is stored in the tenant’s metadata plane through the engine.

Each signed access key is bound to exactly one tenant. A request operates only within that tenant’s object namespace: buckets and keys created under one access key are not visible to a key bound to a different tenant. A tenant’s object store is created on first use — the first PutObject or CreateMultipartUpload ensures the tenant exists; a read against a tenant that was never created fails closed.

The same crate implements the Convex file-storage surface over the same byte and metadata planes. Convex exposes opaque _storage document ids rather than bucket and key names, backed by the reserved _storage bucket. Downloads are served from the /_nimbus/convex/storage/ path.

PropertyBehavior
Storage idsOpaque _storage-scoped document ids
Download path/_nimbus/convex/storage/{token}
Download tokenHMAC-SHA256 over a versioned payload of tenant, storage id, and expiry; verified with a constant-time comparison
Token expiryAn expired token is rejected; a missing object returns 404, an invalid or expired token returns 403
Export/importObjects export to, and import from, a zip archive with a _storage/documents.jsonl manifest

The download route is mounted only when the listener runs with the generated default wire credential — the CLI wires that generated secret as the Convex download-signing secret. Supplying explicit --s3-access-key or NIMBUS_S3_ACCESS_KEYS bindings does not configure a Convex download secret, and the download route is not mounted in that configuration.

The following S3 surfaces are not served. A request for one is rejected, not silently accepted.

  • Bucket lifecycle: CreateBucket, DeleteBucket, ListBuckets, HeadBucket.
  • ListObjects (v1) and batch DeleteObjects.
  • CopyObject and UploadPartCopy.
  • ListMultipartUploads and ListParts.
  • Object, bucket, and part tagging; ACLs; bucket policies.
  • Versioning, object lock, and lifecycle configuration.
  • GetObjectAttributes.
  • Checksum algorithms other than CRC64NVME and Content-MD5.