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:
- Primarily PDFs, ranging from 50 KB to 5 MB each.
- Uploaded by editors, not generated by users.
- Required to be accessible to anonymous users (public documents) or registered users only (restricted documents).
- Subject to version history requirements: when a new rate sheet supersedes the previous one, the old version must be retained for audit purposes.
- SUGEF (Superintendencia General de Entidades Financieras) and SUPEN (Superintendencia de Pensiones) compliance artifacts that may be requested during regulatory review.
- A user trust signal: hosting the official source documents alongside comparison data allows consumers to verify that the platform's rates and fees match the bank's published rate sheet. This transparency is a core differentiator.
- Audit trail for the ingestion pipeline: as documented in ADR-007, PDF parsing is one of the three ingestion source types. Storing the original PDF alongside the extracted data creates a verifiable link between the canonical product record and its source document. If a parsed rate is questioned, the team can trace it back to the specific document version.
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
- Low-ops Phase 1: Adding an external object storage service (AWS S3, Cloudflare R2, GCS) at launch requires provisioning the bucket, configuring CORS (Cross-Origin Resource Sharing) and access policies, managing API credentials, and implementing presigned URL generation. This is not complex, but it is unnecessary overhead when the document library is small.
- Railway persistent volumes: Railway supports persistent volumes mounted at a path in any service. A volume survives service restarts and redeployments. Uploading a PDF writes it to the mounted path; the API reads and serves it from the same path. This requires zero external service configuration.
- Cloudflare R2 as the upgrade target: When the volume outgrows Railway's practical constraints, Cloudflare R2 is the correct upgrade target because: (1) it is S3-compatible, meaning the same
@aws-sdk/client-s3SDK is used with only a different endpoint URL; (2) R2 has zero egress fees (unlike AWS S3), which matters when serving PDFs to browsers; (3) Cloudflare manages R2's CDN (Content Delivery Network) distribution globally; (4) R2 bucket versioning handles the document version history requirement natively. - Versioning requirement: When a new rate sheet is uploaded, the old one must be retained. This is handled in Phase 1 by the
static_documentsanddocument_versionsdatabase tables (the old file is kept on the volume; onlyis_current_versionis updated in the DB). In Phase 2, R2's native versioning replaces this logic. - Access control: The Railway volume is not directly accessible from the internet — only the API service can read from it. This makes the API the natural access control enforcement point (checking
access_levelbefore generating a signed URL or serving the file).
Considered Options
- 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/downloadroute that reads the file and streams it. - Cloudflare R2 from day one — Provision an R2 bucket immediately, implement presigned URL generation, and serve all documents via R2.
- AWS S3 + CloudFront — Store documents on S3, serve via CloudFront CDN. Standard enterprise pattern.
- Database BLOB storage (PostgreSQL bytea) — Store PDFs directly in the PostgreSQL database as binary columns.
- 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):
- Queries
static_documentsbyidto retrievestorage_pathandaccess_level. - If
access_level = registered, validates the Clerk session (requires authenticated user). - If
access_level = public, no auth check. - Returns the file as a stream using
res.sendFile(storage_path)with appropriateContent-Type: application/pdfandContent-Disposition: attachmentheaders. - A
Cache-Control: public, max-age=86400header is set for public documents (24-hour browser cache).
Migration trigger to Cloudflare R2 — migrate when ANY of the following is true:
- Railway volume storage exceeds 1 GB of PDFs.
- Document download requests exceed 500 per day sustained over 7 consecutive days.
- The team needs multi-region document availability (R2 is globally distributed; Railway volumes are single-region).
- Railway deprecates or limits persistent volumes in a future pricing change.
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):
- Lists all
static_documentswithstorage_backend = 'volume'. - For each document, reads the file from the volume and uploads it to R2 with the same key.
- Updates
storage_backendtor2andstorage_pathto the R2 key. - Runs in parallel with 5 concurrent uploads to minimize migration time.
- Is idempotent — can be re-run if interrupted without duplicating uploads (uses R2
HeadObjectcheck before uploading).
The Railway volume is kept for 30 days post-migration as a backup, then decommissioned.
Positive Consequences
- Zero configuration overhead at launch: No external bucket, no CORS configuration, no API credentials for object storage in Phase 1. The Railway volume is provisioned with a single line in the Terraform
volumes.tffile. - Controlled migration path: The
storage_backendfield in the database makes the migration incremental. Old documents remain on the volume; new documents go to R2. The migration script migrates all volume documents in a single run. - R2 zero egress fees: When the platform migrates to R2 in Phase 2, PDF downloads (potentially hundreds per day) incur no egress fees. AWS S3 charges ~$0.09/GB egress — meaningful at scale.
- S3-compatible API: No SDK change is required when migrating from Phase 1 (filesystem) to Phase 2 (R2). The same
@aws-sdk/client-s3client works with R2 by changing the endpoint URL. - Version history works in both phases: Phase 1 retains old files on the volume (the path includes a timestamp). Phase 2 uses R2 native object versioning or the same timestamp-based key strategy.
Negative Consequences / Trade-offs
- Railway volume is single-region: If Railway deploys the API service to a region far from Costa Rica (currently, Railway's primary regions are US-East and EU-West), PDF download latency may be noticeable for large files. Mitigation: most SUGEF-mandated PDFs are under 500 KB and download in under 1 second on a typical CR internet connection even with US-East latency.
- No CDN for Phase 1 documents: Unlike Cloudflare R2 (which serves from Cloudflare's global edge), Phase 1 documents are served directly from the Railway API service. High concurrent download requests could consume API server bandwidth and memory. The 500 req/day migration trigger is set to activate before this becomes a performance issue.
- Volume backup limitations: Railway volume snapshots are taken daily (Pro plan). A catastrophic volume failure could result in up to 24 hours of document loss. Mitigation: editors retain original PDFs from source (bank websites); documents can be re-uploaded from source in case of volume loss.
- Filesystem access in tests: Integration tests that test the document upload/download flow must write to a temp directory and clean up. This is a minor testing inconvenience compared to mocking an S3 client.
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
- Kleppmann, Designing Data-Intensive Applications, Ch. 3 (Storage and Retrieval — appropriate storage engines per data type)
- Cloudflare R2 documentation: S3 compatibility, presigned URLs, object versioning
- Railway documentation: persistent volumes, volume backups
- Architecture reference §8 (Static Document Management)
- SUGEF — Reglamento sobre Transparencia de Información de Productos Financieros (mandates hojas de tarifas publication by regulated entities)
- SUPEN — Reglamento de Inversiones de las Entidades Reguladas (governs pension fund disclosure and prospectus requirements)
- Ley 8968 — Ley de Protección de la Persona frente al Tratamiento de sus Datos Personales (data retention and handling obligations relevant to document storage)