Kevin's getting-started guide takes you from an empty folder to a running site on Cloudflare. Along the way, Wrangler makes one decision for you, almost in passing: on your first deploy, it creates the database and the media bucket without a location. Cloudflare decides where they go, and you can't change that afterwards. If they end up far from your readers, every uncached page pays for the distance on every single query.
This article is about that one step: creating D1 and R2 yourself, in the right region, before your first deploy, and what to do if you're already past that point. Installation is in Kevin's guide. I checked every command against Wrangler 4.147.0 and the EmDash 1.1.0 blog template.
What happens on your first deploy
The template names the database and the bucket in wrangler.jsonc, with no IDs:
"d1_databases": [{ "binding": "DB", "database_name": "my-emdash-site" }],
"r2_buckets": [{ "binding": "MEDIA", "bucket_name": "my-emdash-media" }],If Wrangler can't find a resource by that name at deploy time, it creates one and tells you so: Creating new D1 Database "my-emdash-site" or Creating new R2 Bucket "my-emdash-media". According to the Wrangler 4.147.0 source, all it sends to the API is the name, with no location hint. You can't add one later either, because wrangler.jsonc has no location field for D1 or R2.
Without a hint, Cloudflare places the database close to wherever the create request came from, and R2 does the same. Deploy from a laptop in Munich and you'll usually land in Europe. But if your first deploy runs in CI or through a VPN exit, Cloudflare sees somewhere else entirely, and that's exactly the case the D1 docs give as a reason to pass a hint. It's happened to us: a database and a bucket, both created without a hint, landed in WNAM, Western North America, for sites whose readers are in Germany. Another database ended up in Eastern Europe (EEUR) instead of Western Europe. We didn't write down why at the time.
What the wrong region costs you
That sounds like a technicality. It gets expensive because EmDash asks the database several things, one after another, for every page that doesn't come from the edge cache, and each query is a full round trip. In August 2026 we counted on two sites running EmDash 0.3x: three to six queries per page on a warm Worker, twelve to sixteen after a cold start, when EmDash first loads plugin state, site settings and hooks.
From Munich, a query took about 140 ms against a database in WNAM and 20 to 25 ms against one in Eastern Europe (EEUR). The same code served a page after a cold start in 2.1 to 2.9 seconds with the database in WNAM, and in 0.85 to 1.2 seconds in EEUR. A cache hit took 30 to 40 ms either way.
So the edge cache hides the problem rather than solving it. You pay on every cache miss, after every save that purges the cache, and all the time in the admin, which is never cached. That's why your editors will notice the wrong region before your readers do.
Wrangler shows you where your resources are:
pnpm wrangler d1 info my-emdash-site # running_in_region row
pnpm wrangler r2 bucket info my-emdash-media # location rowFor a single page, EmDash's server-timing header breaks down the render, including the query count. It's cached along with the page, though, so when you measure, add a query parameter that changes on every request. That forces a miss.
If you haven't deployed yet, two commands spare you all of this.
Create D1 and R2 yourself, before you deploy
Names first. Name the Worker, the database and the bucket in wrangler.jsonc after your project. Wrangler finds resources by name, so if you keep my-emdash-site, your second EmDash project in the same account will connect to the first one's database.
"name": "my-blog",
"d1_databases": [{ "binding": "DB", "database_name": "my-blog" }],
"r2_buckets": [{ "binding": "MEDIA", "bucket_name": "my-blog-media" }],Then create them, with a location. Log in first with pnpm wrangler login.
pnpm wrangler d1 create my-blog --location weur --binding DB --update-config
pnpm wrangler r2 bucket create my-blog-media --location weur --binding MEDIA --update-config--binding DB --update-config writes the new database_id into the existing entry with the DB binding instead of adding a second one. Valid values for --location are weur, eeur, wnam, enam, apac and oc. Pick the one closest to your readers; for Germany, Austria and Switzerland, that's weur.
Then check. A location hint is a request, not a guarantee. Run d1 info and r2 bucket info as above, and only once both look right do you deploy as in Kevin's guide. If the deploy output still says Creating new, a name doesn't match.
Location hint or jurisdiction?
Both commands also take --jurisdiction. It sounds similar, but it's a different thing. A location hint is a best-effort performance choice. A jurisdiction such as eu is a guarantee that data is stored and processed inside the EU. What they have in common is that you can only set either one at creation.
If you pass both, the jurisdiction wins for D1, and read replicas are only created inside the jurisdiction. For R2, Wrangler refuses the combination. An R2 bucket with a jurisdiction also needs "jurisdiction": "eu" in its binding, and --update-config in Wrangler 4.147.0 doesn't write it. Leave it out and Wrangler won't find the bucket at deploy time, so it creates a new one, again with no location.
For a blog, you don't need any of that. --location weur is enough.
Already deployed? Read replication or placement
If your database is already in the wrong region, you don't have to move it right away. Two features take the edge off, though neither one fixes it.
Read replication adds a read-only copy in every D1 region at no extra cost. With the primary in WNAM, European visitors then read from Europe. It takes two switches. One is on the database, in the dashboard or through the REST API, since Wrangler can't do it. The other is session: "auto" in EmDash's d1(), which the blog template already sets.
Every write still goes to the primary, though, and writing is mostly what your editors do. There's also a trap: the global_fetch_strictly_public compatibility flag blocks the internal requests D1 uses to reach the replicas (emdash#1273). That used to make every page hang with no error logged. Since EmDash 0.34, EmDash gives up on such a query after five seconds, logs an error and carries on reading without replication. So pages load, but the first request in every new isolate waits five seconds, and replication does nothing for you. Even so, if a database is in the wrong region, I'd turn on read replication first and measure again. It often saves you the move.
Placement moves the Worker instead of the data. Normally it runs in the data center nearest the reader. With a placement hint, it runs close to its backend. The many queries get short, but every uncached request crosses the Atlantic once each way. That beats crossing it a dozen times, but it's not a European load time. Your editors benefit too, though, since their writes get short as well. According to the docs, you can't name a D1 database as the target, only an AWS, GCP or Azure region, so you pick one near your primary. The EmDash docs recommend exactly that and advise against read replication alongside it: session stays "disabled", so reads and writes both hit the nearby primary.
Smart Placement, the automatic variant, won't help much here. Per the docs, it only considers data centers where your Worker has already run, and if your readers are in Europe, North America isn't one of them. I haven't measured either with EmDash.
If neither is enough, the only fix left is a move.
Moving the database
A move comes in three stages: prepare, copy the data, then check and switch. Most of the work is in the copying.
Prepare
First, freeze writes. Nobody saves in the admin and nothing is scheduled to publish, because anything written after the export will be missing from the new database. The docs also note that a running export blocks other queries.
Then create the new database with a location, but this time without --update-config: pnpm wrangler d1 create my-blog-weur --location weur. If a deployed Worker binds it before the import, EmDash migrates and seeds it, and the import collides.
Copy the data
The simple whole-database export fails on almost every EmDash site. As soon as a collection is searchable, there are FTS5 tables, and D1 can't export databases with virtual tables. In the blog template, posts and pages are searchable. To find out whether you're affected:
pnpm wrangler d1 execute my-blog --remote --command "SELECT name FROM sqlite_master WHERE sql LIKE '%VIRTUAL TABLE%'"If that comes back empty, a one-piece export works: d1 export my-blog --remote --output dump.sql. Importing it with d1 execute my-blog-weur --remote --file dump.sql has a catch, though. Since EmDash 0.37, the media table references media_folders, but the dump lists media first. As soon as your media library holds a file, the import stops with no such table: main.media_folders. Move the CREATE TABLE IF NOT EXISTS "media_folders" line to right after the PRAGMA on the first line, or take the route below anyway.
If you do have search tables, you export table by table and leave them out. They're derived entirely from your content, and EmDash rebuilds the index on the first search.
Here's where the trap is. An export with --table carries tables and rows, but no indexes and no triggers, and counting rows won't tell you. We learned that the hard way: a moved database ran for weeks with a quarter of its indexes, and every upsert against a missing UNIQUE index failed silently. This is how you get everything:
# 1. Tables, without search tables and system tables
pnpm wrangler d1 execute my-blog --remote --json --command "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite%' AND substr(name, 1, 4) <> '_cf_' AND instr(name, '_fts_') = 0 ORDER BY rowid"
# 2. Schema and rows separately, with the same --table flags
pnpm wrangler d1 export my-blog --remote --no-data --table posts --table … --output schema.sql
pnpm wrangler d1 export my-blog --remote --no-schema --table posts --table … --output rows.sql
# 3. Indexes and triggers, minus the search ones, as a third file
pnpm wrangler d1 execute my-blog --remote --json --command "SELECT sql FROM sqlite_master WHERE type IN ('index', 'trigger') AND sql IS NOT NULL AND instr(sql, '_fts_') = 0"Import them in that order, each with d1 execute my-blog-weur --remote --file: schema, rows, then indexes and triggers. Schema first sidesteps the media_folders problem from above. Triggers last means none of them fires during the import.
With 70-odd tables in the blog template, that's tedious work.
Check and switch
Before you switch, compare old and new: row counts per table, plus the number of indexes and triggers. For the latter, run this on both sides:
SELECT type, count(*) FROM sqlite_master WHERE type IN ('index', 'trigger') AND sql IS NOT NULL AND instr(sql, '_fts_') = 0 GROUP BY typeIf the numbers match, three steps are left:
- Turn on read replication on the new database if you use it. It's a per-database setting and doesn't move with the data.
- Switch. Change
database_nameanddatabase_idinwrangler.jsonc, deploy, then check the home page, a post and signing in to the admin. - Keep the old database for a week. It's your way back, and Time Travel starts from zero on the new one.
If you'd rather not touch SQL at all, there's site transfer. Export the site as a package and bind the new database. In the setup wizard, choose to import an existing site, create the first admin, then upload the package. The package doesn't carry users, passkeys, API tokens or any plugin data and settings, though, and until you've created that admin, the wizard is open to anyone. For a one-person site, it may still be the shorter route. I haven't run it end to end for a move yet.
And the bucket?
That leaves the media bucket, which is luckily the easier part. An R2 bucket's location is fixed at creation too, but the keys don't change when you move it. Copy the objects into a new bucket, switch bucket_name, and keep the old one as your way back. Just don't upload anything in the meantime. It's also less urgent: an image costs one trip to the bucket, not a dozen, and usually sits in the cache after that. In August, fetching a file from Munich took us about 0.23 seconds with the bucket in WNAM, and 0.09 to 0.12 seconds after the move to WEUR.
pnpm wrangler r2 bucket create my-blog-media-weur --location weur
pnpm wrangler d1 execute my-blog --remote --json --command "SELECT storage_key, mime_type, size FROM media"
# for each object:
pnpm wrangler r2 object get my-blog-media/<key> --file tmp/<key> --remote
pnpm wrangler r2 object put my-blog-media-weur/<key> --file tmp/<key> --content-type <mime> --remoteThree things can trip you up:
- A new name. Per the docs, a location hint only counts the first time a bucket name is created. Delete the bucket and recreate it under the same name, and it gets the old location back.
- No listing. Wrangler can't list objects, so the keys come from the
mediatable. Compare their number withobject_countfromr2 bucket info, or you'll miss objects that aren't inmedia, such as the bundles of plugins installed from the registry. - Don't forget
--remote. Without it,wrangler r2 objectworks on local storage, andgetwrites an empty file even when the key doesn't exist. Check file sizes againstsize, not the exit code.
Conclusion
Kevin's guide gets you live, and that's its job. Just don't leave the location of D1 and R2 to Wrangler. Two commands before your first deploy let you set it yourself, and after that it costs you nothing. Skip them, and Cloudflare decides based on where the first request came from. A database in North America cost us one to two seconds per uncached page after a cold start. Read replication helps with reads, but your editors keep writing across the Atlantic. Only a move fixes it, and with a searchable EmDash database that's more work than a single export. Our own site, theweeklydash.com, runs with D1 and R2 in WEUR, and read replication is on for the database.
Checked against Wrangler 4.147.0 and the EmDash 1.1.0 blog template. The measurements are from August 2026 on EmDash 0.3x.

Comments
No comments yet
First-time comments appear once we've approved them. How we handle your details