How I Deployed a Full Stack Platform with Cloudflare, Railway, Redis and Background Workers
Deploying the frontend was straightforward.
Deploying the complete application was where the actual engineering started.
I recently deployed USDStation, a platform for OpenUSD, NVIDIA Omniverse, Isaac Sim, robotics and digital twin assets.
The application includes:
- A React and Vite frontend
- A Node.js and Express API
- PostgreSQL through Prisma
- Redis and BullMQ
- A background asset-processing worker
- Cloudflare R2 object storage
- Clerk authentication with Google and GitHub OAuth
- Docker-based local infrastructure
- GitHub-based CI/CD
- Custom frontend and API domains
Every component worked locally. The harder problem was making them communicate reliably after they became separate production services.
The architecture
User
|
v
Cloudflare frontend
|
v
Railway API
|-- PostgreSQL
|-- Cloudflare R2
`-- Redis --> Background worker
Clerk sits alongside this flow and manages authentication.
The main system-design decision was to give each service one responsibility:
| Service | Responsibility |
|---|---|
| Cloudflare Pages | Serve the frontend |
| Railway API | Authentication, validation and orchestration |
| PostgreSQL | Store users, assets and processing state |
| Cloudflare R2 | Store asset files and previews |
| Redis | Transport background jobs |
| Railway worker | Process queued assets |
| Clerk | Manage identity and OAuth |
Fixing the Railway deployment
The repository contains separate frontend and backend directories. My first Railway deployment failed while Railpack was preparing the build. The API had not even started, so changing an Express route would not have solved it.
That taught me to classify deployment failures by stage:
- Repository detection
- Dependency installation
- Application build
- Runtime startup
- Health check
- External service connectivity
Because the repository contained multiple applications, Railway needed the correct backend root, build command and start command.
The backend lifecycle is defined through scripts such as:
{
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"start:worker": "node dist/workers/index.js",
"prisma:generate": "prisma generate",
"prisma:migrate:prod": "prisma migrate deploy"
}
}
The repository is deployed twice on Railway. The API service runs:
npm run start
The worker uses the same code and build output but runs:
npm run start:worker
This keeps shared models and processing logic in one codebase while separating the runtime responsibilities.
Separating the API and worker
USDStation processes uploaded 3D assets. Some operations take longer than an ordinary HTTP request should remain open.
Running them inside the API would increase response times, consume API resources and risk losing work when requests disconnect. Instead, the API publishes a BullMQ job to Redis:
await queue.add("process-asset", {
assetId,
fileId
});
A separate worker consumes the job:
const worker = new Worker(
"asset-processing",
async (job) => {
await processAsset(job.data);
},
{ connection: redisConnection }
);
The production flow becomes:
API receives request
-> validates the user and asset
-> stores the initial state
-> adds a Redis job
-> responds to the user
-> worker processes the job
-> worker updates PostgreSQL
The API remains responsive, and the worker can be restarted or scaled independently.
The localhost trap
One local configuration did not make sense in production:
REDIS_HOST=localhost
Inside a container, localhost refers to that container. For the API container, it means the API container. For the worker container, it means the worker container. Redis is a separate service with its own network address.
Production therefore uses the deployed Redis connection details:
REDIS_URL=redis://:<password>@<private-host>:<port>
The real value is stored in Railway's environment configuration and is never committed to Git. Both the API and worker receive the same Redis connection, allowing BullMQ to connect the two processes.
For local development, I run Redis through Docker Compose with redis:7-alpine. This provides a predictable Redis version, persistent local data and a reproducible environment for other developers.
Keeping files outside containers
Container filesystems should be treated as temporary. A container may be rebuilt, restarted or replaced, so files stored only inside it can disappear.
Uploaded assets therefore live in Cloudflare R2 instead of the API container:
Frontend requests a signed URL
-> API authenticates the user
-> API generates the R2 URL
-> browser uploads directly to R2
-> API stores metadata in PostgreSQL
-> API adds a Redis job
-> worker processes the stored object
Direct browser-to-R2 uploads also prevent the API from becoming a bandwidth bottleneck. PostgreSQL stores structured data, R2 stores binary files, Redis stores queue state, and Railway provides the compute.
Connecting Cloudflare and Railway
The frontend and backend use separate production domains:
Frontend: https://usdstation.com
API: https://api.usdstation.com
Connecting them required more than creating a DNS record.
The frontend needs its production API URL during the Vite build:
VITE_API_URL=https://api.usdstation.com
Vite variables are compiled into the generated bundle. Changing a dashboard value does not modify an existing build, so the frontend must be rebuilt.
The API also needs to accept the production frontend through CORS:
app.use(
cors({
origin: allowedFrontendOrigins,
credentials: true
})
);
For the connection to work, these values must agree:
- Cloudflare DNS
- Railway custom domain
- Frontend API URL
- Backend CORS origin
- Clerk application domain
- OAuth callback URLs
Correct DNS proves that traffic reaches a destination. It does not prove that the application accepts that traffic.
Production authentication
Authentication worked locally, but production required its own Clerk configuration:
- Production Clerk keys
- A verified application domain
- Google OAuth credentials
- GitHub OAuth credentials
- Exact callback URLs
- Correct frontend origins
- Matching backend CORS rules
The login flow is:
User
-> USDStation
-> Clerk
-> Google or GitHub
-> Clerk callback
-> authenticated USDStation session
A provider button appearing on the sign-in page does not prove that OAuth works. I tested both providers from the public domain and verified that they reached their real authorization flows with the correct callback.
CI/CD and production verification
GitHub is the source of truth for deployment. The CI flow validates the code before Cloudflare and Railway deploy their respective services:
Install locked dependencies
-> generate the Prisma client
-> compile backend TypeScript
-> lint and build the frontend
-> report success or failure
These stages answer different questions:
- CI: Is this revision buildable?
- Deployment: Can it run in the target environment?
- Production testing: Can a real user complete the workflow?
I did not consider the deployment complete when every dashboard showed green. I verified that:
- The frontend loaded from the public domain.
- The API responded through its custom domain.
- PostgreSQL and Redis were reachable.
- The worker connected and consumed jobs.
- Signed R2 operations worked.
- Google and GitHub reached their authorization flows.
- Processing state was persisted.
What I learned
This deployment reinforced several practical lessons:
- A successful build is not a successful deployment.
- Build failures and runtime failures require different debugging.
-
localhostrefers to the current container. - Long-running work should be separated from API requests.
- Containers should not hold permanent uploaded files.
- Frontend environment variables often exist at build time.
- DNS, CORS and OAuth configuration must describe the same system.
- Green dashboards do not replace end-to-end testing.
The biggest lesson was that production engineering is about boundaries.
The frontend presents the product. The API validates and coordinates. PostgreSQL stores permanent state. R2 stores files. Redis transports jobs. The worker performs background processing. Clerk manages identity.
Those services are deployed independently, but to the user they must behave like one application.
The complete system is live at usdstation.com.
Top comments (0)