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:

AddonKeyEntry
D1 databased1_databases{ "binding": "DB", "database_name": "<alias>", "preview_database_id": "<alias>" }
KV namespacekv_namespaces{ "binding": "CACHE", "id": "<alias>" }
R2 bucketr2_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_table and migrations_pattern on D1, preview_id on KV, preview_bucket_name and local_dev on R2, and remote on each kind.
  • It refuses database_id, jurisdiction and 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:

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:

  • production uses env.production when 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.
  • preview has one exception: a file that declares no binding in any section needs no env.preview. A file that declares a binding anywhere needs env.preview before 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 staging

The 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 deploy

A 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 staging

Delete 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.