Local development
How to run Metric Vault on your machine with wrangler pages dev, the dist/ copy trap that wastes more time than anything else here, and what cannot be tested locally.
Last updated 2026-09-14
Summary#
There is nothing to install and nothing to build. Clone the repository, put a .dev.vars file next to wrangler.toml, copy the files you care about into dist/, and run wrangler pages dev dist. The site comes up on http://127.0.0.1:8788 with the real worker in front of it.
The one thing that will cost you an afternoon if nobody tells you: the dev server serves dist/, not the repository root. Read the next section before you write a line of code.
The dist/ copy trap#
Warning: wrangler pages dev dist serves the dist/ copy of the site, not the files you are editing. After you change dashboard.html, _worker.js, admin.html, index.html or any other top-level file, you must copy it into dist/ or your change will not appear locally. This wastes more developer time in this repository than anything else.
Why it works this way: the project is a Cloudflare Pages project in advanced mode, and wrangler.toml declares pages_build_output_dir = "dist". Wrangler therefore treats dist/ as the published folder both in CI and locally. dist/ is git-ignored and rebuilt from scratch by the deploy workflow, so it is not a mirror of anything: locally it holds whatever you last put there.
The symptom is always the same and never looks like a stale file. You edit, reload, and see the old behavior. You add a console.log, reload, and it does not appear. Nothing errors. You start doubting the edit.
Two habits fix it permanently:
- Copy on every save. Keep a one-line command in your shell history and run it before each reload.
- When something is inexplicable, check the timestamp of the file in
dist/first, before you debug the code.
Be aware that your local dist/ can hold files CI would never ship, because nothing prunes it between runs. compare.html and index-legacy.html are the usual leftovers: neither exists at the repository root any more, but a dist/ built before they were removed still has them. Do not treat local dist/ as evidence of what is in production.
Purpose#
Local development exists to let you iterate on the worker and the app without pushing to main, because a push to main goes live immediately. Everything that can be verified locally should be, and the small set of things that cannot be is listed below so you do not spend an hour trying.
Requirements#
| Requirement | Detail |
|---|---|
| Node.js | Node 24 is what the verification scripts in tests/ are run with. The deploy workflow itself uses Node 22, but it runs no scripts |
| Wrangler | Invoked through npx. There is no lockfile and no local install to maintain |
.dev.vars | At the repository root. See Environment variables and secrets for every name and what breaks without it |
| A Cloudflare login | Only if the [ai] Workers AI binding stays enabled. Workers AI has no local simulator, so wrangler pages dev needs wrangler login |
| Nothing else | No package.json, no npm install, no bundler, no node_modules |
Permissions#
You need write access to the repository and, for anything that touches real provider data, valid API credentials in .dev.vars. You do not need access to the Cloudflare Pages project to develop locally. You do need it to change production configuration, and you need repository secrets access to change how deploys authenticate. Those are covered in Deployment.
Navigation Path#
repository root
├── wrangler.toml pages_build_output_dir = "dist"
├── .dev.vars local secrets, git-ignored
└── dist/ what the dev server actually servesLocal base URL: http://127.0.0.1:8788.
Step-by-Step Guide#
- Clone the repository and change into it. There is no install step.
- Create
.dev.varsat the root. OneNAME=valueper line, no quotes required. Start with the providers you need for the feature you are building; the worker degrades rather than crashing when a key is absent, so a partial file is fine.
`` DATAFORSEO_LOGIN=... DATAFORSEO_PASSWORD=... OPENAI_API_KEY=... ANTHROPIC_API_KEY=... MV_INTERNAL_SECRET=any-local-value ``
> Warning: .dev.vars is git-ignored through the glob .dev.vars*. > Never rename a copy to something outside that glob. A .dev.vars.bak-crlf > backup once got committed with every live key in it.
- Stage the site into
dist/. The simplest correct approach is to run the same copy the deploy workflow runs: createdist/, copy every top-level*.html, copy the named root files (_worker.js,_headers,_routes.json,sw.js,manifest.json,chat-widget.js,pwa-install.js,blog-embed.js,chatbot.js,robots.txt,sitemap.xml, the icons and logos), and copy the directoriesicons free-tools reports legal js css imagesrecursively.
- Start the dev server.
``bash npx wrangler pages dev dist ``
Wrangler reads wrangler.toml, applies the D1, R2 and Workers AI bindings, and serves on http://127.0.0.1:8788. Local D1, KV and R2 state lives under .wrangler/state/v3/, which is git-ignored.
- Edit a file, then copy it into
dist/again, then reload. This is the loop. Editing without copying changes nothing you can see.
- If you edited
login.html, re-sync the inlined copy.
``bash node tests/login-inline-sync.mjs --write ``
_worker.js intercepts GET /login and /login.html and returns an inlined const LOGIN_HTML template literal instead of the static asset. It was added to dodge a Pages caching bug that served stale or zero-byte HTML. Nothing regenerates that copy automatically, so editing login.html alone has no effect anywhere. Commit both files. The no-argument form is a drift check that exits 1.
- Run the gates before you commit. At minimum
node tests/design-lint.mjsfor UI changes andnode tests/chkblk.mjs dashboard.htmlafter editing any inline script. The full list is in Verification scripts.
Driving background jobs locally#
Cloudflare Pages never calls scheduled(), so no job will fire on a timer on your machine either. Trigger the whole set by hand:
curl -X POST http://127.0.0.1:8788/api/cron/run \
-H "x-mv-internal-secret: $MV_INTERNAL_SECRET"Add ?job=monitor (or schedules, rank_alerts, tier2, crawl, sweep_orphans, purge_results, purge_watchtower, sweep_caches, higgsfield, social_schedule, blog_schedule, cms_schedule, billing_sync, google_sync, ga4_sync, gsc_inspect, gsc_vitals, backlink_watch) to run one. An unknown value returns 400 with the valid list. The jobs self-throttle in the database, so calling this repeatedly is safe. See Background jobs and scheduling.
What cannot be tested locally#
| Thing | Why |
|---|---|
Workers AI (env.AI): /api/translate | Workers AI only runs remotely. There is no local simulator, and wrangler pages dev requires wrangler login while [ai] is enabled. Comment the binding out for fully offline work |
Anything needing a key absent from .dev.vars: Stripe, Resend, the Supabase service key, Pexels, DeepL, Google OAuth | Those names are deliberately not in the local file |
| Cron timing | Pages never fires scheduled(). Drive it with the explicit POST above |
/free-tools/*, /ext-preview.html, /legal, the logo PNGs | Those routes fetch their content from raw.githubusercontent.com at request time, so they serve committed main content, not your working tree. Editing those files locally changes nothing until the change is pushed |
| Stripe webhooks | The endpoint fails closed without STRIPE_WEBHOOK_SECRET, and a real signature cannot be forged |
R2 is the exception in the other direction: wrangler pages dev simulates the BLOG_MEDIA bucket locally, so blog media work needs no remote credentials.
Line endings#
dashboard.html and legal.html use CRLF. A search-and-replace written for \n matches nothing in them; write \r?\n. login.html is also CRLF, but ECMAScript normalises CRLF to LF inside a template literal, so the inlined copy is LF and login-inline-sync.mjs compares line-ending agnostically. Several public pages have mixed endings, which is why site-chrome-sync.mjs preserves untouched regions byte for byte.
Troubleshooting#
| Symptom | Likely cause | Fix |
|---|---|---|
| An edit has no visible effect | The file was not copied into dist/ | Copy it, then hard-reload. Check the timestamp in dist/ |
An edit to login.html has no effect | /login is served from the inlined LOGIN_HTML in _worker.js | node tests/login-inline-sync.mjs --write, then copy _worker.js into dist/ |
| A navbar or footer edit is reverted | The chrome is stamped from partials/ | Edit partials/site-nav.html or partials/site-footer.html, then node tests/site-chrome-sync.mjs --write |
MONITOR_DB not bound. Wrangler may need a redeploy. | The bindings were not applied | You passed a positional directory to wrangler, or wrangler.toml is not being read. Run wrangler pages dev dist from the repository root |
wrangler pages dev asks you to log in | The [ai] binding is enabled and Workers AI is remote-only | Run wrangler login, or comment out the [ai] block for offline work |
/api/cron/run returns 503 | MV_INTERNAL_SECRET is not in .dev.vars | Add any value locally. The check fails closed on purpose |
/api/diag/* returns 403 | Same secret, or the ?key= value does not match | Append ?key=<MV_INTERNAL_SECRET> |
| A free-tools page shows old content | Those pages are fetched from GitHub raw at request time | Push the change, or test the underlying /api/tools call directly |
A search-and-replace matches nothing in dashboard.html | CRLF line endings | Use \r?\n |
FAQs#
Do I need to run npm install? No. There is no package.json anywhere in the repository. Wrangler is invoked through npx, and the scripts in tests/ use only Node's standard library.
Can I just point the dev server at the repository root instead of dist/? Wrangler reads pages_build_output_dir from wrangler.toml, and the deploy path depends on that value being dist. Changing it to work around the copy step would change what CI publishes. Copy into dist/ instead.
Why does CI never hit this problem? Because the deploy workflow builds dist/ from scratch on every run. The trap is purely local: your dist/ persists between runs and drifts from your working tree.
How do I test as a signed-in user? Sign in normally against Supabase. tests/login-verify.cjs automates it with Playwright, taking MV_EMAIL and MV_PW from the environment only, and writes a screenshot. It expects the dev server on http://127.0.0.1:8788.
Is local D1 the same as production D1? No. Local D1 lives in .wrangler/state/v3/d1 and starts empty. Tables are created lazily by the worker on first use, so most features build their own schema the first time you exercise them. See Schema migrations.
How do I check my local copy matches what would ship? Compare the staging step in .github/workflows/deploy.yml against your dist/. Anything in dist/ that the workflow does not stage will never reach production, and anything it stages that you did not copy will behave differently locally.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.