Introduction
This guide deploys dreamhunter2333/cloudflare_temp_email as a private temporary-email service on Cloudflare.
It complements the project’s official documentation and official quick start. Use those pages for the complete feature reference and this post for a reviewed, automation-oriented GitHub Actions workflow.
The workflow favors explicit, verifiable steps:
- inputs are named variables instead of hard-coded identifiers;
- commands prefer machine-readable output and explicit read-back checks;
- secrets enter through hidden prompts or standard input;
- deployments stop when the reviewed commit does not match the dispatched commit;
- destructive boundaries, especially MX changes, require human confirmation.
For every Cloudflare or GitHub mutation, the automated path appears first and the dashboard equivalent follows. Choose one mutation path; use the other only to verify the resulting state.
Protect existing email before you beginEnabling Cloudflare Email Routing changes the domain’s MX records. If the domain already receives mail through Google Workspace, Microsoft 365, Fastmail, or another provider, use a separate domain or prepare a tested migration and rollback plan.
Overview
The finished deployment serves the web interface and Worker from one custom domain, stores mail in D1, and routes all incoming addresses to the Worker.
This guide uses Worker Static Assets, so only the backend workflow is required:
- enable
Deploy Backend; - keep
Upstream Syncdisabled during the initial deployment; - keep
Deploy Frontenddisabled; - keep
Deploy Frontend with page functiondisabled.
The two frontend workflows are alternative split-frontend designs. They are not extra steps for Worker Static Assets. Automatic upstream sync stays disabled until the first deployment passes verification. The Maintenance section shows how to enable it and trigger its first run.
Deployment phases
| Phase | Outcome | Human checkpoint |
|---|---|---|
| Prepare | Reviewed source and named inputs | Confirm account and domain |
| Provision | D1 and private Worker config | Confirm names and scopes |
| Deploy | Protected, reviewed Worker | Create and rotate tokens |
| Route mail | Catch-all to the Worker | Approve MX replacement |
| Verify | Web, Worker, routing, and D1 | Send a safe test message |
Prerequisites
Accounts and domain
You need:
- a GitHub account that can create a fork and repository secrets;
- a Cloudflare account with an active zone;
- a domain that is not carrying email you need to preserve;
- permission to create a Worker, D1 database, custom domain, and Email Routing rules.
Deployment inputs
Decide these values before creating Cloudflare or GitHub resources:
ROOT_DOMAIN: the domain that receives mail, such asexample.com;MAIL_WEB_DOMAIN: the Webmail hostname, such asmail.example.com;WORKER_NAME: the deployed Worker name;D1_DATABASE_NAME: the remote database name;- site access and administrator passwords: two different, non-empty values.
The D1 UUID is generated later. The JWT secret is generated locally immediately before it is uploaded, so neither value needs to be prepared manually.
Cloudflare credentials and permissions
The CLI and website paths use the same least-privilege credential plan.
Using the CLI
Use separate credentials for interactive setup, the routing REST call, first CI deployment, and later CI deployments.
| Credential | Used from | Resource scope | Required access | Lifetime and storage |
|---|---|---|---|---|
| Wrangler OAuth | Local terminal | Accounts and zones available to the signed-in member | OAuth scopes plus the member’s Cloudflare permissions; Wrangler requests all available scopes by default | Use --use-keyring; run wrangler logout after one-time setup |
| Routing API token | Local REST helper | Target account and ROOT_DOMAIN | Zone Read; Email Routing Rules Write | Store in a password manager; load only into CF_ROUTING_API_TOKEN; revoke after routing read-back |
| CI bootstrap token | GitHub Actions | Target account and ROOT_DOMAIN | Workers product Admin; Zone Read; Workers Routes Write | Temporarily store as repository secret CLOUDFLARE_API_TOKEN; replace and revoke after the first successful steady-state deployment |
| CI steady-state token | GitHub Actions | Existing WORKER_NAME | Editor on the selected Worker | Store as repository secret CLOUDFLARE_API_TOKEN until planned rotation or revocation |
Wrangler OAuth persists access and refresh credentials locally. --use-keyring
encrypts them with a key from the operating-system keychain. The last three rows
are API tokens; never reuse one token across those roles.
Cloudflare’s older token interface may show Edit where newer documentation uses
Write. The capability is the same.
The later CLI phases show where to load the routing token and how to save each CI token as the GitHub repository secret. Wrangler OAuth remains local and is never copied into GitHub.
Using the Cloudflare website UI
- Open the Cloudflare dashboard’s API Tokens page.
- Select Create Token, then create a custom token.
- Add the permissions for the token type listed above.
- For the routing token, restrict Zone Resources to
ROOT_DOMAIN. - For the bootstrap token, select Workers product → Admin for only the
intended account. Add the two zone permissions for only
ROOT_DOMAIN. - After the Worker exists, create the steady-state token with
Individual Workers → Editor and select only
WORKER_NAME. Do not choose the product-level Worker scope for this token. - Create each token, copy it once, and store it in a password manager.
Create the routing and bootstrap tokens initially. After the Worker exists, create the steady-state per-Worker token, replace the GitHub repository secret, and prove one deployment succeeds with it. Only then revoke the bootstrap token.
Local tools
Install the reusable tools with Homebrew. The formula is named
cloudflare-wrangler, but it provides the wrangler command. Then use mise to
install Bun:
brew install gh jq mise bind cloudflare-wranglermise use --global bun@latest- Bun
- GitHub CLI
jqcurlopenssldig(dnsutilsorbind-utilson many Linux distributions)
Verify the tools:
bun --versionwrangler --versiongh --versionjq --versionAuthenticate GitHub and Cloudflare:
gh auth logingh auth status
wrangler login --use-keyringwrangler whoamiWrangler’s Email Routing commands may still be marked as beta. Read the local help before relying on a flag:
wrangler email routing --helpwrangler email routing rules update --helpPhase 1: Prepare the source and account
Set session variables
Replace the example domains in your private terminal. Do not publish the edited block.
set +x
export UPSTREAM_REPO="dreamhunter2333/cloudflare_temp_email"export ROOT_DOMAIN="example.com"export MAIL_WEB_DOMAIN="mail.example.com"export WORKER_NAME="cloudflare_temp_email"export D1_DATABASE_NAME="temp-email-db"
export GITHUB_OWNER="$(gh api user --jq .login)"export REPOSITORY="${GITHUB_OWNER}/cloudflare_temp_email"set +x prevents shell tracing from echoing later values. It does not repair a
secret that has already reached logs or shell history. Revoke any exposed token.
Fork and clone the repository
For a new fork:
gh repo fork "$UPSTREAM_REPO" --clone --default-branch-onlycd cloudflare_temp_emailIf the fork already exists:
gh repo clone "$REPOSITORY"cd cloudflare_temp_emailgit remote add upstream \ "https://github.com/${UPSTREAM_REPO}.git" \ 2>/dev/null || trueFork with the GitHub website
- Open the upstream repository on GitHub.
- Select Fork, choose the destination owner, and select Create fork.
- On the new fork, select Code and copy its HTTPS URL.
- Clone that URL with GitHub Desktop or your preferred Git client.
- Add the upstream repository as an
upstreamremote when it is absent.
Inspect the exact source that will be deployed:
git remote -vgit status --short --branchgit show --no-patch --oneline HEADResolve the Cloudflare account
If Wrangler reports exactly one account, extract its ID without copying terminal text:
export CLOUDFLARE_ACCOUNT_ID="$( wrangler whoami --json | jq -er ' .accounts | if length == 1 then .[0].id else error("more than one account; choose explicitly") end ')"For multiple accounts, inspect the candidates and enter the intended ID privately:
wrangler whoami --json | jq -r '.accounts[] | [.name, .id] | @tsv'
printf 'Cloudflare Account ID: ' >&2IFS= read -r CLOUDFLARE_ACCOUNT_IDexport CLOUDFLARE_ACCOUNT_IDFind the Account ID in Cloudflare
- Open the Cloudflare dashboard and select the intended account.
- Open the account or zone Overview page.
- Find Account ID in the account details panel and copy it privately.
- Compare the account name with
wrangler whoamibefore using the ID.
Phase 2: Provision storage and configuration
Create and initialize D1
Create the remote database. Change the location hint when appropriate.
wrangler d1 create "$D1_DATABASE_NAME" --location wnamResolve the database UUID from JSON:
export D1_DATABASE_ID="$( wrangler d1 list --json | jq -er --arg name "$D1_DATABASE_NAME" \ '[.[] | select(.name == $name)] | if length == 1 then .[0].uuid else error("database name is missing or not unique") end')"Apply the schema from the same reviewed commit as the Worker code:
wrangler d1 execute "$D1_DATABASE_NAME" \ --remote \ --file db/schema.sql \ --yesVerify that the remote tables exist:
wrangler d1 execute "$D1_DATABASE_NAME" \ --remote \ --command "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;" \ --json | jqCreate D1 in the Cloudflare dashboard
- Go to Storage & Databases → D1 SQL Database.
- Select Create Database, enter
D1_DATABASE_NAME, choose an optional location, and select Create. - Copy the database ID from the new database’s details page.
- Open the database’s Console tab.
- Paste
db/schema.sqlfrom the same reviewed repository commit and run it. - Open Tables or rerun the table query in Console to verify initialization.
Do not paste a schema copied from a different upstream revision.
Generate a private bootstrap configuration
Create a private temporary directory that will be deleted when the shell exits:
umask 077export PRIVATE_TMP="$(mktemp -d)"trap 'rm -rf "$PRIVATE_TMP"' EXITexport BACKEND_TOML_PATH="$PRIVATE_TMP/backend.toml"Create the file shown above. Replace the example domain and D1 UUID with the values resolved earlier. Address creation starts disabled so the first deployment cannot become an open mailbox service before passwords are installed.
name = "cloudflare_temp_email"main = "src/worker.ts"compatibility_date = "2025-04-01"compatibility_flags = ["nodejs_compat"]keep_vars = true
routes = [ { pattern = "mail.example.com", custom_domain = true }]
[assets]directory = "../frontend/dist/"binding = "ASSETS"run_worker_first = true
[triggers]crons = ["0 0 * * *"]
[vars]PREFIX = "tmp"DOMAINS = ["example.com"]DEFAULT_DOMAINS = ["example.com"]ENABLE_USER_CREATE_EMAIL = falseENABLE_USER_DELETE_EMAIL = trueENABLE_ADDRESS_PASSWORD = true
[[d1_databases]]binding = "DB"database_name = "temp-email-db"database_id = "<D1_DATABASE_ID>"After saving the file, restrict its permissions:
chmod 600 "$BACKEND_TOML_PATH"Do not put JWT_SECRET, PASSWORDS, or ADMIN_PASSWORDS in this file. Upload
them as Worker secrets. Do not copy the file into the repository: even without
passwords, it contains a real domain and D1 UUID.
Understand dashboard configuration ownership
After the Worker exists, the dashboard can add the DB binding under
Settings → Bindings and the hostname under Settings → Domains & Routes.
However, the next GitHub Actions deployment reconciles those settings from
BACKEND_TOML. Treat the TOML secret as the source of truth and use the dashboard
for verification or recovery, not as an independent long-term configuration.
Phase 3: Deploy a protected Worker
Create a short-lived bootstrap token
Cloudflare’s current granular permission model requires Workers product-level Admin
to create a Worker. Updating an existing Worker can use single-Worker Editor.
Create a temporary bootstrap token with only:
| Scope | Permission | Why |
|---|---|---|
| Target account | Workers product: Admin | Create the first Worker deployment |
| Target zone | Workers Routes: Write | Attach the custom domain |
| Target zone | Zone: Read | Resolve the selected zone |
It does not need DNS, Email Routing, or D1 edit access. The D1 database already exists and is only referenced as a binding. Older token interfaces may use legacy permission names; check the current Workers permissions documentation instead of granting all resources.
Enter the token without placing it in a command argument:
CF_CI_API_TOKEN=''while [ -z "$CF_CI_API_TOKEN" ]; do printf 'Non-empty Cloudflare Bootstrap Token: ' >&2 IFS= read -r -s CF_CI_API_TOKEN printf '\n' >&2done
printf '%s' "$CF_CI_API_TOKEN" | gh secret set CLOUDFLARE_API_TOKEN --repo "$REPOSITORY"
unset CF_CI_API_TOKENSave the bootstrap token manually
- On Cloudflare’s API Tokens page, create the bootstrap token with the three scopes listed above.
- On the GitHub fork, open Settings → Secrets and variables → Actions.
- Select New repository secret.
- Name it
CLOUDFLARE_API_TOKEN, paste the token, and select Add secret.
Configure GitHub Actions secrets
The backend workflow reads this repository-secret contract:
CLOUDFLARE_ACCOUNT_ID(required): select the Cloudflare account;CLOUDFLARE_API_TOKEN(required): authenticate the deployment;BACKEND_TOML(required): supply the Worker configuration;USE_WORKER_ASSETS(required here): bundle Webmail into the Worker;BACKEND_USE_MAIL_WASM_PARSER(recommended): enable the WASM parser;DEBUG_MODE(optional): print detailed output when set totrue.
JWT_SECRET, PASSWORDS, and ADMIN_PASSWORDS are Worker runtime secrets, not
GitHub Actions secrets. Upload them later with wrangler secret bulk.
printf '%s' "$CLOUDFLARE_ACCOUNT_ID" | gh secret set CLOUDFLARE_ACCOUNT_ID --repo "$REPOSITORY"
gh secret set BACKEND_TOML \ --repo "$REPOSITORY" \ < "$BACKEND_TOML_PATH"
printf '%s' 'true' | gh secret set USE_WORKER_ASSETS --repo "$REPOSITORY"
printf '%s' 'true' | gh secret set BACKEND_USE_MAIL_WASM_PARSER --repo "$REPOSITORY"
printf '%s' 'false' | gh secret set DEBUG_MODE --repo "$REPOSITORY"Add repository secrets in GitHub
- Open Settings → Secrets and variables → Actions → Secrets on the fork.
- Select New repository secret for each name in the contract above.
- Paste the corresponding value and select Add secret.
- For
BACKEND_TOML, paste the complete contents of the private TOML file. - Return to the secrets list and confirm that all six names are present.
GitHub shows secret names and update times after saving, but never reveals the values.
Keep DEBUG_MODE=false. A public fork’s detailed Wrangler output can reveal domains,
Worker names, bindings, D1 identifiers, and deployment IDs.
Verify names and timestamps without attempting to read secret values:
gh secret list --repo "$REPOSITORY"The GitHub workflow installs its own Node.js and pnpm. Neither needs a separate manual setup step; Homebrew manages the runtime dependency of its Wrangler formula.
Enable only the required workflow
gh workflow enable backend_deploy.yaml --repo "$REPOSITORY"gh workflow disable sync.yaml --repo "$REPOSITORY"gh workflow disable frontend_deploy.yaml --repo "$REPOSITORY"gh workflow disable frontend_pagefunction_deploy.yaml --repo "$REPOSITORY"
gh workflow list --repo "$REPOSITORY" --allEnable workflows in GitHub
- Open the fork’s Actions tab and enable workflows if GitHub shows the fork confirmation banner.
- Select Deploy Backend and choose Enable workflow if it is disabled.
- Open each unused frontend workflow’s menu and choose Disable workflow.
- Keep Upstream Sync disabled until the initial deployment is verified.
Define a fail-closed deployment function
The function below refuses dirty or mismatched source, verifies the dispatched
headSha, and cancels the run if GitHub does not execute the reviewed commit.
deploy_reviewed_main() ( set -euo pipefail
local deploy_commit remote_main run_url run_id run_head deploy_commit="$(git rev-parse HEAD)" remote_main="$(gh api "repos/${REPOSITORY}/commits/main" --jq .sha)"
git status --short --branch git show --no-patch --oneline "$deploy_commit"
if [ -n "$(git status --porcelain)" ]; then printf 'Refusing to deploy a dirty working tree.\n' >&2 return 1 fi
if [ "$deploy_commit" != "$remote_main" ]; then printf 'Local HEAD does not match remote main.\n' >&2 return 1 fi
run_url="$( gh workflow run backend_deploy.yaml \ --repo "$REPOSITORY" \ --ref main )"
if [ -z "$run_url" ]; then printf 'GitHub CLI did not return a workflow URL.\n' >&2 return 1 fi
run_id="${run_url##*/}" run_head="$( gh run view "$run_id" \ --repo "$REPOSITORY" \ --json headSha \ --jq .headSha )"
if [ "$run_head" != "$deploy_commit" ]; then gh run cancel "$run_id" --repo "$REPOSITORY" printf 'Canceled run: dispatched commit did not match.\n' >&2 return 1 fi
gh run watch "$run_id" \ --repo "$REPOSITORY" \ --compact \ --exit-status)Run the protected bootstrap deployment:
deploy_reviewed_mainRun the initial deployment in GitHub
- Confirm the reviewed commit is the current commit on the fork’s
mainbranch. - Open Actions → Deploy Backend.
- Select Run workflow, choose
main, and select Run workflow again. - Open the new run and verify its commit SHA immediately.
- If it differs from the reviewed commit, select Cancel workflow and stop.
- Only when the SHA matches, wait for every deployment step to succeed.
Install application secrets
Use different, non-empty site and administrator passwords:
SITE_PASSWORD=''ADMIN_PASSWORD=''
while [ -z "$SITE_PASSWORD" ]; do printf 'Non-empty site access password: ' >&2 IFS= read -r -s SITE_PASSWORD printf '\n' >&2done
while [ -z "$ADMIN_PASSWORD" ]; do printf 'Non-empty admin password: ' >&2 IFS= read -r -s ADMIN_PASSWORD printf '\n' >&2done
if [ "$SITE_PASSWORD" = "$ADMIN_PASSWORD" ]; then printf 'Site and admin passwords must be different.\n' >&2else jq -cn \ --arg jwt "$(openssl rand -base64 48)" \ --arg site "$SITE_PASSWORD" \ --arg admin "$ADMIN_PASSWORD" \ '{ JWT_SECRET: $jwt, PASSWORDS: ([$site] | tojson), ADMIN_PASSWORDS: ([$admin] | tojson) }' | wrangler secret bulk --name "$WORKER_NAME"fi
unset SITE_PASSWORD ADMIN_PASSWORDVerify that all three secret names exist:
wrangler secret list --name "$WORKER_NAME"Add Worker secrets in Cloudflare
- Generate a JWT secret locally with
openssl rand -base64 48. - Go to Workers & Pages → your Worker → Settings.
- Under Variables and Secrets, select Add and choose Secret.
- Add
JWT_SECRETwith the generated value. - Add
PASSWORDSas a JSON array string containing the site password. - Add
ADMIN_PASSWORDSas a JSON array string containing the admin password. - Add all three changes to one version, then select Deploy.
Do not create these as plaintext variables.
Open https://$MAIL_WEB_DOMAIN in a private browser window. Confirm that no password
and an incorrect password are rejected, while the site password succeeds.
Only after that check should address creation be enabled:
awk ' /^ENABLE_USER_CREATE_EMAIL = false$/ { print "ENABLE_USER_CREATE_EMAIL = true" next } { print }' "$BACKEND_TOML_PATH" > "$BACKEND_TOML_PATH.next"
mv "$BACKEND_TOML_PATH.next" "$BACKEND_TOML_PATH"chmod 600 "$BACKEND_TOML_PATH"
gh secret set BACKEND_TOML \ --repo "$REPOSITORY" \ < "$BACKEND_TOML_PATH"
deploy_reviewed_mainEnable address creation in GitHub
- Change
ENABLE_USER_CREATE_EMAILfromfalsetotruein the retained private$BACKEND_TOML_PATHfile. - Open Settings → Secrets and variables → Actions → Secrets.
- Select
BACKEND_TOML, then select Update secret. - Paste the complete revised TOML file and save the secret.
- Run Deploy Backend using the protected SHA-check procedure above.
Rotate to a steady-state token
Create a new token with Editor scoped only to the existing $WORKER_NAME. Because
the custom-domain connection already exists and remains unchanged, future deployments
do not need zone route write access. A later route change will fail closed.
CF_CI_API_TOKEN=''while [ -z "$CF_CI_API_TOKEN" ]; do printf 'Non-empty steady-state Worker Editor token: ' >&2 IFS= read -r -s CF_CI_API_TOKEN printf '\n' >&2done
printf '%s' "$CF_CI_API_TOKEN" | gh secret set CLOUDFLARE_API_TOKEN --repo "$REPOSITORY"
deploy_reviewed_mainunset CF_CI_API_TOKENRotate the CI token manually
- On Cloudflare’s API Tokens page, create a token with Editor access scoped only to the existing Worker.
- On GitHub, open Settings → Secrets and variables → Actions.
- Select
CLOUDFLARE_API_TOKEN, choose Update secret, and paste the new token. - Run Deploy Backend using the protected SHA-check procedure above.
- Return to Cloudflare’s API Tokens page and revoke the bootstrap token.
For either path, do not leave the broad and narrow tokens active together.
Phase 4: Route incoming email
Preserve the current DNS state
The next function stores complete MX and TXT responses in a persistent private file.
It fails if either DNS query fails or returns a status other than NOERROR.
snapshot_email_dns() ( set -euo pipefail
local backup_dir stamp snapshot mx_result txt_result backup_dir="${XDG_STATE_HOME:-$HOME/.local/state}/cloudflare-email-routing" stamp="$(date -u +%Y%m%dT%H%M%SZ)" snapshot="$backup_dir/${ROOT_DOMAIN}-${stamp}.txt"
mkdir -p "$backup_dir" chmod 700 "$backup_dir"
mx_result="$( dig +noall +comments +answer MX "$ROOT_DOMAIN" )" txt_result="$( dig +noall +comments +answer TXT "$ROOT_DOMAIN" )"
printf '%s\n' "$mx_result" | grep -q 'status: NOERROR' printf '%s\n' "$txt_result" | grep -q 'status: NOERROR'
{ printf '# Captured at %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf '\n## MX\n%s\n' "$mx_result" printf '\n## TXT\n%s\n' "$txt_result" } > "$snapshot"
chmod 600 "$snapshot" printf '%s\n' "$snapshot")
DNS_SNAPSHOT="$(snapshot_email_dns)"export DNS_SNAPSHOTless "$DNS_SNAPSHOT"Snapshot DNS in Cloudflare
- Select the zone in Cloudflare and open DNS → Records.
- Record or export every existing MX record and mail-related TXT record, including SPF values and TTLs.
- Save the snapshot outside the repository in a private location.
- Identify the provider behind each MX record before changing Email Routing.
Human confirmation: MX replacementIf the snapshot contains MX records, identify the service behind every record. Do not continue until losing that service is acceptable and its restoration procedure is documented. Wrangler’s
dns getshows Cloudflare’s requirements; it does not inventory the provider currently serving your email.
Read the current Email Routing state and Cloudflare’s required DNS changes:
wrangler email routing settings "$ROOT_DOMAIN"wrangler email routing dns get "$ROOT_DOMAIN"Prepare catch-all API access
Wrangler’s help lists worker as an action type, but its catch-all validation still
rejects Worker actions. Use Cloudflare’s catch-all REST endpoint with the local
routing API token instead.
Skip this API preparation when using the dashboard path in the next section.
Read the token without exposing it in command history, then resolve the target zone:
CF_ROUTING_API_TOKEN=''while [ -z "$CF_ROUTING_API_TOKEN" ]; do printf 'Non-empty Cloudflare routing API token: ' >&2 IFS= read -r -s CF_ROUTING_API_TOKEN printf '\n' >&2doneexport CF_ROUTING_API_TOKEN
cloudflare_auth_header() { printf 'Authorization: Bearer %s\n' "$CF_ROUTING_API_TOKEN"}
export CF_ZONE_ID="$( curl --fail --silent --show-error --get \ "https://api.cloudflare.com/client/v4/zones" \ --header @<(cloudflare_auth_header) \ --data-urlencode "name=$ROOT_DOMAIN" \ --data-urlencode "account.id=$CLOUDFLARE_ACCOUNT_ID" | jq -er ' if .success and (.result | length == 1) then .result[0].id else error("target zone is missing or not unique") end ')"Define update and read-back helpers. The token is passed through an anonymous file descriptor instead of a process argument:
update_worker_catch_all() ( set -euo pipefail
local api_url enabled payload api_url="https://api.cloudflare.com/client/v4/zones" api_url+="/${CF_ZONE_ID}/email/routing/rules/catch_all" enabled="${1:?enabled must be true or false}" payload="$( jq -cn \ --arg worker "$WORKER_NAME" \ --argjson enabled "$enabled" \ '{ actions: [{type: "worker", value: [$worker]}], matchers: [{type: "all"}], enabled: $enabled, name: "Send catch-all to temp email Worker", source: "api" }' )"
printf '%s' "$payload" | curl --fail --silent --show-error \ --request PUT \ "$api_url" \ --header @<(cloudflare_auth_header) \ --header 'Content-Type: application/json' \ --data-binary @- | jq -e ' if .success then .result else error(.errors | map(.message) | join("; ")) end ')
read_worker_catch_all() ( local api_url="https://api.cloudflare.com/client/v4/zones" api_url+="/${CF_ZONE_ID}/email/routing/rules/catch_all"
curl --fail --silent --show-error \ "$api_url" \ --header @<(cloudflare_auth_header) | jq -e ' if .success then .result else error(.errors | map(.message) | join("; ")) end ')Enable routing and the Worker catch-all
wrangler email routing enable "$ROOT_DOMAIN"update_worker_catch_all trueConfigure Email Routing in Cloudflare
- Go to Compute → Email Service → Email Routing.
- Select Onboard Domain and choose
ROOT_DOMAIN. - Review the MX and TXT records that Cloudflare will add, then select Done.
- Select the domain and open Routing Rules.
- Edit or enable Catch-all rule.
- Set Action to Send to a Worker and select
WORKER_NAME. - Set the rule to Active and select Save.
Read everything back:
wrangler email routing settings "$ROOT_DOMAIN"wrangler email routing dns get "$ROOT_DOMAIN"read_worker_catch_allRoll back Email Routing
To reverse the routing change, disable the catch-all before disabling Email Routing:
update_worker_catch_all falseread_worker_catch_allwrangler email routing disable "$ROOT_DOMAIN"Roll back routing in Cloudflare
For a provider migration without an intentional mail outage:
- Go to Compute → Email Service → Email Routing and select
ROOT_DOMAIN. - Under Settings, unlock the routing MX, SPF, and DKIM records.
- In DNS → Records, add the former provider’s records from the private snapshot.
- Verify the replacement provider’s required records and mail flow.
- Return to Routing Rules and disable Catch-all rule.
- Return to Settings, select Disable Email Routing, and confirm.
- Verify that the replacement provider’s records remain and still receive mail.
If the goal is to stop receiving mail entirely, skip steps 2–4, disable the catch-all, then disable Email Routing.
Disabling Email Routing removes Cloudflare-managed routing records but does not
restore a previous provider. Restore its records from $DNS_SNAPSHOT and the saved
configuration. Never commit the snapshot or attach it to a public issue.
After the routing read-back succeeds, unset and revoke CF_ROUTING_API_TOKEN.
Create a new narrowly scoped token if API rollback is needed later. Dashboard
rollback does not require this token.
Verification
Worker deployment
wrangler deployments list \ --name "$WORKER_NAME" \ --json | jqVerify the Worker in Cloudflare
- Go to Workers & Pages and select
WORKER_NAME. - Open Deployments and confirm the latest deployment succeeded.
- Under Settings → Domains & Routes, confirm
MAIL_WEB_DOMAINis active. - Under Settings → Bindings, confirm the D1 binding is named
DB.
Web interface
curl --fail --silent --show-error \ --output /dev/null \ --write-out '%{http_code}\n' \ "https://${MAIL_WEB_DOMAIN}/"Expect HTTP 200, then verify that the site password is required and create a test
address.
Administration and database state
Open https://$MAIL_WEB_DOMAIN/admin and sign in as an administrator. Under
Quick Setup → Database, confirm the schema is healthy. Reinitialize only when
the current migration guide requires it.
End-to-end delivery
Watch only error events in one terminal:
wrangler tail "$WORKER_NAME" \ --format pretty \ --status errorSend a non-sensitive message from an external mailbox to the test address. In another terminal, inspect the newest D1 rows:
wrangler d1 execute "$D1_DATABASE_NAME" \ --remote \ --command \ 'SELECT address, created_at FROM raw_mails ORDER BY id DESC LIMIT 5;' \ --json | jqVerify delivery in Cloudflare
- Open the Worker in Workers & Pages, then open its live logs or observability view before sending the test message.
- Send the test message and confirm that the Worker invocation has no exception.
- Open Storage & Databases → D1 SQL Database → your database → Console.
- Run the same
SELECTquery and confirm the new address and timestamp appear.
The deployment is complete only when all of these are true:
- The catch-all rule is enabled and targets the intended Worker.
- The Worker error tail remains clean during the test.
- Webmail displays the test message.
- D1 contains the test address with a reasonable timestamp.
Do not test with real invoices, one-time passwords, resumes, recovery links, or other sensitive mail.
Maintenance
Review upstream changes before deployment
Keep Upstream Sync disabled if every release must be reviewed. Inspect releases,
the changelog, configuration changes, and D1 migrations before merging an update:
git fetch upstreamgit log --oneline HEAD..upstream/maingit diff --stat HEAD...upstream/maingit diff --name-only HEAD...upstream/main -- dbReview upstream in GitHub
- Open the fork and select Sync fork → Compare instead of updating immediately.
- Review the upstream Releases and CHANGELOG since the deployed revision.
- Inspect changed files, especially
.github/workflows,db, and Worker config. - Apply required D1 migrations before deploying code that depends on them.
- Merge only the reviewed update into the fork’s
mainbranch.
Back up D1 before an upgrade. If upstream documents a schema migration, run the
specific reviewed migration before dispatching Deploy Backend. Do not replay the
full schema blindly against a production database.
Optional: enable automatic upstream sync
The project’s
official auto-update guide
uses the Upstream Sync workflow. Enable it only after the initial deployment and
end-to-end verification succeed.
Understand what auto-sync changesAuto-sync can merge upstream code and workflow changes into the production branch. It does not execute D1 SQL migrations. The backend workflow also reacts when
Upstream Synccompletes, so verify the sync and the following deployment. Review breaking changes and apply required D1 migrations separately.
The sync workflow uses GITHUB_TOKEN to write to the fork. Allow repository contents
write access, without allowing workflows to approve pull requests:
gh api --method PUT "repos/${REPOSITORY}/actions/permissions/workflow" \ -f default_workflow_permissions=write \ -F can_approve_pull_request_reviews=falseThis setting is repository-wide. Review every enabled workflow first, then enable the sync and backend workflows:
gh workflow enable sync.yaml --repo "$REPOSITORY"gh workflow enable backend_deploy.yaml --repo "$REPOSITORY"gh workflow list --repo "$REPOSITORY" --allTrigger Upstream Sync manually the first time instead of waiting for its weekly
schedule. This confirms immediately that the permission and upstream relationship
work:
trigger_first_sync() ( set -euo pipefail
local run_url run_id run_url="$( gh workflow run sync.yaml \ --repo "$REPOSITORY" \ --ref main )"
if [ -z "$run_url" ]; then printf 'GitHub CLI did not return a sync run URL.\n' >&2 return 1 fi
run_id="${run_url##*/}" gh run watch "$run_id" \ --repo "$REPOSITORY" \ --compact \ --exit-status)
trigger_first_syncEnable auto-sync in GitHub
- Open Settings → Actions → General on the fork.
- Under Workflow permissions, select Read and write permissions.
- Leave the pull-request approval option unchecked, then select Save.
- Open Actions → Upstream Sync and select Enable workflow.
- Select Run workflow, choose
main, and start the first run manually. - Wait for the sync to succeed, then verify the resulting Deploy Backend run.
After the sync succeeds, verify the sync and the triggered backend deployment:
gh run list \ --repo "$REPOSITORY" \ --workflow sync.yaml \ --limit 3
gh run list \ --repo "$REPOSITORY" \ --workflow backend_deploy.yaml \ --limit 3Future scheduled syncs use the cron expression in .github/workflows/sync.yaml.
Review that file to change the interval. Disable automatic sync at any time:
gh workflow disable sync.yaml --repo "$REPOSITORY"Configure retention
The daily Cron Trigger only wakes the Worker; it does not define a retention policy. Configure and verify cleanup in the administration interface so D1 does not grow without limit and test mail does not remain indefinitely.
Configure retention manually
- Open the Webmail administration interface and configure the cleanup policy.
- In Cloudflare, open Workers & Pages → your Worker → Settings.
- Confirm the daily Cron Trigger is present under trigger settings.
- Recheck D1 usage after the first scheduled cleanup.
Clean the local session
wrangler logoutunset CLOUDFLARE_ACCOUNT_ID D1_DATABASE_ID ROOT_DOMAIN MAIL_WEB_DOMAINunset WORKER_NAME D1_DATABASE_NAME GITHUB_OWNER REPOSITORYunset DNS_SNAPSHOT CF_ROUTING_API_TOKEN CF_ZONE_IDwrangler logout invalidates OAuth and removes stored credentials. The EXIT trap
removes the temporary Worker config but preserves the DNS rollback snapshot.
Troubleshooting
Authentication error [code: 10000]
The CI token, account ID, or resource scope does not match. Confirm that the token belongs to the target account and that the GitHub secret has no quotes or newline.
The custom domain returns 404
Confirm that USE_WORKER_ASSETS exists, BACKEND_TOML contains [assets], and
the route uses custom_domain = true.
Catch-all rule only supports 'forward' or 'drop' action types
Wrangler currently rejects Worker actions for catch-all rules even though its help
lists worker. Use the catch-all REST helpers from Phase 4 and read the rule back.
Cannot read properties of undefined (reading 'map')
Open /open_api/settings and confirm that it returns valid JSON. This error usually
means a JSON-shaped variable is missing or malformed. Check DOMAINS and
DEFAULT_DOMAINS, then confirm the password secrets contain JSON array strings.
D1_ERROR: no such table
For a new empty database, apply the schema from the exact deployed commit:
wrangler d1 execute "$D1_DATABASE_NAME" \ --remote \ --file db/schema.sql \ --yesFor an existing database, use the specific migration documented for the upgrade.
D1_ERROR: Exceeded maximum DB size
The database can no longer store mail. Remove unneeded messages and configure cleanup. Confirm the Worker has a Cron Trigger, then review D1 limits before changing retention.
Upstream Sync cannot push
Read back the repository workflow permission:
gh api "repos/${REPOSITORY}/actions/permissions/workflow"default_workflow_permissions must be write. The sync workflow does not need
permission to approve pull requests.
Mail does not arrive
wrangler email routing settings "$ROOT_DOMAIN"wrangler email routing dns get "$ROOT_DOMAIN"wrangler email routing rules get "$ROOT_DOMAIN" catch-allConfirm that no old provider MX records remain, then inspect the Worker error tail.