- 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:
- What is live right now?
- 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 mastermakes it a production deployment (the project’s production branch), not a preview.--commit-dirty=trueonly 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 againstHEADto see exactly what a deploy would add. - Build from a
git worktreeof a commit, andnpm ciinside it; symlinkingnode_modulescan import the clone’s mistakes. --branch <production branch>for production; checkdist/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.