Bindings and environments
An app keeps data in addons: D1 databases, KV namespaces and R2 buckets that its workspace owns. The app's wrangler.jsonc binds each addon by its alias. Each target of the app has an environment, and the environment selects the section of wrangler.jsonc that the target's deployments bind.
You need an app, from Getting started, and an addon for the data. district addon create creates an addon and prints its alias.
Bind an addon
Create the addons first. An addon binds to no app when you create it. A workspace holds at most 10 D1 databases, 10 R2 buckets and 3 KV namespaces. The alias is the hadd_ id in lower case with the underscore replaced by a hyphen. It does not change when you rename the addon.
# One database that production and every preview share
district addon create d1 orders
# A separate database for the staging environment
district addon create d1 orders-staging
# The alias of each addon
district addon list --kind d1 --json | jq -r '.[] | "\(.name) \(.alias)"'Each binding key takes a list of entries. An entry has a binding name and the addon's alias, in the field of its kind:
| Addon | Key | Entry |
|---|---|---|
| D1 database | d1_databases | { "binding": "DB", "database_name": "<alias>", "preview_database_id": "<alias>" } |
| KV namespace | kv_namespaces | { "binding": "CACHE", "id": "<alias>" } |
| R2 bucket | r2_buckets | { "binding": "FILES", "bucket_name": "<alias>" } |
{
"name": "shopfront",
"main": "worker.ts",
"compatibility_date": "2026-08-01",
"d1_databases": [
{
"binding": "DB",
"database_name": "hadd-01k4x0j8w1qz5b7n3g2r6tq9yd",
"preview_database_id": "hadd-01k4x0j8w1qz5b7n3g2r6tq9yd",
},
],
"kv_namespaces": [{ "binding": "CACHE", "id": "hadd-01k4x0j8w1qz5b7n3g2r6tq9ye" }],
"r2_buckets": [{ "binding": "FILES", "bucket_name": "hadd-01k4x0j8w1qz5b7n3g2r6tq9yf" }],
}On a D1 entry, preview_database_id holds the alias too. The build ignores it, and wrangler dev uses it as the key of the local database.
The build checks each entry in every section of the file:
- It ignores the fields that only local Wrangler tooling reads:
preview_database_id,migrations_dir,migrations_tableandmigrations_patternon D1,preview_idon KV,preview_bucket_nameandlocal_devon R2, andremoteon each kind. - It refuses
database_id,jurisdictionand every other field. District creates the resource, so a configuration names it only by its alias. - It refuses a value that is not the alias of an addon, and a binding name that is empty, longer than 128 characters, or that starts with
DISTRICT_. - A binding name is unique in its section. It also differs from the assets binding and from each name in the top-level
vars.
A refused build fails at its export step, and its message names each refused path, such as d1_databases[0].database_id. The deployment checks each alias again: an alias that is not a ready addon of the key's kind in the app's workspace fails the deployment. The message names the binding and the alias. It does not say whether the addon exists in another workspace.
The CLI and the API show no Cloudflare identifier. The alias is the only name of the resource that you write. district addon show lists the apps that bind an addon.
Give each environment a section
Each target has an environment:
- The primary target uses
production. - Each preview target uses
preview. - Another durable target uses
production, or the name that district target create <slug> --env <name> gives it. district target set-env changes it later.
An environment name starts with a lower-case letter and has at most 32 lower-case letters, digits and hyphens. The environment selects the bindings of the target's deployments:
productionusesenv.productionwhen the file has that section. Otherwise, it uses the bindings at the root of the file.- Every other environment uses its own
env.<name>section. A deployment to a target whose section is missing fails, and its message names the section to add. previewhas one exception: a file that declares no binding in any section needs noenv.preview. A file that declares a binding anywhere needsenv.previewbefore it deploys to a preview target.
A section does not inherit the root bindings. List every binding in each section, with the same binding names, so the same code runs in each environment. A section with no d1_databases key binds no D1 database. Two sections can name the same alias. The addresses of those environments then share one database. In the example, each preview uses the production data, and staging has its own addons.
{
"name": "shopfront",
"main": "worker.ts",
"compatibility_date": "2026-08-01",
"vars": { "SHOP_NAME": "Corner Shop" },
// production: the primary target and every other target in "production"
"d1_databases": [
{
"binding": "DB",
"database_name": "hadd-01k4x0j8w1qz5b7n3g2r6tq9yd",
"preview_database_id": "hadd-01k4x0j8w1qz5b7n3g2r6tq9yd",
},
],
"kv_namespaces": [{ "binding": "CACHE", "id": "hadd-01k4x0j8w1qz5b7n3g2r6tq9ye" }],
"r2_buckets": [{ "binding": "FILES", "bucket_name": "hadd-01k4x0j8w1qz5b7n3g2r6tq9yf" }],
"env": {
// a durable target created with --env staging
"staging": {
"d1_databases": [
{
"binding": "DB",
"database_name": "hadd-01k4x0j8w1qz5b7n3g2r6tq9yg",
"preview_database_id": "hadd-01k4x0j8w1qz5b7n3g2r6tq9yg",
},
],
"kv_namespaces": [{ "binding": "CACHE", "id": "hadd-01k4x0j8w1qz5b7n3g2r6tq9yh" }],
"r2_buckets": [{ "binding": "FILES", "bucket_name": "hadd-01k4x0j8w1qz5b7n3g2r6tq9yj" }],
},
// every preview target, with the production addons
"preview": {
"d1_databases": [
{
"binding": "DB",
"database_name": "hadd-01k4x0j8w1qz5b7n3g2r6tq9yd",
"preview_database_id": "hadd-01k4x0j8w1qz5b7n3g2r6tq9yd",
},
],
"kv_namespaces": [{ "binding": "CACHE", "id": "hadd-01k4x0j8w1qz5b7n3g2r6tq9ye" }],
"r2_buckets": [{ "binding": "FILES", "bucket_name": "hadd-01k4x0j8w1qz5b7n3g2r6tq9yf" }],
},
},
}The deployment reads main, compatibility_date, compatibility_flags, assets and vars from the root only. A section can repeat them for local Wrangler tooling, and District ignores the repeated values.
# A durable target in the staging environment
district target create shop-staging --env staging
# Move a durable target to another environment
district target set-env next stagingThe primary target always uses production, and a preview target always uses preview. Neither environment can change. A durable target in another environment cannot become primary: district target set-primary refuses it until district target set-env moves it to production.
Deploy to each target
A build does not depend on an environment. A deployment reads the environment of its target when it is created, selects that environment's section, and records the addon that each binding names. The deployment keeps those bindings. A later change to wrangler.jsonc, to the target's environment, or to an addon's display name does not change a deployment that exists.
# Deploy to the staging target: it binds env.staging
district deploy --target shop-staging
# Deploy to the primary target: it binds the root bindings
district deployA target serves only a deployment of its own environment. district target set-deployment refuses a deployment made for another environment. Its message names the target's environment and the command that deploys the build to the target. A deployment of a preview target therefore cannot serve the primary target. Deploy the same build to the primary target instead: district deploy --from <hdep_…> makes a new deployment of the build of that deployment, in the environment of the target, and runs no new build. A deployment made before environments existed has no environment and can serve a target of any environment.
$ district target set-deployment shopfront hdep_01K4…
deployment hdep_01K4… was made in another env than shopfront, which uses env production. Deploy its build to shopfront with: district deploy --from hdep_01K4… --target shopfront
$ district deploy --from hdep_01K4… --target shopfront --yes
build hbld_01K4… (9f1c2e4…)
hostname shopfront.ondistrict.net
environment production
✔ deployment hdep_01K5… succeeded
pinned https://hdep-01k5….ondistrict.net/ · serves this deployment only, not the site address
env production
https://shopfront.ondistrict.net/A build keeps its files for 7 days. After that, the API refuses a new deployment of the build, and its message says to build the repository again.
A rollback points a target at an earlier deployment of the same environment. It moves the hostname to the earlier code and its recorded bindings. It does not change the data in an addon.
When district target set-env changes a target's environment, the target keeps its current deployment until a deployment for the new environment finishes. When that deployment was made in the earlier environment, the command prints Environment change pending deployment and the district deploy --from command that deploys its build in the new environment. A deployment that was created for the earlier environment and finishes after the change fails and does not serve the target.
Manage the data
The app reaches an addon only through its binding, as env.DB, env.CACHE or env.FILES. District has no SQL console, no data browser and no migration command, and a build step has no binding. The app applies its own D1 schema when it runs.
The example applies each change in MIGRATIONS once, in order, and records it in a migrations table of the same database. Each change and its record run in one batch, so a change that fails records nothing. When two requests apply the same change at the same time, the batch of the second request fails, changes nothing, and fails that request. The next request finds the change recorded.
/// <reference types="@cloudflare/workers-types" />
type Env = { DB: D1Database }
// Append a change; never edit or remove an earlier one.
const MIGRATIONS = [
'create table orders (id text primary key, total integer not null)',
'alter table orders add column note text',
]
export default {
async fetch(_request, env) {
await migrate(env.DB)
const { results } = await env.DB.prepare('select id, total, note from orders').all()
return Response.json(results)
},
} satisfies ExportedHandler<Env>
async function migrate(db: D1Database): Promise<void> {
await db.exec('create table if not exists migrations (version integer primary key)')
const applied = await db.prepare('select max(version) as version from migrations').first<{ version: number | null }>()
for (const [index, statement] of MIGRATIONS.entries()) {
const version = index + 1
if (version > (applied?.version ?? 0)) {
await db.batch([db.prepare(statement), db.prepare('insert into migrations (version) values (?)').bind(version)])
}
}
}- Make only additive changes: add tables and nullable columns. Do not rename or drop a table or a column, and do not change the type of a column. A preview of a new version runs its schema code on the shared database before the version goes live, and a rollback runs earlier code against the changed schema.
- When two apps bind the same database, one app applies all of its migrations. District does not coordinate the schema between apps.
- A rollback does not undo a schema change or a data change.
- The only recovery is the one Cloudflare provides: D1 Time Travel for a D1 database. District promises no recovery for R2 objects or KV values.
Run the app locally
wrangler dev uses the same file. It keys a local namespace on id, a local bucket on bucket_name and a local database on preview_database_id. With the alias in these fields, it keeps one local store for each alias. A D1 entry without preview_database_id uses the binding name as its key, so sections that use the same binding name share one local database. The alias names no Cloudflare resource in your own account, so keep remote absent or false. --env <name> selects a section, as a target's environment does. The migrations in the app create the local schema on the first request.
# The root bindings, with local storage
pnpm exec wrangler dev
# The env.staging bindings, with their own local storage
pnpm exec wrangler dev --env stagingDelete an addon
district addon delete destroys the addon and its data. The API refuses the deletion while a retained deployment binds the addon. A deployment is retained while its script exists: District removes the script 14 days or more after the deployment stops serving. The refusal lists the references in the apps that you can see, and counts the others.
To delete an addon, remove its binding from each section, and deploy to each target that bound it. Then wait until the earlier deployments are removed. Deleting an app deletes no addon. An R2 bucket must also be empty.