Railway Buckets
Store files in Railway Buckets from Convex, with presigned browser uploads, access checks, and file metadata kept in Convex.
git clone https://github.com/leoisadev1/convex-railway-buckets.git
cd convex-railway-bucketsThis upstream package has not been adapted or qualified for Bijection. Read the original documentation before adopting it.
Description
Store files in Railway Buckets from Convex, with presigned browser uploads, access checks, and file metadata kept in Convex.
Explore the package README for API concepts, configuration, and examples. Check the Bijection source for adaptation-specific setup.
Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.
Getting started
- Get the source at the revision above and read its package documentation.
- Check the required Bijection SDK version, dependencies, and external service credentials.
- Follow the package setup instructions and the guide to using components.
- Exercise the component in your deployment according to your project’s verification rules.
Open source
Maintained source: leoisadev1/convex-railway-buckets. License: See repository. Consult the repository for license terms, notices, and contributor attribution.
From the upstream publisher
Store files in Railway Buckets from Convex: presigned browser uploads, metadata in Convex, durable deletes.
Explore the demo ↗Upstream overview
From the upstream package documentation.
Railway Buckets is a Convex component that integrates Railway's S3-compatible object storage with your Convex backend. Browsers upload files directly to the bucket via presigned PUT URLs signed with a built-in SigV4 implementation, while Convex stores and queries file metadata reactively. Upload policies, access checks, and post-upload hooks all run inside Convex transactions, keeping your file records consistent with the rest of your data.
Use cases
From the upstream package documentation.
- Adding user file uploads (images, documents, CSVs) to a Convex app without routing file bytes through your backend functions
- Enforcing per-upload size and content type limits at the storage layer, with Railway rejecting any non-conforming PUT request
- Gating file reads behind auth checks using presigned GET URLs or a stable HTTP redirect route for use in img src tags
- Generating and storing server-side files (reports, exports) from Convex actions and writing them directly to the bucket with buckets.store()
- Running multiple isolated buckets in one Convex deployment by installing the component multiple times under different names
How it works
From the upstream package documentation.
Setup involves installing the component, running the provided CLI command to pipe Railway bucket credentials into Convex environment variables, and declaring those env vars in convex.config.ts with app.use(railwayBuckets, { env: { ... } }). Convex validates that all three required vars (BUCKET, ACCESS_KEY_ID, SECRET_ACCESS_KEY) are present at deploy time, so missing credentials surface at npx convex dev rather than at runtime.
The upload flow is a three-step protocol: the client calls generateUploadUrl to get a presigned PUT URL and required headers, uploads directly to Railway, then calls verifyUpload (which HEADs the object) and completeUpload (which records the file and runs the onUpload hook in a single transaction). Each URL is signed for one exact size and content type, includes a random upload ID in metadata, and uses a conditional write so replaying the URL cannot overwrite an existing object.
File metadata lives in Convex tables, so listFiles and getFile return paginated, reactive results alongside presigned GET URLs. Railway buckets are private, so all reads go through signed URLs (default 1-hour expiry) or through a stable HTTP route registered with buckets.registerRoutes(http, { pathPrefix }) that issues a fresh 302 redirect per request. Deletions are durable: the file record is removed immediately and the actual object deletion is retried in the background until Railway confirms it.
The component ships a useUploadFile React hook and a framework-agnostic uploadFile function from convex-railway-buckets/browser for use with Svelte, Vue, or plain JS. Testing is supported via convex-test by importing railwayBuckets from convex-railway-buckets/test and calling railwayBuckets.register(t).
Upstream package FAQ
Does convex-railway-buckets require the AWS SDK?
No. convex-railway-buckets includes a minimal SigV4 signer built on Web Crypto. It runs in Convex queries, mutations, actions, and HTTP actions without any AWS SDK dependency.
How does convex-railway-buckets prevent unauthorized uploads?
Each presigned upload URL is signed for one exact file size and content type, and only succeeds if no object exists at the key yet. The URL also embeds a random upload ID in x-amz-meta-upload-id. Only the identity that requested the URL can call verifyUpload and completeUpload, and each step verifies the caller matches the original requester.
What happens if a browser upload is abandoned before completion?
convex-railway-buckets tracks pending uploads and, after the URL lifetime plus a completeWithin window (6 hours by default), automatically deletes any partially uploaded object and removes the pending record. The delete targets only the object carrying the specific upload ID, so it cannot affect unrelated objects at the same key.
Can convex-railway-buckets be used outside of React?
Yes. The convex-railway-buckets/browser export provides an uploadFile function that accepts a ConvexClient, ConvexReactClient, or ConvexHttpClient, with no React dependency. This makes it usable with Svelte, Vue, or plain JavaScript.
What version of Convex does convex-railway-buckets require?
convex-railway-buckets requires convex 1.39 or newer. This minimum version is required for component environment variable support, which is how Railway Bucket credentials are securely bound to the component without appearing in function arguments, scheduled jobs, or database tables.
Use with agents and CLI
Give your coding agent the component’s source identity and setup guidance.
Help me integrate Railway Buckets with Bijection.
Read: https://bijection.com/components/convex-railway-buckets/SKILL.md
Check SDK compatibility and dependency availability.
Follow this project's execution and verification rules.Upstream README.md
View repository ↗Documentation from leoisadev1/convex-railway-buckets, recorded October 23, 2026. APIs and setup may differ in the Bijection adaptation.
Railway Buckets for Convex
A Convex component that stores files in Railway Buckets. Browsers upload straight to the bucket through presigned URLs, and Convex keeps the metadata, so queries over your files are reactive and paginated.
Railway Buckets cost $0.015 per GB-month, with free API calls and free egress from the bucket.
// React: upload a file, get back its metadata once it is stored and verified.
const upload = useUploadFile(api.files);
const file = await upload(input.files[0], { onProgress: setProgress });
What you get:
- Direct browser uploads with progress, no file bytes passing through Convex.
- Limits the bucket enforces. Each upload URL is signed for one exact size and content type, and only writes if nothing is at the key yet. Railway rejects any other body, and a replayed URL can't overwrite the file.
- Uploads tied to the uploader. Only the identity that asked for an upload URL can confirm the upload.
- An
onUploadhook that runs in the same transaction that records the file, so linking a file to your own tables is atomic. - Durable cleanup. Abandoned uploads get deleted, and deletes retry until the bucket confirms them.
- No AWS SDK. A small SigV4 signer on Web Crypto runs in queries, mutations, actions, and HTTP actions.
- Credentials stay in the component. They are bound as component env vars and never travel in function arguments, scheduled jobs, or tables.
Requires convex 1.39 or newer, for component env vars.
Quick start
You'll end up with a React page that uploads files to your bucket and lists them. The example app is the finished version.
1. Install
npm install convex-railway-buckets
2. Create a bucket and give Convex its credentials
Create a bucket in the Railway dashboard, or with the CLI:
railway bucket create my-uploads -r sjc
Pipe its credentials into the setup command. It sets three env vars on your Convex deployment, passing values over stdin so the secret key never shows up in your shell history or process list:
railway bucket credentials -b my-uploads --json | npx convex-railway-buckets env
Add --prod (or any other convex env set flag) to target another deployment.
Browsers need CORS to upload to the bucket. Allow every origin your app runs on. The command replaces the previous policy, so list them all each time:
railway bucket credentials -b my-uploads --json \
| npx convex-railway-buckets cors --origin http://localhost:5173 --origin https://myapp.com
3. Install the component
Declare the env vars in convex/convex.config.ts and bind them to the
component:
// convex/convex.config.ts
import { defineApp } from "convex/server";
import { v } from "convex/values";
import railwayBuckets from "convex-railway-buckets/convex.config.js";
const app = defineApp({
env: {
RAILWAY_BUCKET: v.string(),
RAILWAY_BUCKET_ACCESS_KEY_ID: v.string(),
RAILWAY_BUCKET_SECRET_ACCESS_KEY: v.string(),
},
});
app.use(railwayBuckets, {
env: {
BUCKET: app.env.RAILWAY_BUCKET,
ACCESS_KEY_ID: app.env.RAILWAY_BUCKET_ACCESS_KEY_ID,
SECRET_ACCESS_KEY: app.env.RAILWAY_BUCKET_SECRET_ACCESS_KEY,
},
});
export default app;
Convex refuses to deploy if any of the three env vars is missing, so a typo
shows up at npx convex dev instead of at the first upload.
4. Expose upload functions and call them from React
// convex/files.ts
import { RailwayBuckets } from "convex-railway-buckets";
import { components } from "./_generated/api";
import type { DataModel } from "./_generated/dataModel";
export const buckets = new RailwayBuckets(components.railwayBuckets, {
upload: {
maxSize: 10 * 1024 * 1024,
allowedContentTypes: ["image/*"],
keyPrefix: "uploads/",
},
});
export const { generateUploadUrl, verifyUpload, completeUpload, cancelUpload } =
buckets.clientApi<DataModel>({
checkUpload: async (ctx) => {
if (!(await ctx.auth.getUserIdentity())) throw new Error("Sign in first");
},
onUpload: async (ctx, file) => {
await ctx.db.insert("photos", { key: file.key, owner: file.owner });
},
});
// src/Upload.tsx
import { useUploadFile } from "convex-railway-buckets/react";
import { api } from "../convex/_generated/api";
export function Upload() {
const upload = useUploadFile(api.files);
return (
<input
type="file"
onChange={async (event) => {
const file = event.target.files?.[0];
if (file) console.log("stored", await upload(file));
}}
/>
);
}
Choose an image. The promise resolves once the file is in the bucket, verified,
and recorded, and by then onUpload has inserted your photos row.
How an upload works
browser your Convex functions Railway
│ generateUploadUrl({size, type}) ─▶ checkUpload, policy
│ component signs a PUT for
│ ◀──────── { key, url, headers } exactly size + type, once
│ PUT url (headers) ──────────────────────────────────────────▶ 200
│ verifyUpload({key}) ─────────────▶ HEAD the object ──────────▶ 200
│ completeUpload({key}) ───────────▶ files row + onUpload,
│ ◀──────────────────── file one transaction
Each step checks that the caller is the same owner who requested the URL. The
owner defaults to the caller's tokenIdentifier, or null when signed out.
Override it with getOwner.
If the client never finishes, the upload expires: after the URL's lifetime plus
completeWithin (6 hours by default), the component drops it and deletes
anything that was uploaded. completeUpload is safe to retry. A second call by
the same owner returns the same file and doesn't run onUpload again.
Every upload URL also signs a random x-amz-meta-upload-id. Cleanup only
deletes an object carrying the ID of the upload being cleaned up, pinned to that
exact version with If-Match, so an abandoned upload can never take out an
unrelated object that happens to share its key.
Guides
Show files
getFile and listFiles return metadata plus a presigned GET URL. Railway
buckets are always private, so every read goes through a URL like this.
export const { getFile, listFiles } = buckets.clientApi<DataModel>({
checkRead: async (ctx, file) => {
const identity = await ctx.auth.getUserIdentity();
if (file.owner !== identity?.tokenIdentifier) throw new Error("Not yours");
},
checkList: async (ctx) => {
if (!(await ctx.auth.getUserIdentity())) throw new Error("Sign in first");
},
});
From your own queries, call buckets.getFile(ctx, key),
buckets.listFiles(ctx, { paginationOpts, prefix }), or
buckets.getUrl(ctx, key, options). listFiles returns the standard pagination
shape. Use usePaginatedQuery from convex-helpers/react on the client,
since the component paginates with the convex-helpers paginator.
URLs last an hour by default (urlExpiresIn option, up to 7 days). A query's
result is cached and pushed to subscribers until its data changes, so a page
left open longer than the expiry can end up holding dead URLs. For long-lived
pages, raise the expiry or use the stable route below. In actions,
buckets.getFreshUrl(ctx, key) always signs a new URL.
To make the browser download instead of display, pass
{ disposition: "attachment" }. The download uses the original filename.
Serve files at stable URLs
registerRoutes adds an HTTP route that redirects to a fresh presigned URL, so
an <img src> never expires:
// convex/http.ts
import { httpRouter } from "convex/server";
import { buckets } from "./files";
const http = httpRouter();
buckets.registerRoutes(http, {
pathPrefix: "/files/",
authorize: async (ctx, request, file) => true,
});
export default http;
GET https://<deployment>.convex.site/files/<key> now responds with a 302 to a
URL signed for that request (never a cached one). Browsers don't send Convex
auth tokens on <img> requests, so authorize can only check what's in the
request itself, such as a cookie or a signed query parameter. () => true means
anyone who knows the key can read the file. Generated keys are random UUIDs.
Delete files
export const { deleteFile } = buckets.clientApi<DataModel>({
checkDelete: async (ctx, key, file) => {
/* throw to refuse */
},
onDelete: async (ctx, key, file) => {
/* remove your rows for this key */
},
});
Or call buckets.deleteFile(ctx, key) from any mutation or action. Convex
forgets the file right away, and the component deletes the object in the
background, retrying with backoff (capped at one hour) until Railway confirms.
The key stays reserved while the delete is pending, so a new file can't land on
it and get hit by it. For browser uploads the reservation lasts until the
original upload URL's deadline (URL lifetime plus completeWithin), when the
component checks the key one last time. That catches a client that replays its
old URL after the delete. Generated keys are random, so this only matters if you
reuse custom keys. Files stored with store() skip the wait because their URL
never leaves your server.
Store files from the server
export const saveReport = action({
args: {},
handler: async (ctx) => {
const csv = await buildCsv();
const file = await buckets.store(ctx, csv, {
contentType: "text/csv",
filename: "report.csv",
keyPrefix: "reports/",
});
return file.key;
},
});
store accepts a Blob, ArrayBuffer, Uint8Array, or string. It skips the
upload policy (server code is trusted) and refuses to overwrite: if key is
already in use, even by an object Convex doesn't know about, it throws
KEY_IN_USE and leaves the existing object alone. To read bytes back in an
action, use buckets.download(ctx, key), which returns a Response.
Adopt objects written elsewhere
If another Railway service writes to the bucket, record its objects with
buckets.syncFile(ctx, key) from an action. It HEADs the object and upserts the
record. When the object is gone, it removes the record and returns null.
Upload without React
convex-railway-buckets/browser exports uploadFile, which takes any
ConvexClient, ConvexReactClient, or ConvexHttpClient. Use it with Svelte,
Vue, or plain JS. It has no React dependency:
import { uploadFile } from "convex-railway-buckets/browser";
const file = await uploadFile(client, api.files, blob, {
onProgress: ({ loaded, total }) => console.log(loaded / total),
signal: abortController.signal,
});
Aborting or failing the PUT calls cancelUpload when you've exported it.
Test with convex-test
import { convexTest } from "convex-test";
import railwayBuckets from "convex-railway-buckets/test";
const t = convexTest(schema, modules);
railwayBuckets.register(t);
Bucket requests still go through fetch, so stub it in tests that upload,
verify, or delete.
Use more than one bucket
Install the component once per bucket under different names, and bind each instance's env vars:
app.use(railwayBuckets, {
name: "avatars",
env: { BUCKET: app.env.AVATARS_BUCKET /* ... */ },
});
app.use(railwayBuckets, {
name: "exports",
env: { BUCKET: app.env.EXPORTS_BUCKET /* ... */ },
});
Then create new RailwayBuckets(components.avatars) and
new RailwayBuckets(components.exports).
Reference
Component env vars
| Name | Required | Default | Notes |
|---|---|---|---|
BUCKET | yes | The S3 bucket name (bucketName in Railway's JSON), not the display name. | |
ACCESS_KEY_ID | yes | ||
SECRET_ACCESS_KEY | yes | ||
ENDPOINT | no | https://t3.storageapi.dev | |
REGION | no | auto | |
URL_STYLE | no | virtual-host | Set path for older buckets whose Credentials tab says so. The env command tells you when. |
new RailwayBuckets(component, options?)
| Option | Default | Meaning |
|---|---|---|
upload.maxSize | none | Largest upload in bytes, enforced by the bucket. Single uploads are capped at 5 GiB regardless. |
upload.allowedContentTypes | any | Exact types or wildcards like image/*. |
upload.keyPrefix | "" | Prefix for generated keys. |
upload.urlExpiresIn | 900 | Seconds an upload URL works. |
upload.completeWithin | 21600 | Seconds after that during which the upload can still be completed. |
urlExpiresIn | 3600 | Default lifetime of read URLs, in seconds. |
Methods (ctx is any context that can run the needed function type):
| Method | Context | Returns |
|---|---|---|
getUrl(ctx, key, { expiresIn?, disposition?, filename?, contentType? }) | query+ | string |
getFreshUrl(ctx, key, urlOptions?) | action | string, signed on every call |
getFile(ctx, key, urlOptions?) | query+ | file with url, or null |
getMetadata(ctx, key) | query+ | file, or null |
listFiles(ctx, { paginationOpts, prefix?, url? }) | query+ | pagination result of files with url |
generateUploadUrl(ctx, { size, contentType, filename?, owner? }) | mutation+ | { key, url, method, headers, expiresAt } |
verifyUpload(ctx, { key, owner? }) | action | "verified" or "completed" |
completeUpload(ctx, { key, owner? }) | mutation+ | { file, created } |
cancelUpload(ctx, { key, owner? }) | mutation+ | |
store(ctx, data, { key?, keyPrefix?, contentType?, filename?, cacheControl?, contentDisposition?, owner? }) | action | file |
syncFile(ctx, key, { owner?, filename? }) | action | file, or null |
deleteFile(ctx, key) | mutation+ | { deleted } |
download(ctx, key) | action | Response |
clientApi(options) | public functions to export | |
registerRoutes(http, { authorize, pathPrefix?, expiresIn? }) |
A file is { key, size, contentType, etag, lastModified, filename?, owner }.
fileValidator and fileWithUrlValidator are exported for your own returns
validators.
clientApi(options)
Returns generateUploadUrl, verifyUpload, completeUpload, cancelUpload,
getFile, listFiles, and deleteFile. Export the ones you need.
| Option | Used by | Without it |
|---|---|---|
checkUpload(ctx, { size, contentType, filename, owner }) | generateUploadUrl | uploads are denied |
onUpload(ctx, file) | completeUpload | |
checkRead(ctx, file) | getFile | denied |
checkList(ctx, { prefix }) | listFiles | denied |
checkDelete(ctx, key, file | null) | deleteFile | denied |
onDelete(ctx, key, file | null) | deleteFile | |
getOwner(ctx) | upload functions | tokenIdentifier or null |
A missing check denies its function, so exporting a function doesn't make it public by accident.
Errors
Failures throw ConvexError<{ code, message }>. Branch on error.data.code:
| Code | When |
|---|---|
FILE_TOO_LARGE, CONTENT_TYPE_NOT_ALLOWED | The upload policy rejected the request. |
INVALID_ARGUMENT | Bad key, size, content type, filename, or expiry. |
UNAUTHORIZED | A clientApi function whose check callback wasn't provided. |
NOT_OWNER | Someone other than the requester tried to verify, complete, or cancel. |
OBJECT_MISSING | verifyUpload before the PUT finished, or download of a missing object. |
OBJECT_MISMATCH | The stored object differs from what was signed. It has been deleted. |
NOT_VERIFIED | completeUpload before verifyUpload. |
UPLOAD_NOT_FOUND, UPLOAD_EXPIRED | The upload was completed by someone else, cancelled, or expired. |
KEY_IN_USE | A custom key is taken, pending, being deleted, or exists in the bucket. |
STORAGE_ERROR | Railway returned an unexpected status. |
MISCONFIGURED | Component env vars are missing or invalid. |
Admin queries
components.railwayBuckets.admin.pendingUploads and admin.pendingDeletions
list in-flight work. A deletion whose attempt keeps climbing with a
lastError usually means the credentials are wrong or were rotated. A deletion
with a nextAttemptAt hours away is waiting for its final recheck, which is
expected. Run them from the dashboard or with
npx convex run --component railwayBuckets admin:pendingDeletions.
Security notes
- Content types are declared, not sniffed. The bucket enforces the
Content-Typeheader the client declared, not what the bytes are. Someone can upload HTML labeledimage/png, andimage/*includes SVG, which can contain script. Files are served from Railway's storage domain, never your app's origin, so they can't read your cookies. If you render user files inline, prefer an explicit list like["image/png", "image/jpeg"], or serve risky types withdisposition: "attachment". - Presigned URLs are bearer tokens until they expire. Anyone holding one can read that object. Keep read expiries as short as your UI allows.
- The access key ID appears in every presigned URL. That's how SigV4 works and it isn't secret. The secret key never leaves the component.
Limitations
- Single-PUT uploads only, up to 5 GiB. Multipart uploads aren't supported yet.
- Keys are immutable. To replace a file, upload a new one and delete the old one.
- Railway buckets don't support versioning, lifecycle rules, or server-side encryption settings (objects are encrypted at rest).
- There's no bulk import of existing bucket contents yet. Use
syncFileper key.
Developing this package
npm install
CONVEX_AGENT_MODE=anonymous npx convex dev # local backend with the example app
npm run dev:frontend # example UI on :5173
npm test # unit and type tests
npm run test:e2e # against a real bucket, needs convex dev running
DESIGN.md records the design decisions and what was verified against a live
bucket.