• Aug 29, 2026
  • 5 min read

Deploy a Direct-Upload Cloudflare Pages Site from a Git Worktree

Two of my sites, a consulting landing page and a free ebooks library, are Cloudflare Pages projects without a Git provider. They are deployed by direct upload:

npm run build
npx wrangler pages deploy dist --project-name <project>

That is simple, and it hides a trap: Pages publishes whatever is in dist/, and dist/ is built from whatever is in the folder. Not what’s committed, not what’s on GitHub. The folder.

When I came back to both sites to wire their forms to my central forms Worker, the last deploys were four months old and the working copies were not clean.

What was in the folder

git status on the consulting site showed a dozen untracked files: draft sales-pitch pages, slide decks, a tooling folder. None of it was meant to be public, and two of the drafts were Astro pages under src/pages/, so a normal npm run build would have turned them into live URLs.

The ebooks site was tidier, but had the opposite problem: a commit of mine from June (a reworked ebook with new images and chart rendering) that had never been deployed. Deploying my change would publish that too.

Two questions before touching anything:

  1. What is live right now?
  2. What would go live if I deployed HEAD?

What is live: ask Pages, not your memory

Direct-upload deployments still record the commit they were made from:

npx wrangler pages deployment list --project-name <project> --json \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['Source'], d[0]['Status'])"

Source is the commit hash of the working copy at upload time (wrangler reads it from git). With that, the second question is a diff:

git diff --stat <live-commit>..HEAD -- src public astro.config.mjs package.json

For the consulting site the diff was empty: the three commits since the last deploy only touched talk materials outside src/. So a clean build of HEAD equals the live site, plus whatever I add. For the ebooks site the diff was the June rework, which I showed to the owner (me, wearing a different hat) and confirmed before publishing. Deploying someone’s never-deployed work is a decision, not a side effect.

Build from a worktree, not from the folder

A git worktree is a second checkout of the same repository in another directory, at any commit, sharing the object store. Untracked files don’t exist there by definition:

W=/tmp/site-build
git worktree add "$W" HEAD
cd "$W"
ln -s /path/to/repo/node_modules node_modules   # first attempt: reuse deps
npx astro build
npx wrangler pages deploy dist --project-name <project> --branch master --commit-dirty=true
cd - && git worktree remove --force "$W"
  • --branch master makes it a production deployment (the project’s production branch), not a preview.
  • --commit-dirty=true only silences wrangler’s “uncommitted changes” warning (I passed it out of habit; with a clean worktree it’s mostly a no-op). What matters is that the tracked files are exactly the commit.

I checked the result before deploying: the new section was in dist/index.html, the old third-party links were gone, and none of the draft pages existed in dist/.

The second site broke, usefully

The same recipe failed on the ebooks site:

[vite]: Rollup failed to resolve import "apexcharts" from
"src/pages/books/[slug]/[chapter].astro"

apexcharts was declared in package.json, but the local node_modules didn’t have it — added to the manifest at some point, never installed in this clone. The symlink shortcut copied that gap into the worktree.

The fix is the better default anyway: install from the lockfile inside the worktree.

rm -f node_modules
npm ci --no-audit --no-fund    # exact versions from package-lock.json
npm run build

npm ci is fast on a warm cache (six seconds here), never edits package.json or the lockfile, and it proves the lockfile is complete: if it builds in a fresh worktree with npm ci, it builds anywhere. The owner’s clone stayed untouched.

Keep the old site one checkout away

Before changing either repo I tagged the last commit before my work:

git tag -a pre-lead-hub <commit> -m "Last commit before the lead-hub migration"
git push origin pre-lead-hub

Nothing was deleted — the old survey scripts still live in the repo — but a named tag beats “it’s somewhere in history”. Rolling back the code is git checkout pre-lead-hub; rolling back the deployment is redeploying a build of that tag (or picking the old deployment in the Pages dashboard).

Verify the live site, not the build

After each deploy I fetched the production URL and counted what mattered: the new form present, the removed scripts absent, a deep page (a chapter) still answering 200. The forms themselves I had already exercised in a browser against the local build with mocked responses (Playwright route mocking).

Takeaways

  • Direct upload publishes the folder. Untracked drafts under src/pages/ become public pages on the next build.
  • Ask Pages what’s live (deployment list --json → Source), then diff that commit against HEAD to see exactly what a deploy would add.
  • Build from a git worktree of a commit, and npm ci inside it; symlinking node_modules can import the clone’s mistakes.
  • --branch <production branch> for production; check dist/ before uploading.
  • Tag the pre-change state and push the tag.
  • Publishing an unreleased commit is the owner’s call — ask.

If a site is git-connected, the opposite problem applies: nothing goes live until someone pushes, which is why I schedule daily rebuilds for future-dated posts.

Next: replacing a Formbricks qualifier with my own form.