Phase 3 — Deploy the Cloudflare email Worker
Polaris Express does not send email directly. Instead, the Fresh app HMAC-signs a payload and POSTs it to a small Cloudflare Worker, which verifies the signature, dedupes nonces, enforces a per-recipient rate limit, and finally hands the message to Cloudflare Email Service. This phase deploys that Worker.
This is Phase 3 in the self-host sequence. You should have finished Phase 1 (DNS and zone setup) and Phase 2 (R2 assets) before starting here. The Fresh app itself comes in Phase 4 — but it will refuse to start without the Worker URL and shared secret you produce here.
Prerequisites
Section titled “Prerequisites”- Phase 1 complete.
example.comis on Cloudflare with SPF, DKIM, and DMARC configured. Substitute your own apex forexample.comthroughout this page. - Phase 2 complete. Brand artwork is uploaded to
assets.example.com(R2). The Worker doesn’t serve these, but the emails it sends will reference them. - Cloudflare Workers Paid plan. Cloudflare Email Service is in public beta on the paid plan only. Verify in the dashboard before deploying.
- Email Service enabled on the
example.comzone. Thesend_emailbindings declared inwrangler.jsoncwill fail to deploy otherwise. - A sender from-address you control. This runbook uses
[email protected](customer-facing, Polaris brand) and[email protected](admin-facing, ExpresSync brand). - Local toolchain:
git, Node 20+, andnpx. The Worker is npm-only; the Fresh app is the Deno half. opensslfor generating the signing secret.
-
Clone the repo and enter the Worker directory.
Terminal window git clone https://github.com/your-org/polaris-express.gitcd polaris-express/email-workernpm installDirectoryemail-worker/
- wrangler.jsonc Worker config — bindings, vars, KV
Directorysrc/
- index.ts Verify, dedup, rate-limit, send
- package.json npm scripts
- .dev.vars (you create this, gitignored)
-
Authenticate
wrangleragainst your Cloudflare account.Terminal window npx wrangler loginThe browser flow returns an OAuth token cached at
~/.config/.wrangler/config/default.toml. If you’re deploying from CI instead, setCLOUDFLARE_API_TOKENwithWorkers Scripts:EditandWorkers KV Storage:Editpermissions. -
Create the KV namespace for nonce dedup and rate-limit counters.
A single namespace holds both, distinguished by key prefix.
Terminal window npx wrangler kv namespace create EMAIL_NONCE_DEDUPnpx wrangler kv namespace create EMAIL_NONCE_DEDUP --previewEach command prints an
id. Paste them intowrangler.jsonc:wrangler.jsonc "kv_namespaces": [{"binding": "EMAIL_NONCE_DEDUP","id": "<production id from first command>","preview_id": "<preview id from second command>"}] -
Generate the signing secret and register it as
POLARIS_SECRET_A.This is the shared HMAC secret between the Fresh app and the Worker. Generate something random and long:
Terminal window openssl rand -base64 32Store the output in a password manager — you’ll paste the same value into the Fresh app’s
<EnvVar name="CF_EMAIL_WORKER_SECRET" />in Phase 4.Terminal window npx wrangler secret put POLARIS_SECRET_A# Paste the secret at the prompt.Leave
POLARIS_SECRET_Bunset for now. It exists for rolling rotation, not first-time install. -
Dry-run the deploy to validate config.
Terminal window npx wrangler deploy --dry-runThis parses
wrangler.jsonc, resolves bindings, and confirms the Email Service bindings are valid for your zone — without publishing. Fix any errors before continuing. -
Deploy.
Terminal window npx wrangler deployWrangler prints a
*.workers.devURL. That URL works, but the Fresh app expectsmail.example.com. -
Bind the Worker to
mail.example.com.In the Cloudflare dashboard, open the Worker and add a route:
mail.example.com/* Zone: example.comAlternatively, declare it in
wrangler.jsoncunderroutesand redeploy — but the dashboard approach keeps the route out of version control, which is what you want if different self-host tenants reuse the same Worker source.
Configure environment variables
Section titled “Configure environment variables”The Worker is configured almost entirely via wrangler.jsonc. There
is no .env file in production — secrets go through
wrangler secret put, and non-secret tuning lives in the vars
block.
| Name | Default | Required | Source |
|---|---|---|---|
POLARIS_SECRET_A | — | yes | wrangler secret put |
POLARIS_SECRET_B | — | no (for rotation) | wrangler secret put |
DEFAULT_REPLY_TO | [email protected] | no | wrangler.jsonc → vars |
RATE_LIMIT_MAX | 5 | no | wrangler.jsonc → vars |
RATE_LIMIT_WINDOW_SECONDS | 600 | no | wrangler.jsonc → vars |
TS_WINDOW_MS | 300000 | no | wrangler.jsonc → vars |
NONCE_TTL_SECONDS | 600 | no | wrangler.jsonc → vars |
A few notes on the defaults:
RATE_LIMIT_MAXof 5 per 10 minutes is keyed onsha256("${to}:${category}"). That’s enough headroom for a user who bounces between magic-link requests but blocks abuse. Raise it if you operate at scale; lower it if you’re paranoid.TS_WINDOW_MSandNONCE_TTL_SECONDSmust stay related:NONCE_TTL_SECONDS * 1000 >= TS_WINDOW_MS. Otherwise a replay can outlast its dedup record. The defaults satisfy this — if you change one, change the other.DEFAULT_REPLY_TOis what the Worker stamps onto outbound mail when the Fresh-side payload doesn’t specifyreplyTo. Point it at a monitored mailbox.
Verify
Section titled “Verify”You can’t reach the Worker without a valid HMAC signature — that’s the point — but you can confirm it’s listening and rejecting bad input.
-
Confirm the route resolves.
Terminal window curl -i https://mail.example.com/send \-X POST \-H 'Content-Type: application/json' \-d '{}'Expect
401 Unauthorized(missing/invalidX-Polaris-Sig). Anything else — TLS error,522,404— means the route binding in Step 7 didn’t take. -
Confirm logging is on. Tail live logs:
Terminal window npx wrangler tailReplay the curl above in another terminal. You should see a single JSON log line categorising the rejection. Note that payload contents are never logged — only
to_hashandnonce_hash. -
Confirm KV is wired up. From the dashboard, open the
EMAIL_NONCE_DEDUPnamespace. It will be empty until your first real send, but the namespace should exist and the Worker should be bound to it (Workers → expresscharge-email-worker → Settings → Bindings). -
End-to-end send (optional, after Phase 4). Once the Fresh app is up, trigger a magic-link login. The Worker should log
send okand the email should land within a few seconds. If you want to test before Phase 4, use the Fresh repo’sscripts/preview-email.ts [email protected]against a staging Fresh deployment.
If something goes wrong
Section titled “If something goes wrong”wrangler deploy fails with “binding send_email not allowed”.
Email Service isn’t enabled on the zone, or your account isn’t on the
Workers Paid plan. Check Cloudflare dashboard → Email → Email Workers
for the example.com zone.
wrangler deploy succeeds, but curl returns 522 or 1016.
The route binding didn’t land or the DNS record for
mail.example.com is grey-cloud. Either add a proxied AAAA/CNAME
record pointing at 100:: (Cloudflare’s “Workers-only” placeholder)
or attach the route to an existing proxied subdomain.
Worker returns 401 even when the Fresh app signs correctly. The
secret values are out of sync. Re-run wrangler secret put POLARIS_SECRET_A
on the Worker side, then push the same value into the Fresh app’s
<EnvVar name="CF_EMAIL_WORKER_SECRET" /> and restart the container.
A trailing newline pasted into one side but not the other is the most
common cause.
Sends succeed but emails never arrive. SPF/DKIM/DMARC from Phase 1 are missing or wrong. Check the Cloudflare Email Service dashboard for delivery failures, and run your apex through mail-tester.com to verify alignment.
Emails arrive but images are broken. Phase 2 (R2 assets) isn’t
finished — the templates link to assets.example.com/email/*.png.
Upload the four required PNGs before going live.
429 Too Many Requests on a legitimate user. Per-recipient rate
limit tripped. The bucket clears in RATE_LIMIT_WINDOW_SECONDS
(default 600s). If this happens often, raise RATE_LIMIT_MAX in
vars and redeploy.
Rotating the signing secret
Section titled “Rotating the signing secret”The Worker accepts a signature from either POLARIS_SECRET_A or
POLARIS_SECRET_B. That’s how you rotate without downtime:
-
Set the new value as
POLARIS_SECRET_Bon the Worker and redeploy.Terminal window npx wrangler secret put POLARIS_SECRET_Bnpx wrangler deploy -
Roll the new value into the Fresh app’s
<EnvVar name="CF_EMAIL_WORKER_SECRET" />and restart it. Both old and new signatures verify during this window. -
Confirm via
wrangler tailthat the Fresh app is signing with the new secret. Then retire the old one:Terminal window npx wrangler secret delete POLARIS_SECRET_Anpx wrangler secret put POLARIS_SECRET_A # paste the same value as Bnpx wrangler secret delete POLARIS_SECRET_Bnpx wrangler deploy
Next phase
Section titled “Next phase”With the Worker live and verified, you’re ready for Phase 4 — Deploy
the Fresh app and Postgres, which wires CF_EMAIL_WORKER_URL and
CF_EMAIL_WORKER_SECRET into the customer-facing container.