Architecture decision ADR-008

Use Railway Persistent Volume for Documents in Phase 1; Migrate to Cloudflare R2 at Defined Threshold

Accepted · 2026-05-10

Historical decision text is preserved. Accepted describes the decision, not deployment of every capability discussed. Consult the Architecture and Infrastructure tabs for the verified running system.

Date: 2026-05-10
Status: Accepted
Deciders: Daniel Meza


Context and Problem Statement

Urza's Wallet must store and serve static regulatory documents: rate sheets (hojas de tarifas), fund prospectuses, regulatory disclosures, and product terms from Costa Rican banks and regulators. These documents are:

At launch, the document library is small (~20–50 PDFs total across 3 verticals and ~10 issuers). The question is how to store and serve these documents in a way that is simple to operate in Phase 1 and has a clear upgrade path as volume grows.


Decision Drivers


Considered Options

  1. Railway persistent volume (Phase 1 only) — Store all documents on a Railway volume mounted to the API service. Serve via a GET /api/v1/documents/:id/download route that reads the file and streams it.
  2. Cloudflare R2 from day one — Provision an R2 bucket immediately, implement presigned URL generation, and serve all documents via R2.
  3. AWS S3 + CloudFront — Store documents on S3, serve via CloudFront CDN. Standard enterprise pattern.
  4. Database BLOB storage (PostgreSQL bytea) — Store PDFs directly in the PostgreSQL database as binary columns.
  5. Vercel Blob / Supabase Storage — Platform-specific storage services.

Decision Outcome

Option 1 for Phase 1, with a planned migration to Option 2 (Cloudflare R2) at the defined threshold.

Phase 1 implementation (Railway volume):

The API service mounts a Railway persistent volume at /data/documents. The upload endpoint (POST /api/v1/documents) uses multer to write the uploaded PDF to /data/documents/{issuer-slug}/{doc-type}/{filename}-{timestamp}.pdf. The metadata (file path, access_level, effective_date, etc.) is stored in the static_documents table.

The download endpoint (GET /api/v1/documents/:id/download):

  1. Queries static_documents by id to retrieve storage_path and access_level.
  2. If access_level = registered, validates the Clerk session (requires authenticated user).
  3. If access_level = public, no auth check.
  4. Returns the file as a stream using res.sendFile(storage_path) with appropriate Content-Type: application/pdf and Content-Disposition: attachment headers.
  5. A Cache-Control: public, max-age=86400 header is set for public documents (24-hour browser cache).

Migration trigger to Cloudflare R2 — migrate when ANY of the following is true:

Phase 2 implementation (Cloudflare R2):

The storage_backend column in static_documents records volume or r2. The API's document service checks this field to determine whether to read from the local filesystem or generate an R2 presigned URL.

// Phase 1: volume
res.sendFile(`/data/documents/${storagePath}`);

// Phase 2: R2
const url = await getSignedUrl(s3Client, new GetObjectCommand({
  Bucket: R2_BUCKET_NAME,
  Key: storagePath,
}), { expiresIn: 900 });
res.redirect(302, url);

The migration script (scripts/migrate-docs-to-r2.ts):

  1. Lists all static_documents with storage_backend = 'volume'.
  2. For each document, reads the file from the volume and uploads it to R2 with the same key.
  3. Updates storage_backend to r2 and storage_path to the R2 key.
  4. Runs in parallel with 5 concurrent uploads to minimize migration time.
  5. Is idempotent — can be re-run if interrupted without duplicating uploads (uses R2 HeadObject check before uploading).

The Railway volume is kept for 30 days post-migration as a backup, then decommissioned.


Positive Consequences

Negative Consequences / Trade-offs


Pros and Cons of the Options

Option Phase 1 Complexity Cost at Phase 1 Global CDN Migration Effort
Railway volume → R2 (chosen) Very low Included in Railway plan No (Phase 1) / Yes (Phase 2) One-time script
R2 from day one Low ~$0 storage, zero egress Yes N/A
AWS S3 + CloudFront Medium ~$1–5/month + egress Yes N/A
PostgreSQL BLOB Very low Database storage cost No Hard to migrate later
Vercel Blob / Supabase Storage Low $0.023/GB Partial N/A

Cloudflare R2 from day one was the strongest alternative. The only reason to defer it is operational simplicity: no external credentials to manage, no bucket policy to configure, no Terraform Cloudflare provider to set up, before the volume thresholds are reached. Since Railway's volume is effectively free (included in the Pro plan storage allocation), the cost difference is negligible. The decision is purely about reducing configuration surface area in Phase 1.

PostgreSQL BLOB storage was rejected because: PDF files in database columns significantly increase backup size, slow down pg_dump, and make point-in-time restoration more complex. File data belongs on a filesystem or object store, not in a relational database.


References