Object storage
Files on Cloudflare R2 — signed upload and download URLs, buckets with size and type rules, resumable multipart uploads, public files and resized images — in the same project as authentication.
Object storage is where your app keeps files — profile pictures, receipts, PDFs, whatever your users send. Files live on Cloudflare R2, under a prefix of your project's own. Your app reaches them through the Camplax storage API and signed, expiring URLs, so there are no storage keys to create or paste.
Storage lives in the same project as authentication, so a file can belong to a person without you wiring two services together.
How an upload works
Your app asks the project API for an upload URL, then the file goes straight to storage — it does not pass through your server:
POST /v1/projects/<slug>/storage/upload-url → { url, key, method: "PUT", expiresIn: 900 }
PUT <the returned url> → the file itself
The request body declares what you intend to receive — filename, contentType, size in bytes, and an optional prefix (default uploads/). The response's url is where the client PUTs the file, key is the storage key it lands at, and expiresIn is how many seconds the URL stays valid.
The URL is bound to what you declared, and the binding is enforced at the sink:
- It expires after 900 seconds and works once — a successful PUT burns the token.
- A declared
sizeis enforced byte-for-byte; a different length is rejected. - A declared
contentTypeis enforced against the request and, for images and PDFs (JPEG, PNG, GIF, WebP, PDF), checked against the file's real bytes — not just the extension the caller claimed. - Files larger than the folder's or bucket's limit are refused before the URL is even issued, and a bucket that only takes certain types refuses the rest.
- One request carries up to 95 MB. Bigger files use a resumable upload.
So a leaked upload URL expires fast, and a renamed executable cannot pose as a photo.
The storage endpoints
Everything lives under /v1/projects/<slug>/storage, with your console session or a project API key:
| Method | Path | Scope | What it does |
|---|---|---|---|
GET | /storage/stats | storage:read | File count, total bytes, and the project prefix |
GET | /storage/files?prefix=… | storage:read | List keys under a prefix — up to 200 per call, with a truncated flag |
POST | /storage/upload | storage:write | Form upload (file field plus a path) through the API — for server-side files up to 40 MB |
POST | /storage/upload-url | storage:write | Mint the signed PUT URL described above |
POST | /storage/multipart | storage:write | Start a resumable upload |
DELETE | /storage/files/<path> | storage:write | Delete one object |
GET, POST, PATCH, DELETE | /storage/buckets | read / write | Buckets and their rules |
Files are also listed, uploaded and deleted from the Storage page in the console, which uses these same routes.
Where files live
Every file lands under your project's prefix on the object store — R2_PREFIX is injected into your app's environment at deploy time, so server code can always tell where its files are. Keys returned by the API (uploads/photo.png, say) are relative to that prefix.
Separate storage per environment
Previews write to their own storage. A test upload on a branch cannot appear in your live app, and the preview's files are discarded with the branch.
The app's own key decides which storage it reaches — not anything the request says. The CAMPLAX_PROJECT_TOKEN a preview runs with only ever reaches that preview's files; it cannot list, read, overwrite or delete a production file, even by naming the exact path or another environment.
camplax dev works the same way. Storage calls from your laptop land in a sandbox that belongs to you on this project — not production, and not a teammate's sandbox — so uploads just work locally with nothing to configure. Pass --use-production-services when you deliberately want your local app on live production storage.
Buckets
A bucket is a top-level folder with rules. avatars is everything under avatars/. Each bucket can say three things:
- Public or private. A public bucket serves every file in it at a stable URL, like a public folder.
- Largest file. Up to 5 GB. Without one, the project's upload limit applies (5 MB unless you changed it).
- Allowed types. A list like
image/*, application/pdf. Anything else is refused, and uploads must say their type.
POST /v1/projects/<slug>/storage/buckets { "name": "avatars", "public": true, "maxBytes": 5242880, "allowedMimeTypes": ["image/*"] }
PATCH /v1/projects/<slug>/storage/buckets/avatars { "public": false }
With the SDK: cx.storage.buckets.create("avatars", { public: true, maxBytes: 5 * 1024 * 1024, allowedMimeTypes: ["image/*"] }). In the console, Storage → Buckets has the same controls.
- The rules are checked by the API on every upload — signed upload URLs, resumable uploads, form uploads, the dropzone widget and the MCP tool. A browser cannot talk its way past them.
- Buckets are per environment. A preview with no bucket of its own follows production's size and type rules, but is never public because production is.
camplax devfollows production's rules too. - Removing a bucket removes its rules, not its files.
- Names are 2–63 lowercase letters, numbers and dashes. Folders Camplax writes to itself (
exports,backups,artifacts,src,screenshots,previews,email) can never be a bucket, and the storage API never writes to or deletes from them.
Large files
Files over 95 MB — and anything you want to survive a dropped connection — go up in parts. Start the upload, send the parts, then complete it:
POST /v1/projects/<slug>/storage/multipart { "path": "videos/talk.mp4", "size": 734003200, "contentType": "video/mp4" }
→ { "url": "https://camplax.dev/v1/uploads/multipart/mpu_…", "partSize": 8388608, "partCount": 88 }
PUT <url>/parts/1 … <url>/parts/88 each part, exactly partSize bytes (the last one is the remainder)
GET <url> which parts have landed — to resume
POST <url>/complete stitch them into one file
POST <url>/abort cancel and discard the parts
The SDK does all of it: cx.storage.upload(file, "videos/talk.mp4") switches to parts at 32 MB, sends three at a time, retries a failed part, and reports progress. uploadLarge gives you the upload url in onStart; keep it, and resumeUpload(url, file) finishes the job after a crash — from any machine, with no key, because the URL is the permission.
- The size, type and bucket rules are fixed when the upload starts. Every part must be exactly the size planned for it, and the first part is checked against the file's real type.
- The URL works for 24 hours. It stops at once if the key that started it is revoked, the person loses access to the project, or the project is deleted.
- Unfinished uploads are cancelled after 24 hours, so a half-done upload never keeps using storage.
- A project can have 20 uploads in progress. Parts are limited to 300 a minute and 20,000 a day per project.
Resized images
Add ?width= to any image URL — public or signed — and you get a resized copy:
/v1/public/<slug>/production/avatars/ada.png?width=256&format=auto
/v1/files/<token>?width=640&height=360&fit=cover
With the SDK: cx.storage.publicUrl("avatars/ada.png", { transform: { width: 256 } }), or the same transform option on url().
| Parameter | Values |
|---|---|
width, height | 1–2560 pixels. Sizes round up to a fixed set (16, 32, 48, 64, 96, 128, 192, 256, 384, 512, 640, 768, 1024, 1280, 1536, 1920, 2560); with both, the height follows the width so the shape holds. |
fit | scale-down (default), contain, cover, crop, pad |
format | auto (default — AVIF or WebP when the browser takes it), webp, avif, jpeg, png |
quality | 1–100, rounded to 50, 75 or 90 |
- PNG, JPEG, GIF, WebP and AVIF up to 20 MB can be resized. SVG is never resized and still downloads rather than opening.
- Resized copies are cached, and a new upload of the same file gets fresh copies.
- Each project can make 100 new sizes a day and 3,000 a month (and one visitor 25 new ones a day). A size already made this month never counts again — it keeps working for every visitor even after the day's new sizes are used up. Past a limit, or if resizing fails, you get the original image — the page never shows a broken image. The
X-Camplax-Imageheader says which you got. - A value out of range is an error (
400), so a typo never quietly ships a full-size image.
Private by default
Every file starts private. Upload URLs are signed and expire, and so do download links: GET /storage/files/url?path=… mints one that works for 15 minutes. A download link follows the same rule as a public file: images, video, audio, fonts and plain text open in the browser, and everything else downloads.
Public files
Avatars and product images need a link that never expires. Publish one file, or a whole folder by ending the path in /, and it is served at a stable URL with no signature:
POST /v1/projects/<slug>/storage/public { "path": "avatars/" }
GET /v1/public/<slug>/production/avatars/ada.png
With the SDK: cx.storage.makePublic("avatars/"), cx.storage.publicUrl("avatars/ada.png"), and cx.storage.makePrivate("avatars/") to undo it. In the console, Storage has a Make public button on every file and folder.
- Nothing is public until you publish it — a file, a folder, or a public bucket — and the whole project's storage can never be published in one go.
- Publishing is per environment. A preview publishes its own files at
/v1/public/<slug>/<preview-env-id>/…; making a preview folder public says nothing about production. - Images, video, audio, fonts and plain text open in the browser. Everything else, including PDFs and anything that could run code (HTML, SVG and similar), downloads instead.
- Folders Camplax writes to itself (
exports/,backups/,artifacts/,src/,screenshots/,previews/,email/) can never be made public. - Making a path private again takes effect at once. Browsers may keep a copy for up to five minutes.
| Method | Path | Scope | What it does |
|---|---|---|---|
GET | /storage/public | storage:read | The published paths for this environment |
POST | /storage/public | storage:write | Publish { path } — a file, or a folder ending in / |
DELETE | /storage/public/<id> | storage:write | Make that path private again |
Limits
Your plan includes 10 GB of files on Indie, 50 GB on Pro and 200 GB on Scale, pooled across all your apps. Usage in the console shows how much each app stores: the number moves as files are uploaded and deleted, and is checked against storage itself once a day. Camplax's own folders — builds, database exports and backups, deploy screenshots and inbound mail — do not count toward it.
Under the hood: object storage on Cloudflare R2 — each environment and preview branch gets its own key prefix, and a bucket is a folder of that prefix with a policy row. Large files use R2 multipart uploads; images are resized by the Cloudflare Images binding.