# Railway Buckets

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

This is an upstream package. It has not been adapted or qualified for Bijection.

- Package: convex-railway-buckets
- Source: https://github.com/leoisadev1/convex-railway-buckets
- npm: https://www.npmjs.com/package/convex-railway-buckets
- Directory: https://bijection.com/components/convex-railway-buckets

Read the upstream README for setup, license, and compatibility before using it.

## Upstream package documentation

Recorded 2026-10-23. This describes convex-railway-buckets; check the Bijection adaptation separately.

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

- 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

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 README

# Railway Buckets for Convex

A [Convex component](https://www.convex.dev/components) that stores files in
[Railway Buckets](https://docs.railway.com/storage-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.

```tsx
// 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](./example) is the finished version.

### 1. Install

```sh
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:

```sh
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:

```sh
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:

```sh
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:

```ts
// 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

```ts
// 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 });
    },
  });
```

```tsx
// 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.

```ts
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:

```ts
// 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

```ts
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

```ts
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:

```ts
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

```ts
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:

```ts
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-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

```sh
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.