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:
| Provider | Endpoint | Region | URL style |
|---|---|---|---|
| Cloudflare R2 (recommended) | https://<ACCOUNT_ID>.r2.cloudflarestorage.com (EU jurisdiction: <ACCOUNT_ID>.eu.r2.cloudflarestorage.com) | auto | Virtual-hosted |
| Amazon S3 | Leave empty | The bucket's region, e.g. eu-central-1 | Virtual-hosted |
| Backblaze B2 | https://s3.<region>.backblazeb2.com | e.g. eu-central-003 | Virtual-hosted |
| Wasabi | https://s3.<region>.wasabisys.com | e.g. eu-central-1 | Virtual-hosted |
| DigitalOcean Spaces | https://<region>.digitaloceanspaces.com | e.g. fra1 | Virtual-hosted |
| Google Cloud Storage | https://storage.googleapis.com | auto | Path-style |
| MinIO | Your server's URL, e.g. https://minio.example.com | us-east-1 unless changed | Path-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
- In the Cloudflare dashboard, open R2 → Manage API tokens → Create API token.
- Under Permissions, choose Object Read & Write.
- Under Specify bucket(s), pick only your attachments bucket.
- Create the token and copy the Access Key ID and Secret Access Key. The secret is shown once.
- Your Account ID is on the R2 overview page. The endpoint is
https://<ACCOUNT_ID>.r2.cloudflarestorage.com, and the region isauto.
Amazon S3
- In IAM, create a user for poolse with no console access.
- 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/*"
}
]
}
- Under Security credentials, create an access key (use case: application running outside AWS) and copy both parts.
- 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": [ … ]}incors.jsonand 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_ORIGINto 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
- Open Storage in the dashboard sidebar.
- Pick your Provider. The endpoint, region and URL style are filled in; check them against your bucket.
- Enter the Bucket, the Key prefix (default
poolse/), the Access key ID and the Secret access key. - Optionally enter your Web app origin, such as
https://app.example.com, so the check also tests your CORS rules. - 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
| What | With your own bucket |
|---|---|
| Total storage | No limit from poolse |
| One file | 100 MB. Larger files are refused. |
| Upload URL lifetime | 15 minutes |
| Download URL lifetime | 1 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, nothttps://app.example.com/); PUTis in the allowed methods andcontent-typein 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.