Limitations
We publish this because you will find it anyway — ten minutes with any SDK is enough — and because a limitation you learn about on your first day is a design decision, while the same limitation on your first outage is a betrayal.
Each entry below is a single record. It renders here, on every integration page it affects, and in search. When one is fixed, it disappears from all of them at once.
Blockers
These stop a normal workflow. Read them before you plan a migration.
Conditional reads return 400 instead of 304 or 412
- You’ll see
- A GET carrying If-None-Match, If-Match or If-Modified-Since returns 400 InvalidRequest. Expected behaviour is 304 Not Modified for a cache hit and 412 PreconditionFailed for a stale precondition.
- Why
- The conditional headers are not handled correctly on the read path. This is a defect, not a design decision.
- Instead
- Do not send conditional headers on GET. If you are putting a CDN in front of a bucket, cache on a fixed TTL rather than on revalidation, and version your object keys so a new key means new content. Conditional *writes* (If-None-Match on PUT) are separately unsupported.
Applies to AWS SDK for JavaScript v3, Browser uploads.
Keys containing . or .. path segments are rejected
- You’ll see
- 403 SignatureDoesNotMatch for a key with a dot segment in it, such as `./relative/../path.bin`, while every other key in the same run works.
- Why
- The URL is normalised somewhere between signing and verification, so the path that gets verified is not the path that was signed. This is a defect.
- Instead
- Normalise keys before uploading — collapse `.` and `..` segments yourself. This matters most when keys are derived from filesystem paths during a migration; a bulk copy will otherwise fail on a small, unpredictable subset of objects.
Friction
These have a workaround, and the workaround is usually one line.
CORS is configured on the control plane, not with PutBucketCors
- You’ll see
- A browser upload fails at the preflight — the OPTIONS request is refused and the real request never runs. PutBucketCors returns 405 MethodNotAllowed.
- Why
- Bucket configuration is owned by the control plane. Each bucket is provisioned with CORS rules for the dashboard origin; your own origins have to be added there, not through the S3 API.
- Instead
- Add your origin in the dashboard's bucket settings, or with `PUT /v1/buckets/{bucket}/cors`. Read the current rules with `GET /v1/buckets/{bucket}/cors`. Do this *before* testing a browser upload — a missing rule looks exactly like a broken signature from the browser console.
Applies to Browser uploads.
Bucket-level configuration APIs are not offered
- You’ll see
- 405 MethodNotAllowed on PutBucketPolicy, PutBucketLifecycleConfiguration, PutBucketWebsite, PutBucketReplication, PutBucketNotificationConfiguration, PutBucketTagging, PutPublicAccessBlock and their delete counterparts; the matching Get calls return 501 or are refused upstream.
- Why
- These configure infrastructure behaviour that PopCloud manages itself. Access control is expressed through credential scopes and the bucket's public flag rather than through bucket policies.
- Instead
- Use credential scopes for access control (permissions plus a bucket allowlist, optionally with an expiry), the bucket's public flag for anonymous reads, and the control plane for CORS. Tools that reconcile bucket configuration — Terraform's `aws_s3_bucket` in particular — must be limited to object-level resources.
The control-plane API has no machine token
- You’ll see
- There is no API key to give a CI job or a Terraform provider that needs to create a bucket, mint a credential, or set CORS rules.
- Why
- The control plane authenticates people: an access token from `POST /v1/auth/login` plus a refresh cookie. Storage credentials (PCAK…) authenticate against the S3 endpoint only — they carry no control-plane authority.
- Instead
- For automation, log in from the job with a dedicated user's credentials, hold the access token for the run, and refresh if the run outlives it. Do not embed a human's long-lived session in shared CI. Bucket and credential creation is otherwise a one-time setup step you can do in the dashboard.
Buckets are not created over the S3 API
- You’ll see
- 405 MethodNotAllowed from CreateBucket or DeleteBucket — `aws s3 mb`, `mc mb`, or a tool provisioning its own bucket on first run.
- Why
- Bucket lifecycle belongs to the control plane, which also allocates the storage account, the data-plane key and the CORS rules that come with it. Letting the edge create buckets would create half of one.
- Instead
- Create buckets in the dashboard, or with `POST /v1/buckets` on the control-plane API. Everything object-level then works normally against that bucket.
Applies to AWS CLI, Django, boto3, rclone, AWS SDK for JavaScript v3, AWS SDK for Go v2, AWS SDK for Java v2, AWS SDK for PHP, AWS SDK for Ruby.
Browser POST-policy uploads are not supported
- You’ll see
- 403 AccessDenied when a browser submits a multipart/form-data POST to the bucket root, using a policy document and signature as form fields.
- Why
- The edge parses inbound authentication from the Authorization header and the query string. It never looks at form fields, so a POST policy carries no identity it can verify.
- Instead
- Use a presigned PUT — it is one URL, it supports Content-Type and size limits through the signature, and every modern uploader supports it. For large browser uploads use presigned multipart via `POST /v1/files/multipart` and `POST /v1/files/multipart/{upload_id}/sign`.
Applies to Browser uploads.
Public bucket names currently expose object listings
- You’ll see
- An anonymous ListObjectsV2 request to a public bucket returns its object keys, even though the caller was only given one object's URL.
- Why
- Public visibility currently bypasses authentication for every bucket GET and HEAD, including bucket-level listing requests as well as object reads.
- Instead
- Keep the bucket private when object names or the bucket inventory are sensitive. Use short-lived presigned GET URLs for private downloads. Use a public bucket only for intentionally public assets until anonymous listing is restricted to signed callers.
Per-chunk signed streaming uploads are refused
- You’ll see
- 501 NotImplemented on an upload whose x-amz-content-sha256 is STREAMING-AWS4-HMAC-SHA256-PAYLOAD.
- Why
- That encoding signs every chunk of the body separately, which a re-signing proxy cannot forward without buffering the whole object. The platform streams instead, and never holds a body in memory.
- Instead
- Nothing to do on a current SDK — the modern default is STREAMING-UNSIGNED-PAYLOAD-TRAILER, which works, checksum trailer and all. Only older SDK versions and explicitly-configured chunked signing hit this. If you do, set the payload signing option off (`AWS_S3_DISABLE_CHUNKED_ENCODING`, `chunkedEncodingEnabled=false`, or the equivalent) and the SDK falls back to a supported encoding.
Applies to AWS SDK for Java v2.
Worth knowing
Unlikely to affect you, cheap to know about.
The bucket listing omits some elements strict parsers expect
- You’ll see
- A tool fails to parse the bucket list, shows no owner, or sorts every bucket as if it were created just now.
- Why
- The bucket list is synthesized at the edge from the organisation's own buckets, rather than proxied. It currently ships without an XML namespace declaration or an Owner element, and stamps CreationDate with the time of the request.
- Instead
- Address buckets by name rather than discovering them from the listing, and do not rely on CreationDate. Every other listing operation — ListObjects, ListObjectsV2, ListObjectVersions, ListParts, ListMultipartUploads — is proxied and complete.
Applies to rclone.
Customer-managed encryption keys (SSE-C, SSE-KMS) are not supported
- You’ll see
- 400 InvalidArgument when a request carries x-amz-server-side-encryption-customer-* headers, or asks for aws:kms.
- Why
- There is no key-management service behind this endpoint, and customer-supplied keys are not accepted upstream.
- Instead
- Server-side encryption with a provider-managed key (AES256) works and can be set as a bucket default. If you need to hold the keys yourself, encrypt client-side before upload — the object is opaque bytes to the platform either way.
Object Lock, retention and legal hold are not available
- You’ll see
- GetObjectRetention and GetObjectLegalHold are refused; PutObjectLockConfiguration returns 405 MethodNotAllowed.
- Why
- Object Lock has to be enabled on a bucket at creation time upstream, and the platform does not offer it yet.
- Instead
- For backup tools that offer an immutability or object-lock mode, leave it off. Use a delete-restricted credential (permissions `read` and `write`, without `delete`) to get most of the protection: the backup agent can write new objects and read old ones, but cannot remove them.
Only the STANDARD storage class exists
- You’ll see
- 501 NotImplemented when a request sets x-amz-storage-class to GLACIER, STANDARD_IA, INTELLIGENT_TIERING or similar.
- Why
- Storage economics are expressed as a bucket's tier (hot or cool), chosen when the bucket is created, rather than per object.
- Instead
- Leave the storage class unset. If your tool sets one by default, point it at STANDARD. To store colder data, create a cool-tier bucket and write there. RestoreObject does not exist either, because nothing is ever archived.
Fixed
Kept for anyone who read this page before the fix landed.