‹ Blog
Juri Strumpflohner
Juri StrumpflohnerJuri Strumpflohner

Deploying a PNPM Monorepo to Cloudflare Pages

Deploying a PNPM Monorepo to Cloudflare Pages

Deploying from a monorepo with multiple apps comes down to one requirement: only build and redeploy the apps that actually changed. Cloudflare Pages itself gives you fast static hosting with a global edge, custom domains, and a generous free tier. The trick is wiring it up so a push that only touches app1 doesn't rebuild and redeploy app2, app3, and everything else.

The setup in this post works for any pnpm or npm workspace. I use Nx as the task scheduler on top of the workspace because for the deployment setup it gives me an important thing: a project graph for computing what's affected by a change.

As a result I can declare a new deploy target on each deployable app, then let one generic GitHub Actions workflow run nx affected -t deploy and push results to Cloudflare Pages with wrangler.

Two paths for deploying

Cloudflare Pages offers two deployment models:

  1. Git integration. Point CF Pages at a GitHub repo, give it a build command and an output directory. Every push triggers a build in CF's container.
  2. Direct upload. You build the site somewhere else (e.g. GitHub actions), then call wrangler pages deploy <dist> to publish the artifacts.

Both ways work with a monorepo. For the GitHub integration you need to make sure though that you define the build watch paths so you don't build and redeploy projects you didn't touch in your monorepo.

While this works, it is tricky because it won't let you dynamically capture project dependencies. Say you have an app app1 (apps/app1) which imports from pkg1 (packages/pkg1), you'd have to statically define a watch path glob that captures both. If tomorrow you change your workspace in a way where app1 -> pkg1 -> pkg2 those changes wouldn't be captured automatically.

This is exactly where Nx comes in. Nx affected relies on the project graph to dynamically discover affected projects, following the dependency chain as it evolves.

The deploy target

Instead of hardcoding a deploy step per app in the workflow, create a deploy target on those apps you want to deploy to Cloudflare. Each deployable app declares its own in package.json:

{
  "name": "juriweb",
  "nx": {
    "targets": {
      "deploy": {
        "executor": "nx:run-commands",
        "options": {
          "command": "pnpm dlx wrangler pages deploy dist --project-name=juriweb --branch=$DEPLOY_BRANCH",
          "cwd": "{projectRoot}"
        },
        "dependsOn": ["build"],
        "cache": false
      }
    }
  }
}

Three things worth calling out.

cwd: "{projectRoot}". Nx's nx:run-commands defaults to running from the workspace root. That means dist would resolve to /workspace-root/dist, not /workspace-root/apps/juriweb/dist. Set cwd to the project root and dist resolves where you expect it.

dependsOn: ["build"]. This defines a so-called task pipeline so that Nx runs build before deploy. On a run where nothing changed, build is an instant cache hit and deploy uploads the last-good artifact.

cache: false. Critical. A cached deploy is not a deploy. If Nx cached the deploy task by its inputs, a re-run with no changes would skip the actual wrangler call entirely, and Cloudflare would never receive the update.

Adding a second deployable app is now a three-line copy of the same block. The workflow stays untouched.

The workflow

Because discovery runs through nx affected, the workflow is short:

name: Deploy affected (Cloudflare Pages)

on:
  push:
    branches: [main]
  pull_request:

permissions:
  actions: read
  contents: read
  deployments: write

env:
  NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_CI_TOKEN }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }

      - uses: actions/setup-node@v4
        with: { node-version: 24 }

      - run: corepack enable pnpm
      - run: pnpm install --frozen-lockfile

      - uses: nrwl/nx-set-shas@v4

      - name: Deploy affected to Cloudflare Pages
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          DEPLOY_BRANCH: ${{ github.head_ref || github.ref_name }}
        run: pnpm exec nx affected -t deploy --parallel=3

That single nx affected -t deploy command does the discovery, the build (via dependsOn), and the deploy.

Two vars worth noting.

nx-set-shas sets the base SHA for affected. It queries the GitHub API for the last successful run on the base branch and sets NX_BASE and NX_HEAD env vars. That's what makes nx affected compute against "what actually changed since the last green CI" instead of against HEAD, which would report everything as affected on every push. The workflow needs actions: read for the API query to succeed.

DEPLOY_BRANCH. Cloudflare Pages uses the branch name to create preview aliases at <branch-slug>.<cf-project>.pages.dev. head_ref is set in PR context, ref_name in push context. This expression gives you one rule that produces clean preview URLs in both.

Secrets and one-time setup

Three GitHub Actions secrets:

  • CLOUDFLARE_API_TOKEN: create a custom token in the Cloudflare dashboard with the single permission Account → Cloudflare Pages → Edit. Don't use the "Edit Cloudflare Workers" preset, it grants far more than this workflow needs.
  • CLOUDFLARE_ACCOUNT_ID: visible in the Cloudflare dashboard sidebar, or via wrangler whoami.
  • NX_CLOUD_CI_TOKEN: from your Nx Cloud workspace settings.

Before the first deploy runs, create the CF Pages project so wrangler has somewhere to upload to:

pnpm dlx wrangler pages project create juriweb --production-branch=main

Wrapping up

So, to summarize:

  • we declare a deploy target for the apps we want to deploy
  • we use Nx and its affected command to automatically determine which apps require deployment
  • we use Cloudflare's wrangler CLI to deploy the app

The affected change detection doesn't depend on static globs but is dynamically calculated based on the Nx project graph and therefore works as we evolve our monorepo over time.

The same pattern works beyond Cloudflare Pages. Swap wrangler pages deploy for whatever your host's CLI is and the rest of the structure carries over.

Learn More