Rate My Uni is a full-stack web app where students post and read reviews of UK universities. The product is small. A React SPA, a Spring Boot API, a Postgres database, and a bucket for user-uploaded images. The production setup is the more interesting part. Compute and data live on Railway, and everything that faces the public internet goes through Cloudflare.
This post walks through that architecture: what runs where, why each piece sits where it does, and the rough edges I want to file down before scaling out.
Topology
Three Railway services (frontend, backend, Postgres), two Cloudflare zones (ratemyuni.co.uk for the site, rate-my-uni-images.co.uk for image hosting), and one R2 bucket sit underneath it all.
flowchart LR
user(["🌐 Internet / Browser"])
subgraph cf["☁️ Cloudflare · DNS · CDN · TLS"]
direction TB
edge["Proxy / WAF
ratemyuni.co.uk · www"]
r2[("R2 bucket
rate-my-uni-images
rate-my-uni-images.co.uk")]
end
subgraph prod["Railway · production"]
direction TB
fe["rate-my-uni-frontend
React SPA · Railpack"]
be["backend
Spring Boot · JDK 21"]
db[("Postgres
postgres-ssl:17
postgres-volume")]
end
user -->|"https"| edge
edge -->|"proxied CNAME
jqe1bq9i.up.railway.app"| fe
user -.->|"public API
REACT_APP_API_URL"| be
fe -.->|"API calls"| be
be <==>|"private network
postgres.railway.internal:5432"| db
be <-->|"image read / write"| r2
user -->|"images via
rate-my-uni-images.co.uk"| r2
classDef svc fill:#eef2fb,stroke:#9fb3e0,color:#1c2330;
classDef data fill:#e6f5ee,stroke:#7cc4a2,color:#14402e;
classDef edge fill:#fdf3dc,stroke:#e0b85c,color:#4a3a12;
class fe,be svc;
class db,r2 data;
class edge edge;
Every public hostname is proxied through Cloudflare. The backend talks to Postgres over Railway’s private network and to R2 over the public internet through Cloudflare’s S3-compatible API.
Application services on Railway
All three application pieces run as Railway services in a single project. They’re all built with Railpack, Railway’s zero-config builder, so I haven’t had to write or maintain Dockerfiles for any of them.
Backend. Spring Boot on Java 21, built from the Rate-My-Uni/backend GitHub repo. It owns authentication, university and course reviews, blog posts, the mailing list, transactional email, and image reads and writes against R2. It runs as a single replica today.
Frontend. A React SPA with Material UI, built with CI=false npm run build and served with npm run prod --omit=dev. The interesting wrinkle is that the SPA calls the backend at its public Railway domain (backend-production-c62d.up.railway.app), not over Railway’s private network. That’s because the calls originate in the user’s browser, not from the frontend container, so the private network isn’t available to them.
Postgres. The official postgres-ssl:17 image with a Railway volume mounted at /var/lib/postgresql/data. The backend reaches it at postgres.railway.internal:5432 on the private network, and a public TCP proxy is exposed on ballast.proxy.rlwy.net:35646 for admin tooling.
Why Railway in the first place? Sensible defaults, private networking out of the box, and source-to-service deploys straight from GitHub. For a one-person project, that’s the right level of abstraction.
The Cloudflare edge
Two Cloudflare zones front the app, both on the Free plan. ratemyuni.co.uk and www.ratemyuni.co.uk are proxied CNAMEs pointing at the Railway frontend. Cloudflare terminates TLS, caches static assets at the edge, and applies WAF rules in front of the origin. Same story for the images domain.
One deliberate asymmetry: the SPA’s API calls go straight to the backend’s .up.railway.app hostname, bypassing Cloudflare. That keeps API responses out of the CDN (no risk of stale review data getting cached at the edge) but it also means the API doesn’t sit behind Cloudflare’s WAF. The natural next step is to put the API on a proxied api.ratemyuni.co.uk CNAME with caching disabled but WAF on, so the backend gets edge protection without the cache surprises.
Object storage with Cloudflare R2
User-uploaded images live in an R2 bucket called rate-my-uni-images. They’re served from rate-my-uni-images.co.uk, which is bound to the bucket as a Cloudflare custom domain. The default pub-….r2.dev URL is turned off, so there’s only one public way to reach the bucket.
Two reasons R2 instead of S3. First, egress out of R2 is free, and image bandwidth on a review site adds up fast. Second, the bucket sits in the same Cloudflare account as the DNS, which keeps the wiring simple: one place to manage the custom domain, the TLS cert, and the bucket policy.
Auth and email: the multi-provider parts
Auth supports three flows, all of which end in a JWT: email and password, Google OAuth, and email magic links. The shared JWT layer means the API itself doesn’t care which path the user took. From the API’s perspective there’s one auth contract; the differences live in the sign-in code.
Email is split across two providers, by purpose:
- Zoho Mail owns the
@ratemyuni.co.ukmailboxes. The backend sends user-facing mail throughsmtppro.zoho.eu. - Resend, sitting on top of Amazon SES, handles transactional email. The
send.ratemyuni.co.uksubdomain has its own SPF and DKIM records pointing at Amazon SES.
The split was deliberate. Zoho is great for inbox-to-inbox conversation, but transactional volume is cleaner through a sender like Resend, and keeping it on a subdomain means a deliverability problem on one path can’t poison the other. If Resend ever gets flagged, the mailboxes keep working.
What I’d change next
A few changes I want to make next, roughly in priority order. The top two are bigger architectural moves; the rest are operational rough edges that will bite once the app gets real traffic.
- Dockerise the backend. Today Railpack auto-builds the Spring Boot app straight from source. It works, but it’s a black box. I don’t pin a JDK base image, I can’t do multi-stage builds for smaller production layers, and switching builders means starting from scratch. A real Dockerfile gives me an explicit base image, reproducible builds, and the same artifact I can run locally with
docker run. It’s also the unlock for the next item: anywhere that takes a container can host the backend. - Move the backend to GCP. Once the backend is a container, Cloud Run is the obvious target. It’s stateless, scales to zero between bursts of traffic, and per-request billing fits the load shape of a side project better than an always-on Railway VM.
- In-process cache on a single replica. The backend uses EhCache, which lives in the JVM heap. Today there’s exactly one instance, so the cache is consistent by definition. The moment it scales to two, each instance holds its own copy and they drift apart until invalidation reaches them. Before scaling out, this needs to move to a shared cache (Memorystore on GCP if the backend moves there, Redis on Railway if it doesn’t).
- Postgres public TCP proxy. Railway exposes Postgres on a public TCP proxy alongside the private network address. It’s behind credentials, but I’d rather restrict it to an IP allowlist or disable it entirely except when I need it from a laptop. This gets more pointed if the backend moves off Railway, since it would then have to talk to Postgres over that public proxy unless Postgres moves too.
Wrap-up
For a small project the multi-provider split was the right call. Railway is good at running services I don’t want to babysit. Cloudflare is good at being a cheap, reliable edge with object storage attached. Putting each provider on the part of the stack it’s best at meant I didn’t have to overbuild any one piece.
The trade-off is more places to configure DNS, secrets, and IAM. That’s fine when the team is one person and the architecture fits on the back of an envelope. If the team or the surface area grows, the next move is consolidating the public edge under a single proxied domain so the backend gets the same protection the frontend already has.
← Back to blog