Two sites, one codebase: multi-tenant Cloudflare Pages with separate D1s


When I built the photography portfolio , I wanted one repo to deploy two completely separate sites: alex.edestudio.us and jamie.edestudio.us . Same React app, same Pages Functions , same migrations, but two custom domains, two separate D1 databases, two independent photo catalogues, two admin areas behind Cloudflare Access .

I assumed Wrangler would let me do this with --env. It does not, not for Pages, not in 4.x. This post is how I got there anyway.


wrangler pages deploy reads only ./wrangler.jsonc. It rejects --config. It rejects --env. The Cloudflare docs on Pages Wrangler configuration say so explicitly. A Pages project is permanently tied to whatever name field is in the file it reads at deploy time.

That is fine if you run one Pages project per repo. It is not fine if you want to run two from the same code.

Branching in git is the easy solution that everyone reaches for first. I did not want it. Two branches means two histories drifting, two CI pipelines staying in sync by hand, and every shared feature landing twice. I wanted one branch, one CI run, two deploys.


Wrangler has an undocumented-feeling but officially supported indirection called the generated wrangler configuration . If a file exists at .wrangler/deploy/config.json with a configPath field, Wrangler reads the config from that path instead of the default wrangler.jsonc.

So that is what I do. A small script writes the redirect, calls wrangler pages deploy, and cleans up after itself.

scripts/deploy-pages.mjs:

import { execSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const deployDir = resolve(root, ".wrangler/deploy");
const redirectFile = resolve(deployDir, "config.json");

const site = process.argv[2] ?? "alex";
const configBySite = {
  alex: null,
  jamie: "wrangler.jamie.jsonc",
};

const targetConfig = configBySite[site];
const useRedirect = Boolean(targetConfig);

if (useRedirect) {
  mkdirSync(deployDir, { recursive: true });
  writeFileSync(
    redirectFile,
    `${JSON.stringify({ configPath: `../../${targetConfig}` })}\n`,
  );
}

try {
  execSync("npx wrangler pages deploy", { cwd: root, stdio: "inherit", env: process.env });
} finally {
  if (useRedirect && existsSync(redirectFile)) {
    rmSync(redirectFile);
  }
}

alex is the default and uses the regular wrangler.jsonc, so no redirect file is written. jamie points the redirect at wrangler.jamie.jsonc. The finally block deletes the redirect file once the deploy resolves, which matters locally so the next wrangler dev does not accidentally pick it up.

package.json is one extra script:

{
  "scripts": {
    "deploy": "npm run build && wrangler pages deploy",
    "deploy:jamie": "npm run build && node scripts/deploy-pages.mjs jamie"
  }
}

Once Wrangler can see either config, the rest is just per-site values.

// wrangler.jsonc (Alex)
{
  "name": "photography",
  "pages_build_output_dir": "./dist",
  "compatibility_date": "2026-05-13",
  "vars": {
    "ENVIRONMENT": "production",
    "IMAGE_DELIVERY": "flexible",
    "IMAGE_VARIANT_HERO": "hero"
    // ...image widths, qualities, suggest prompts
  },
  "ai": { "binding": "AI" },
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "photography",
      "database_id": "<alex-database-id>"
    }
  ]
}
// wrangler.jamie.jsonc (Jamie)
{
  "name": "photography-jamie",
  "pages_build_output_dir": "./dist",
  "compatibility_date": "2026-05-13",
  "vars": { /* same vars as Alex */ },
  "ai": { "binding": "AI" },
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "photography-jamie",
      "database_id": "<jamie-database-id>"
    }
  ]
}

Three fields differ: name (the Pages project), database_name, and database_id. Everything else is identical or trivially aligned. The DB binding name is the same on both, so the Pages Functions code never has to branch on which site it is running under.


