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:
- 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.
- 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=3That 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 permissionAccount → 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 viawrangler 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=mainWrapping up
So, to summarize:
- we declare a
deploytarget 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.








