Production and staging repos
Why Metric Vault lives in two repositories, which files must stay identical, which three must never be copied, and how compare-repos.mjs measures the drift.
Last updated 2026-08-06
Summary#
Metric Vault is developed in two repositories that hold the same application code and deliberately different infrastructure. metricvaultai is production: it serves metricvaultai.com through the Cloudflare Pages project named metricvaultai. metricvaultai-seo is staging, and it deploys to metricvaultai-seo.pages.dev. Most feature work happens in staging first and is then ported across.
The model works because the split is precise. Six paths are supposed to be byte-identical, three must never be copied in either direction, and one script tells you which side of that line every difference falls on. Copying one of the three has caused a production outage before.
Purpose#
Two repositories exist to separate the risk of shipping code from the risk of changing infrastructure.
Production has no build step and no approval gate: a push to main is live within about a minute. A repository that is both the place experiments happen and the place customers are served offers no safe way to try something. Staging absorbs that. It has its own Pages project, its own bindings, its own database and its own deploy workflow, so a change can be exercised end to end without any possibility of touching live customer data.
The cost of that separation is drift. Two copies of the same 1.7 MB worker and 7.2 MB dashboard diverge quietly, and a port that overwrites the wrong file takes production down. Everything below exists to make drift visible and to make the dangerous files obvious.
Overview#
What each repository is#
| Production | Staging | |
|---|---|---|
| Repository | metricvaultai | metricvaultai-seo |
| Serves | metricvaultai.com | metricvaultai-seo.pages.dev |
| Cloudflare project | metricvaultai | its own |
| Production branch | main. A push is live immediately | its own |
| Bindings | Its own D1 database, KV namespace, R2 bucket and Workers AI binding | Separate instances of the same shapes |
| Secrets | Set on the Cloudflare Pages project | Set on its own project |
Both are Cloudflare Pages projects in advanced mode with a hand-written _worker.js at the top of the published folder, and neither has a build step. The shape is described in System architecture.
The three files that must never be ported#
Copying any of these between the repositories replaces this project's infrastructure with the other project's.
| File | What it holds | What happens if you copy it |
|---|---|---|
wrangler.toml | This project's name, output directory and every binding | The live site loses its bindings and fails with MONITOR_DB not bound. Wrangler may need a redeploy. This has happened |
.github/workflows/deploy.yml | This project's deploy, including the Cloudflare project name it targets | The deploy points at the wrong Pages project, or stages the wrong file set |
.dev.vars | Local secrets | Never committed at all. It is git-ignored through the glob .dev.vars* because a .dev.vars.bak-crlf backup once slipped past a bare rule with every live key in it |
Warning: production's wrangler.toml is in Pages format, with pages_build_output_dir = "dist". It must not be converted to Workers format. The header comment in the file records the outage: the file previously used the Workers format while the workflow deployed with wrangler pages deploy, so the D1 and KV bindings were never applied to the deployment.
What should stay in sync#
Everything that is the product rather than the plumbing:
_worker.js, dashboard.html, js/, css/, the top-level *.html pages, legal/, and free-tools/.
If one of those differs between the repositories, that is a real finding to investigate, not a normal state of the world.
What is expected to differ#
compare-repos.mjs classifies these as infrastructure and reports them separately rather than failing on them: wrangler.toml, .assetsignore, .gitignore, sitemap.xml, CLAUDE.md, README.md, plus everything under .github/ and cron-worker/. CLAUDE.md and README.md are per-repository onboarding documents, so each one describing its own repository is correct, not drift.
How it works#
Checking the drift before you port#
node tests/compare-repos.mjs ../metricvaultai-seo-mainWith one argument the script compares the current working directory against the given directory. With two, it compares the two directories you name.
What it ignores. Directories .git, dist, node_modules, .wrangler, images and icons. Files .dev.vars, server.log and .DS_Store. Any file with a binary extension: png, jpg, jpeg, gif, ico, webp, woff, woff2, ttf, pdf, mp4, zip.
How it compares. Content is hashed with SHA-256 after stripping every carriage return and a single trailing newline, so CRLF against LF and a final-newline difference never read as a real change. That matters here, because dashboard.html and legal.html are CRLF and several public pages have mixed line endings.
What it prints. A header with both paths and the number of text files compared, then four counts: identical, differ, only in A and only in B. Then the section that matters, APP CODE that differs (these SHOULD match; investigate each), listing each file with its line count on both sides. Then a separate infra/config that differs (usually fine) list, then the files present on only one side, each tagged [infra: expected to differ] where that applies.
The semantic diff. Line-by-line equality is a weak signal on two files this large, so the script also extracts and compares feature sets: every /api route in _worker.js and every id="view-..." in dashboard.html. It reports the count on each side and names the routes or views present on one side only. This is the fastest way to answer "did the port actually bring the feature across".
The exit code. 1 if any non-infrastructure file differs, 0 otherwise. Infrastructure differences never fail the run.
It closes with the command to see the real changes in any one file:
git diff --no-index "<other-repo>/<file>" "<this-repo>/<file>"Note: the report labels the two sides A (this/staging) and B (other/prod), because the script was written to be run from staging. When you run it from production against the staging checkout, as CLAUDE.md instructs, those labels are reversed: A is production and B is staging. Read the paths in the header, not the labels.
Porting work across#
- Run the drift check first, before you copy anything, so you know what was already different and do not attribute it to your port.
- Copy only application code.
_worker.js,dashboard.html, the specific files underjs/andcss/, the pages you changed. Never the three infrastructure files. - Watch for new files. A ported feature that introduced a new directory needs that directory staged by the deploy workflow, or its HTML ships and its assets 404. This repository once staged a hardcoded 24-file allowlist and produced exactly that failure, which is why the workflow now copies every top-level
*.htmland stages by directory rather than by filename. See Deployment. - Re-run the generated copies. Porting
login.htmlwithout re-runningnode tests/login-inline-sync.mjs --writeleaves production serving the old inlined page. Porting a public page withoutnode tests/site-chrome-sync.mjs --writeleaves its navbar and footer stale. Both have no-argument drift checks that exit 1. - Re-run the drift check and confirm the app-code list is empty.
- Run the gates for whatever you touched, then push. Every script is documented in Verification scripts.
Preview branches are not a substitute#
Within production, any branch listed in the deploy workflow's trigger gets its own Cloudflare Pages preview at <branch>.metricvaultai.pages.dev, with the same D1 and KV bindings, and never touches the live site. Today that is main and redesign.
That is useful for reviewing a change on a real URL. It is not a staging environment, because the bindings are the production ones: a preview branch writes to the production database. Anything that mutates data, changes a schema or exercises a background job belongs in the staging repository, not on a preview branch.
Where drift usually comes from#
| Source | Why it happens | What to do |
|---|---|---|
| A fix applied in production during an incident | Nobody ports it back to staging afterwards | Port it back the same day |
| A new asset directory | The other repository's deploy does not stage it | Add it to the directory loop in that repository's workflow |
| A generated file | js/mv-i18n-dict.js and the inlined LOGIN_HTML are regenerated per repository | Regenerate rather than copy |
| Different provider keys | Staging may lack a key production has | Expected. It changes behavior, not code |
| A per-repository document | CLAUDE.md and README.md describe their own repository | Expected. Classified as infrastructure |
See also
Was this article helpful?
Thanks — feedback noted for the docs team.