Every Deploy Wiped the D1 Binding — Declarative Config Means Undeclared Is Deleted
Lynsi's redeem endpoint returned 1101 forever. The cause: wrangler pages deploy syncs the Pages project's bindings to whatever it parses out of the config file, and that project's config file had no bindings — they'd been attached in the dashboard, so every deploy synced them back to None. Why that behaviour is correct, what each of the two fixes costs, and why another Pages project on the same account is completely unaffected.
When Lynsi shipped, there was a launch promotion, and the site got a self-service page for claiming discount codes. The endpoint was a Pages Function; the codes lived in D1.
In production the endpoint returned 1101, every time. 1101 is the Cloudflare Workers runtime’s “your code threw” error, and the exception here was a missing D1 binding — env.REDEEM_DB was undefined.
Except the binding was attached. Open the dashboard and there it is: right name, right database. Re-attach it and the endpoint works immediately. Then deploy once, and it’s broken again.
pages deploy syncs bindings, not just files
Measured behaviour, consistent across wrangler 4.123 and 4.127:
wrangler pages deploy syncs the Pages project’s binding configuration to whatever it parses out of wrangler.toml, on every deploy.
And that project’s wrangler.toml never had any bindings — they’d been added by hand in the dashboard. So wrangler parsed “no bindings” and faithfully made production match.
This is not a bug. This is the standard semantics of declarative configuration: the config file is the only truth, and undeclared means deleted. Terraform works this way. kubectl apply works this way. wrangler works this way. What makes it bite here:
- the dashboard offers an editing interface that looks persistent
- and a CLI deploy silently overwrites it — no warning, no confirmation, no mention in the output
Two entry points with different ideas about the source of truth for the same state, and one of them wins quietly.
Another Pages project on the same account is untouched
This site — mahui.me — is also a Pages project, also using D1 (comments) and KV (feedback). I ran wrangler pages deploy on it four times today. The D1-backed endpoint stayed at 200 throughout.
One difference: its bindings are declared in the config file.
{
"name": "mahui-me",
"pages_build_output_dir": "dist",
"kv_namespaces": [
{ "binding": "FEEDBACK", "id": "8ff90a5612d0474bb7078f815666ee04" }
],
"d1_databases": [
{ "binding": "COMMENTS", "database_name": "mahui-comments", "database_id": "..." }
]
}
wrangler parses those two bindings, the sync result matches production, and nothing changes.
So the trigger condition is “the binding exists only in the dashboard” — not a defect in wrangler. Same tool, same command, opposite outcomes on two projects, and the entire difference is whether it’s written down.
Two ways out
One: put the bindings in the config file
The direct fix, and what mahui.me does. It has a side benefit: bindings land in version control, so who changed what and when is recorded, and a fresh clone isn’t missing configuration.
The cost is that identifiers like database_id end up in the repo. They aren’t secrets — possessing one grants no access, since authorisation is per-account — so that cost is usually fine.
Two: move the endpoint to a standalone Worker
This is what Lynsi did, for two reasons: the Pages config for that project is managed by another toolchain, and Workers bindings go through their own deploy path, which has no such sync behaviour — configure once and it stays.
name = "lynsi-redeem"
main = "src/index.js"
compatibility_date = "2026-08-01"
workers_dev = false
[[d1_databases]]
binding = "REDEEM_DB"
database_name = "lynsi-redeem"
database_id = "7f0ca9c8-fcd7-413d-aeb5-29bc25926207"
The dead Pages Function was deleted, and the Pages wrangler.toml got a comment to stop a future reader from putting the binding back:
The redeem endpoint is not here: wrangler’s
pages deploysyncs the Pages project’s bindings to whatever it parses from this file, and it always parses “no bindings” — so every deploy wipes the D1 binding attached via dashboard/API. Do not add any bindings to this file.
That comment is the most valuable line in the whole fix. Six months from now nobody remembers why the redeem endpoint lives somewhere odd, and “moving it back into a Pages Function would be tidier” is an extremely natural thought to have.
Three traps in the migration
One: deploying a Worker finds the Pages config above it
Run wrangler deploy inside redeem-worker/ and wrangler searches parent directories for a config file, finds the site’s Pages wrangler.toml, and deploys against that.
You have to be explicit:
npx wrangler deploy --config wrangler.toml
A design that treats “nearest config wins” as a convenience becomes a trap in nested projects.
Two: workers_dev must precede every [[table]]
TOML’s rule: every key after a table header belongs to that table. So this is wrong:
[[d1_databases]]
binding = "REDEEM_DB"
...
workers_dev = false # now a field of d1_databases, not a top-level setting
workers_dev = false has to come before any [[...]]. Getting it wrong doesn’t error — the setting is silently ignored, and the *.workers.dev entry stays open.
The reason for closing it is practical: *.workers.dev resolves and connects unreliably from mainland China, so leaving it open just publishes a side door users can’t open.
Three: cross-origin, and then back again
The first version had the page on the Pages domain and the endpoint on the Worker domain — cross-origin calls with a CORS allowlist (the site domain plus the pages.dev preview domain). It worked, but it added an OPTIONS preflight, and the allowlist was one more piece of configuration that has to track your environments.
The final shape puts the Worker on a path under the site’s own hostname:
[[routes]]
pattern = "lynsi.app.mahui.me/api/redeem*"
zone_name = "mahui.me"
Measured: Worker routes take precedence over a Pages custom domain. /api/redeem* is served by the Worker; every other path on the site still goes to Pages. Same origin, so CORS and the preflight both disappear — and availability improves as a side effect: if the site loads, the endpoint loads, with no dependency on a second hostname’s reachability.
This version’s real advantage over the CORS one: one fewer thing that can break by itself.
How to catch it sooner
The shape of this bug is “it un-fixes itself,” with a deploy in between the two failures. Fix it, test by hand, deploy a few days later, and the causal link between action and breakage is gone — it gets attributed to “Cloudflare being flaky.”
Two cheap habits:
Run a smoke check after deploying, not before. Deployment is the action most likely to disturb runtime configuration, so verification belongs after it. This one is trivial: GET returns remaining=100; POST yields a real link, and a repeat from the same IP idempotently returns the same one.
Treat “I changed it in the dashboard” as a to-do, not as done. The dashboard is a good interface for exploring and for firefighting, but anything changed there should be written back into the config file the same day. Otherwise its lifespan is exactly “until the next CLI deploy.”
In one line
“Sync” in a declarative deployment tool means deletion. When a tool promises to make production match your config file, it is simultaneously promising that anything absent from that file will be removed. That second half is the important half, and it’s usually not in the paragraph of documentation you happen to be reading.
Comments