Bring your own storage

By default poolse stores attachment files for you, within your plan's storage limit. You can instead connect your own S3-compatible bucket, on any plan, Free included. New attachments then go to your bucket:

  • poolse sets no storage limit; your provider bills you for what you store.
  • Files can be up to 100 MB each, whatever your plan.
  • The files sit in an account you control, with your provider's retention, region and backup options.

Nothing changes in your app. The SDK, the UI components and the API work the same way in both modes.

How it works

File bytes never pass through poolse, in either mode. When a client asks for an upload URL, poolse signs a short-lived URL for your bucket with the key you gave it, and the client uploads straight to the bucket. For a download, poolse signs a short-lived download URL the same way.

1. Client → poolse        POST /v1/attachments/upload-url
2. poolse → Client        a signed PUT URL for your bucket (valid 15 minutes)
3. Client → your bucket   PUT the bytes
4. Client → poolse        send the message with the attachment id

poolse does talk to your bucket itself for a few small requests: it reads a file's size when the message is sent (to check the declared size), and it deletes files when an attachment, its message or the workspace is deleted.

Your secret key is write-only. The dashboard encrypts it with your workspace's key before storing it and never shows it again.

Because browsers upload and download straight from the bucket, the bucket must allow your web app's origin in its CORS rules. Native iOS and Android apps don't need CORS.

Supported providers

Any service that speaks the S3 API and accepts SigV4 presigned URLs works. These have presets on the dashboard:

ProviderEndpointRegionURL style
Cloudflare R2 (recommended)https://<ACCOUNT_ID>.r2.cloudflarestorage.com (EU jurisdiction: <ACCOUNT_ID>.eu.r2.cloudflarestorage.com)autoVirtual-hosted
Amazon S3Leave emptyThe bucket's region, e.g. eu-central-1Virtual-hosted
Backblaze B2https://s3.<region>.backblazeb2.come.g. eu-central-003Virtual-hosted
Wasabihttps://s3.<region>.wasabisys.come.g. eu-central-1Virtual-hosted
DigitalOcean Spaceshttps://<region>.digitaloceanspaces.come.g. fra1Virtual-hosted
Google Cloud Storagehttps://storage.googleapis.comautoPath-style
MinIOYour server's URL, e.g. https://minio.example.comus-east-1 unless changedPath-style

R2 is recommended because it charges nothing for downloads, which is most of an attachment's traffic. Google Cloud Storage works through its S3-compatible XML API with HMAC keys. A MinIO server must be reachable from the internet over HTTPS, both by poolse and by your users.

1. Create a bucket

Create a private bucket. Don't make it public: every upload and download uses a signed URL, so public access isn't needed.

poolse writes under a key prefix, poolse/ by default, so the bucket can hold other files too. A dedicated bucket is simpler to reason about.

2. Create a key for that bucket only

Give poolse a key of its own that can only read, write and delete objects in this one bucket. It doesn't need to list buckets, change bucket settings or touch anything else.

Cloudflare R2

  1. In the Cloudflare dashboard, open R2 → Manage API tokens → Create API token.
  2. Under Permissions, choose Object Read & Write.
  3. Under Specify bucket(s), pick only your attachments bucket.
  4. Create the token and copy the Access Key ID and Secret Access Key. The secret is shown once.
  5. Your Account ID is on the R2 overview page. The endpoint is https://<ACCOUNT_ID>.r2.cloudflarestorage.com, and the region is auto.

