Cloudflare Pages hosts static frontends and Cloudflare Workers runs serverless functions at the edge, both with generous free tiers that cover most side projects, internal tools, and even light production traffic. No EC2, no NAT Gateway, no ALB. This walkthrough covers deploying a static site to Pages, backing it with a Workers API, and wiring both into a GitHub Actions pipeline for automatic deploys.

Prerequisites

Step 1: Authenticate wrangler

wrangler is Cloudflare’s CLI for deploying Workers and Pages, and for local development against the edge runtime.

wrangler login

This opens a browser flow that stores an OAuth token locally. For CI, skip this and use an API token instead (Step 5).

Verify the account is linked:

wrangler whoami

Step 2: Scaffold a Worker

Create a new Worker project. This becomes your API backend.

npm create cloudflare@latest my-api -- --type=hello-world --lang=ts
cd my-api

The generated wrangler.toml controls routing, environment variables, and bindings:

name = "my-api"
main = "src/index.ts"
compatibility_date = "2026-06-01"

[vars]
ENVIRONMENT = "production"

A minimal Worker handler:

export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname === "/health") {
      return new Response(JSON.stringify({ status: "ok" }), {
        headers: { "content-type": "application/json" },
      });
    }
    return new Response("Not found", { status: 404 });
  },
};

Deploy it directly to confirm the setup works before wiring CI:

wrangler deploy

Verify:

curl https://my-api.<your-subdomain>.workers.dev/health

Note: the free tier includes 100,000 requests per day per account across all Workers, reset daily. CPU time per invocation is capped at 10ms on the free plan, which is enough for routing, auth checks, and lightweight JSON responses but not for heavy compute.

Step 3: Scaffold the Pages site

Pages serves static assets (HTML, JS, CSS, images) directly from Cloudflare’s CDN, with build integration for frameworks like Astro, Next.js, or plain Vite.

npm create vite@latest my-site -- --template vanilla-ts
cd my-site
npm install
npm run build

Deploy the built output directly with wrangler, or connect the repo through the Cloudflare dashboard for git-based deploys (recommended, since it gives you preview URLs per branch):

wrangler pages deploy dist --project-name=my-site

Step 4: Connect Pages to the Worker API

Rather than hardcoding the .workers.dev URL in the frontend, use a Pages Function to proxy /api/* requests to the Worker. This keeps everything under one domain and avoids CORS entirely.

Create functions/api/[[path]].ts in the Pages project:

export const onRequest: PagesFunction = async (context) => {
  const url = new URL(context.request.url);
  const apiUrl = `https://my-api.<your-subdomain>.workers.dev${url.pathname.replace("/api", "")}`;
  return fetch(apiUrl, context.request);
};

Redeploy Pages and the API is now reachable at https://my-site.pages.dev/api/health with no separate CORS configuration needed.

Step 5: Automate deploys with GitHub Actions

Generate a scoped API token in the Cloudflare dashboard (My Profile > API Tokens > Create Token, using the “Edit Cloudflare Workers” template as a base, then add Pages edit permission). Store it as a GitHub secret named CLOUDFLARE_API_TOKEN, along with CLOUDFLARE_ACCOUNT_ID.

name: Deploy to Cloudflare

on:
  push:
    branches: [main]

jobs:
  deploy-worker:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: my-api
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx wrangler deploy
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

  deploy-pages:
    runs-on: ubuntu-latest
    needs: deploy-worker
    defaults:
      run:
        working-directory: my-site
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run build
      - run: npx wrangler pages deploy dist --project-name=my-site
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

Every push to main now deploys the Worker first, then the Pages site. Ordering matters here since the Pages Function proxy depends on the Worker being live.

Verify the pipeline by checking the Actions run and then hitting the health endpoint:

curl https://my-site.pages.dev/api/health

Troubleshooting

Pages Function returns 522 or timeout. The proxy fetch inside functions/api/[[path]].ts is calling the wrong Worker URL, usually a typo in the subdomain or a Worker that failed to deploy. Confirm with wrangler deployments list in the Worker project.

Free tier CPU limit exceeded (Error 1102). The Worker is doing more than routing and light JSON handling, JWT verification with expensive crypto, or synchronous loops over large payloads. Move heavy compute to a paid plan with the 50ms+ CPU limit, or offload to a queue-backed Worker.

GitHub Actions deploy succeeds but the site is stale. Cloudflare’s CDN cache can serve the previous asset version for a few minutes after deploy. Purge cache manually from the dashboard, or set Cache-Control: no-cache on index.html specifically while allowing long-lived caching on hashed asset filenames.

wrangler deploy fails with 10021 “no compatibility_date set”. Add a compatibility_date to wrangler.toml. Cloudflare requires this to lock in runtime behavior and avoid silent breaking changes on future deploys.

Summary

Cloudflare Pages and Workers together give you a static frontend, an edge API, and a CDN, all on the free tier with no servers to patch or scale. The GitHub Actions pipeline above deploys both on every push to main, with the Worker going first so the Pages Function proxy always has a live backend to call. This setup comfortably handles side projects, internal dashboards, and low-to-moderate traffic production apps. Once request volume or CPU time outgrows the free tier, the same wrangler.toml and Actions workflow carry over unchanged, the only difference is a billing plan switch in the Cloudflare dashboard.

Was this article helpful?

Need help setting this up for your team?

nerdSolv designs and deploys cloud-native infrastructure on Azure, AWS, and GCP.

Talk to us →