
How to Automatically Deploy Your Frontend upon Railway Updates
Recently, I was developing a Payload CMS 🔗 project for a client. The project follows a traditional frontend-backend architecture:
- The Payload CMS Next.js app serves as the backend API with a built-in dashboard, alongside MongoDB hosted on Railway 🔗
- The frontend is an OpenNext.js 🔗 app deployed on Cloudflare Workers 🔗, consuming data through RESTful APIs
Railway automatically triggers deployments when code is pushed, which is convenient and straightforward. However, what if we want to deploy the frontend automatically after Railway deployments complete? Since backend changes typically involve API modifications, we want to automatically apply these changes to the frontend as well.
In this post, we’ll use Cloudflare Workers and GitHub Actions to achieve this automation.
Setting Up a Railway Webhook
Following the Railway documentation 🔗, we can easily set up a webhook in the dashboard:
- Open your existing project on Railway
- Click the Settings button in the top right corner
- Navigate to the Webhooks tab
- Enter your desired webhook URL
- Optional: specify which events to receive notifications for
- Click Save Webhook

As shown in the image, we need an endpoint to receive webhook events. We’ll use Cloudflare Workers to handle this. Let’s move on to the next step.
Creating Cloudflare Workers Functions
Cloudflare Workers provides one of the easiest ways to deploy serverless code. Let’s walk through the setup process.
Creating a Cloudflare Workers Project
- Go to your Cloudflare dashboard 🔗
- Click Workers & Pages
- Click the Create button

Start with the basic example by clicking Get started under Start with Hello World. Deploy the script, connect your GitHub repository, then clone the code locally to write custom functions.

Writing Custom Functions
After cloning the code locally, let’s implement our custom logic.
According to Railway’s documentation, we’ll receive a payload like this when webhook events occur:
{ "type": "DEPLOY", "timestamp": "2025-02-01T00:00:00.000Z", "project": { "id": "[project ID]", "name": "[project name]", "description": "...", "createdAt": "2025-02-01T00:00:00.000Z" }, "environment": { "id": "[environment ID]", "name": "[environment name]" }, "deployment": { "id": "[deploy ID]", "creator": { "id": "[user id]", "name": "...", "avatar": "..." }, "meta": {} }}
We want to deploy the frontend once the backend Railway project is successfully deployed. We only need the Railway project ID for validation.
Let’s define the TypeScript interface for the payload:
interface RailwayPayload { type: string; timestamp: string; project: { id: string; };}
Next, we need to validate that the payload comes from the correct Railway project:
function validateRailwayWebhook( env: Env, payload: unknown,): payload is RailwayPayload { if (payload && typeof payload === "object" && "project" in payload) { const { project } = payload;
if (project && typeof project === "object" && "id" in project) { return project.id === env.RAILWAY_PROJECT_ID; } }
return false;}
The Env
type comes from Wrangler’s type generation and depends on your wrangler.toml
or wrangler.jsonc
configuration. We pass the RAILWAY_PROJECT_ID
to the vars
section in the config file.
Now we’ll trigger the remote GitHub Actions workflow that deploys the frontend. We’ll use the GitHub REST API 🔗 to create a custom repository dispatch event:
async function dispatchGitHubAction({ env, eventType, clientPayload,}: { env: Env; eventType: string; clientPayload: unknown;}) { const githubToken = await env.GITHUB_TOKEN.get();
if (!githubToken) { console.log("Missing GitHub token"); throw new Error("Missing GitHub token"); }
const githubRepo = env.GITHUB_REPO;
if (!githubRepo) { console.log("Missing GitHub repository"); throw new Error("Missing GitHub repository"); }
const response = await fetch( `https://api.github.com/repos/${env.GITHUB_REPO}/dispatches`, { method: "POST", headers: { Authorization: `Bearer ${githubToken}`, Accept: "application/vnd.github+json", "Content-Type": "application/json", "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "cloudflare-workers", }, body: JSON.stringify({ event_type: eventType, client_payload: clientPayload, }), }, );
if (!response.ok) { throw new Error( `GitHub API error: ${response.status} ${response.statusText}`, ); }
return response;}
async function handleRailwayWebhook(request: Request, env: Env) { try { const payload = await request.json();
if (!validateRailwayWebhook(env, payload)) { return new Response("Unauthorized", { status: 401 }); }
await dispatchGitHubAction({ env, eventType: "railway_deploy", clientPayload: { projectId: payload.project.id, }, });
return Response.json({ success: true, message: "Railway deployment rebuild triggered", }); } catch (_error) { return new Response("Internal Server Error", { status: 500 }); }}
The code is straightforward, but there are a few important details to note. We’re using Cloudflare’s secrets store 🔗 to securely store the GitHub Personal Access Token 🔗, which must have the appropriate repository permissions:
"secrets_store_secrets": [ { "binding": "GITHUB_TOKEN", "store_id": "your-store-id", "secret_name": "GITHUB_TOKEN" } ],
We must follow GitHub API’s header requirements precisely, otherwise the request will fail. The eventType
is a custom event name that must match the one in your GitHub Actions workflow:
name: Deploy Web
on: # Manual trigger for deployment as an escape hatch workflow_dispatch: # Deploy frontend automatically when backend Railway deployment completes repository_dispatch: types: [railway_deploy] # This must match the event type in your Workers code
jobs: format: uses: ./.github/workflows/format.yaml
build: needs: format runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 name: Install pnpm
- name: Install Node.js uses: actions/setup-node@v4 with: node-version: 24 cache: "pnpm"
- name: Install dependencies run: pnpm install --frozen-lockfile
- name: Build OpenNext for Cloudflare run: npx opennextjs-cloudflare build working-directory: apps/web env: NEXT_PUBLIC_WEBSITE_URL: ${{ vars.NEXT_PUBLIC_WEBSITE_URL }} NEXT_PUBLIC_PAYLOAD_SITE_URL: ${{ vars.NEXT_PUBLIC_PAYLOAD_SITE_URL }} REVALIDATE_SECRET: ${{ secrets.REVALIDATE_SECRET }}
- name: Deploy run: npx opennextjs-cloudflare deploy working-directory: apps/web env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Finally, let’s expose our functions through the main handler:
export default { async fetch( request: Request, env: Env, _ctx: ExecutionContext, ): Promise<Response> { if (request.method !== "POST") { return new Response("Method not allowed", { status: 405 }); }
const url = new URL(request.url);
switch (url.pathname) { case "/railway-webhook": return await handleRailwayWebhook(request, env); // You can add other routes for different webhook handlers default: return new Response("Not found", { status: 404 }); } },} satisfies ExportedHandler<Env>;
Deploying Your Code
Now we’re ready to deploy. You can either:
- Commit your code to trigger automatic deployment (if you connected GitHub in the dashboard)
- Run
pnpm run deploy
to deploy manually
Optionally, you can assign a custom domain to your Workers project in the dashboard settings for a cleaner webhook URL.
Finally, return to the Railway dashboard and:
- Enter your endpoint URL (
your-cloudflare-workers-url/railway-webhook
) - Select the Success event type
- Save the webhook configuration
That’s it! Now when your Railway project successfully redeploys, Cloudflare Workers will automatically trigger your frontend redeployment, keeping everything synchronized and up-to-date.
What’s Next?
Consider implementing a centralized workflow management system using Cloudflare Workers KV 🔗 to schedule and debounce operations across multiple projects. This could help you manage complex deployment pipelines more efficiently.