Amazon S3

  1. In IAM, create a user for poolse with no console access.
  2. Attach an inline policy limited to the bucket and prefix:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::my-app-attachments/poolse/*"
    }
  ]
}
  1. Under Security credentials, create an access key (use case: application running outside AWS) and copy both parts.
  2. Leave the endpoint empty on the dashboard and enter the bucket's region, for example eu-central-1.

If the bucket uses SSE-KMS with your own KMS key, also allow kms:GenerateDataKey and kms:Decrypt on that key. The default SSE-S3 encryption needs nothing extra.

Backblaze B2

Under Application Keys → Add a New Application Key, allow access to this bucket only, with Read and Write. The keyID is the access key ID and the applicationKey is the secret. The region is the middle part of the bucket's S3 endpoint: s3.eu-central-003.backblazeb2.com is region eu-central-003.

Wasabi

Create a user with programmatic access only, attach a policy like the AWS one above (Wasabi uses the same format), and create an access key. Use the service URL for the bucket's region, such as https://s3.eu-central-1.wasabisys.com.

DigitalOcean Spaces

Under Spaces Object Storage → Access Keys → Create Access Key, choose Limited Access, pick only this bucket, with Read/Write/Delete. The endpoint is the regional one, such as https://fra1.digitaloceanspaces.com, without the bucket name in front.

Google Cloud Storage

Create a service account for poolse and grant it Storage Object User on this bucket only (not on the project). Then open Cloud Storage → Settings → Interoperability and create an HMAC key for that service account. Use endpoint https://storage.googleapis.com and region auto.

MinIO

Create a user or service account with a policy like the AWS one above, turn on path-style URLs on the dashboard, and use your server's public HTTPS URL as the endpoint.

3. Allow your web app (CORS)

A browser only uploads to another origin when the bucket allows it. Allow PUT, GET and HEAD from each origin your web app runs on, with the content-type request header, and expose ETag. List every origin: production, staging, and http://localhost:3000 while you develop.

The dashboard's Storage page fills your origin into these rules for you.

R2, S3, B2, Wasabi, Spaces and MinIO

These use the S3 rule format:

[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["PUT", "GET", "HEAD"],
    "AllowedHeaders": ["content-type"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]
  • R2: the bucket's Settings → CORS policy → Add CORS policy; paste the JSON.
  • S3: the bucket's Permissions → Cross-origin resource sharing (CORS) → Edit; paste the JSON.
  • B2 and Wasabi: set it over the S3 API. Wrap the rules as {"CORSRules": [ … ]} in cors.json and run:
aws s3api put-bucket-cors --bucket my-app-attachments \
  --cors-configuration file://cors.json \
  --endpoint-url https://s3.eu-central-003.backblazeb2.com

The B2 web UI's CORS options don't allow uploads, so use the command above rather than the UI.

  • Spaces: the Space's Settings → CORS Configurations → Add, with the same origin, methods and header.
  • MinIO: allows every origin by default. To limit it, set MINIO_API_CORS_ALLOW_ORIGIN to your origins on the server.

Google Cloud Storage

Google uses its own format. Save this as cors.json:

[
  {
    "origin": ["https://app.example.com"],
    "method": ["PUT", "GET", "HEAD"],
    "responseHeader": ["Content-Type", "ETag"],
    "maxAgeSeconds": 3600
  }
]

and apply it:

gcloud storage buckets update gs://my-app-attachments --cors-file=cors.json

4. Connect it on the dashboard

  1. Open Storage in the dashboard sidebar.
  2. Pick your Provider. The endpoint, region and URL style are filled in; check them against your bucket.
  3. Enter the Bucket, the Key prefix (default poolse/), the Access key ID and the Secret access key.
  4. Optionally enter your Web app origin, such as https://app.example.com, so the check also tests your CORS rules.
  5. Click Check and connect.

poolse checks the bucket before saving anything: it writes a small test object, reads its metadata, downloads it and deletes it, and, if you gave an origin, sends a CORS preflight for an upload from that origin. Each step is shown with ✓ or ✗ and your provider's answer. If any step fails, nothing is saved and uploads keep going where they went before.

Once connected, every new upload goes to your bucket. Use Test connection at any time to run the check again. Details of the page: Storage.

Existing attachments

Connecting a bucket doesn't move anything. Attachments uploaded before stay in poolse's hosted storage and keep working; they still count toward your plan's hosted storage. Only new uploads go to your bucket.

Disconnecting

Disconnect on the Storage page sends new uploads back to poolse's hosted storage, within your plan's limits.

Attachments already in your bucket stay where they are. poolse keeps the old settings, retired, to sign download URLs for them, so they keep working as long as the stored key still works. If you revoke the key or delete the bucket, those attachments can no longer be downloaded. Keep both for as long as you need the files.

To switch to a different bucket, edit the connection instead. Files in the old bucket stay readable the same way.

Deleting a workspace

Deleting your poolse workspace also deletes the attachment files poolse wrote to your bucket, under the key prefix. Other objects in the bucket are not touched. See Danger zone.

Limits

WhatWith your own bucket
Total storageNo limit from poolse
One file100 MB. Larger files are refused.
Upload URL lifetime15 minutes
Download URL lifetime1 hour

The plan quotas attachment_too_large and attachment_total_cap_exceeded don't apply to uploads to your own bucket. See Limits & quotas.

Troubleshooting

The browser console shows a CORS error

No 'Access-Control-Allow-Origin' header on the PUT or the download means the bucket's CORS rules don't cover this origin. Check that:

  • the origin is listed exactly, with scheme and port and no trailing slash (https://app.example.com, not https://app.example.com/);
  • PUT is in the allowed methods and content-type in the allowed headers;
  • you saved the rules on the right bucket.

Then run Test connection with your origin. Some providers take a minute to apply new rules.

403 SignatureDoesNotMatch

The request was signed for a different place than it was sent to. Usually:

  • Wrong region. S3, Wasabi and B2 sign with the bucket's real region; R2 and Google Cloud Storage use auto.
  • Wrong URL style. MinIO and some self-hosted services need path-style URLs.
  • The endpoint has the bucket name in it. Enter the endpoint without the bucket, such as https://fra1.digitaloceanspaces.com.
  • A mistyped secret. Edit the connection and paste the key again.

403 AccessDenied

The key works but isn't allowed to do this. Check it can read, write and delete objects in this bucket, under the key prefix if your policy limits one.

RequestTimeTooSkewed or expired URLs

Signed URLs carry a timestamp. If a device's clock is far off, the provider can refuse the request. Ask the user to set their clock automatically. For a self-hosted MinIO, check the server's clock (NTP).

Uploads still go to poolse

Check the Storage page: the banner says New attachments go to your bucket when a bucket is connected. Attachments uploaded before you connected it stay in hosted storage; only new uploads go to your bucket.

Bring your own storage · poolse docs