Skip to content

Find a component

Loading the directory…

↑ ↓ to select · Enter to open · Esc to closeBrowse all →
Contributor login

Railway Buckets

Store files in Railway Buckets from Convex, with presigned browser uploads, access checks, and file metadata kept in Convex.

Upstream source
git clone https://github.com/leoisadev1/convex-railway-buckets.git
cd convex-railway-buckets

This 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.

Available to evaluate

Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.

Getting started

  1. Get the source at the revision above and read its package documentation.
  2. Check the required Bijection SDK version, dependencies, and external service credentials.
  3. Follow the package setup instructions and the guide to using components.
  4. 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.

agent-prompt.md
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 onUpload hook 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

NameRequiredDefaultNotes
BUCKETyesThe S3 bucket name (bucketName in Railway's JSON), not the display name.
ACCESS_KEY_IDyes
SECRET_ACCESS_KEYyes
ENDPOINTnohttps://t3.storageapi.dev
REGIONnoauto
URL_STYLEnovirtual-hostSet path for older buckets whose Credentials tab says so. The env command tells you when.

new RailwayBuckets(component, options?)

OptionDefaultMeaning
upload.maxSizenoneLargest upload in bytes, enforced by the bucket. Single uploads are capped at 5 GiB regardless.
upload.allowedContentTypesanyExact types or wildcards like image/*.
upload.keyPrefix""Prefix for generated keys.
upload.urlExpiresIn900Seconds an upload URL works.
upload.completeWithin21600Seconds after that during which the upload can still be completed.
urlExpiresIn3600Default lifetime of read URLs, in seconds.

Methods (ctx is any context that can run the needed function type):

MethodContextReturns
getUrl(ctx, key, { expiresIn?, disposition?, filename?, contentType? })query+string
getFreshUrl(ctx, key, urlOptions?)actionstring, 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? })actionfile
syncFile(ctx, key, { owner?, filename? })actionfile, or null
deleteFile(ctx, key)mutation+{ deleted }
download(ctx, key)actionResponse
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.

OptionUsed byWithout it
checkUpload(ctx, { size, contentType, filename, owner })generateUploadUrluploads are denied
onUpload(ctx, file)completeUpload
checkRead(ctx, file)getFiledenied
checkList(ctx, { prefix })listFilesdenied
checkDelete(ctx, key, file | null)deleteFiledenied
onDelete(ctx, key, file | null)deleteFile
getOwner(ctx)upload functionstokenIdentifier 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:

CodeWhen
FILE_TOO_LARGE, CONTENT_TYPE_NOT_ALLOWEDThe upload policy rejected the request.
INVALID_ARGUMENTBad key, size, content type, filename, or expiry.
UNAUTHORIZEDA clientApi function whose check callback wasn't provided.
NOT_OWNERSomeone other than the requester tried to verify, complete, or cancel.
OBJECT_MISSINGverifyUpload before the PUT finished, or download of a missing object.
OBJECT_MISMATCHThe stored object differs from what was signed. It has been deleted.
NOT_VERIFIEDcompleteUpload before verifyUpload.
UPLOAD_NOT_FOUND, UPLOAD_EXPIREDThe upload was completed by someone else, cancelled, or expired.
KEY_IN_USEA custom key is taken, pending, being deleted, or exists in the bucket.
STORAGE_ERRORRailway returned an unexpected status.
MISCONFIGUREDComponent 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-Type header the client declared, not what the bytes are. Someone can upload HTML labeled image/png, and image/* 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 with disposition: "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 syncFile per 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.

Keywords