Two photographers, two catalogues. Some options I considered:

  1. One D1, one set of tables, a site_id column on every row. Cheapest. But every public query has to filter by site_id, every join has to remember it, and a missed filter means one site’s gallery leaks into the other.
  2. One D1, two schemas (separate table sets with a prefix). Less risky than option 1 because the table names diverge, but migrations get awkward and I have to thread the prefix through query code.
  3. Two D1 databases, one schema per site. Isolation is structural. The Pages Function for one site literally cannot see the other site’s data because the binding points elsewhere. Migrations apply to each independently.

I picked option 3. The cost is tiny (D1 is generous on the free tier and almost free at this scale) and the upside is “the data physically cannot leak between sites”. Isolation is structural rather than a query-level convention, which matters when the two catalogues should never overlap.

Cloudflare Images stays account-wide. Variants, account hash, and the API are shared because there is no per-site state in any of that. Photos are isolated by the D1 row that points at a given cf_image_id, not by the storage layer.


Both deploys run from the same Gitea workflow on every push to main. The whole file is short:

# .gitea/workflows/deploy-pages.yml
name: Deploy Pages

on:
  push:
    branches: [main, master]
  workflow_dispatch:

jobs:
  deploy-alex:
    name: Alex (photography)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "22" }
      - run: npm ci
      - run: npm run check
      - name: Build and upload to Cloudflare Pages
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
        run: npm run deploy

  deploy-jamie:
    name: Jamie (photography-jamie)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "22" }
      - run: npm ci
      - run: npm run check
      - name: Build and upload to Cloudflare Pages
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
        run: npm run deploy:jamie

Both jobs run in parallel. One push, two Pages deploys, two Cloudflare URLs updated. If only one job fails, the other still ships.

CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are repo secrets in Gitea. Other Cloudflare secrets like CF_API_TOKEN and IMAGES_ACCOUNT_HASH live on each Pages project’s settings page, not in CI, because they are runtime values used by Pages Functions rather than build-time values used by Wrangler.


Each custom domain needs its own Cloudflare Access protection. I made the mistake of leaving the Access application on photography.pages.dev after adding alex.edestudio.us as a custom domain. The result: hitting /admin on the custom domain showed a login page that never completed.

Access only protects what you tell it to. Adding alex.edestudio.us to the existing application (or creating a second self-hosted app for it) fixed it in one click. Same later for jamie.edestudio.us. Cloudflare has a known-issues page dedicated to this specific failure mode, which I found only after I had already debugged it.

A small operational detail: keep Access pointed at /admin*, /api/admin*, and optionally /api/whoami. Do not put it on /, /photos/*, or /api/public/*, or your public gallery stops being public.


For a third or fourth site, I would skip the per-site config files and lift the differing fields into a top-level sites.json that deploy-pages.mjs reads. The current two-file approach is fine for two sites, but it would be a copy-paste tax at five.

I would also wire D1 migrations into the same CI workflow as a manual workflow_dispatch job. Right now I run npm run db:migrate:remote and npm run db:migrate:jamie:remote from my laptop, which is fine until I forget.


  1. Building a photography portfolio on Cloudflare’s full stack. The stack overview: Pages, D1, Images, Access, and Workers AI.
  2. Two sites, one codebase. The scripts/deploy-pages.mjs indirection, per-site D1, and Gitea CI. (this post)
  3. Cloudflare Images flexible delivery and retina srcSet. Variant setup, srcSet generation, and the original-download endpoint.
  4. Direct Creator Upload from the browser. Why a Pages Function should not proxy your image bytes, and what the three-request handshake looks like in DevTools.
  5. Stripping EXIF before upload and backfilling existing photos. What gets stripped, what stays, and the npm run backfill:exif script.
  6. AI-assisted captions, alt text, and tags. The Workers AI vision-model dispatcher and where it helps versus where it confidently invents.

The codebase pattern is in production right now: same repo, two domains, two databases, one push to ship both. The Cloudflare Pages and Wrangler docs are worth a read if you are adapting this pattern.

If you are running into the same Wrangler 4.x constraint and want to compare notes, reach out on LinkedIn .

×
Page views: