Skip to content

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.

  • Phase 1 complete. example.com is on Cloudflare with SPF, DKIM, and DMARC configured. Substitute your own apex for example.com throughout 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.com zone. The send_email bindings declared in wrangler.jsonc will 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+, and npx. The Worker is npm-only; the Fresh app is the Deno half.
  • openssl for generating the signing secret.
  1. Clone the repo and enter the Worker directory.

    Terminal window
    git clone https://github.com/your-org/polaris-express.git
    cd polaris-express/email-worker
    npm install
    • Directoryemail-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)
  2. Authenticate wrangler against your Cloudflare account.

    Terminal window
    npx wrangler login

    The browser flow returns an OAuth token cached at ~/.config/.wrangler/config/default.toml. If you’re deploying from CI instead, set CLOUDFLARE_API_TOKEN with Workers Scripts:Edit and Workers KV Storage:Edit permissions.

  3. 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_DEDUP
    npx wrangler kv namespace create EMAIL_NONCE_DEDUP --preview

    Each command prints an id. Paste them into wrangler.jsonc:

    wrangler.jsonc
    "kv_namespaces": [
    {
    "binding": "EMAIL_NONCE_DEDUP",
    "id": "<production id from first command>",
    "preview_id": "<preview id from second command>"
    }
    ]
  4. 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 32

    Store 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_B unset for now. It exists for rolling rotation, not first-time install.

  5. Dry-run the deploy to validate config.

    Terminal window
    npx wrangler deploy --dry-run

    This parses wrangler.jsonc, resolves bindings, and confirms the Email Service bindings are valid for your zone — without publishing. Fix any errors before continuing.

  6. Deploy.

    Terminal window
    npx wrangler deploy

    Wrangler prints a *.workers.dev URL. That URL works, but the Fresh app expects mail.example.com.

  7. Bind the Worker to mail.example.com.

    In the Cloudflare dashboard, open the Worker and add a route:

    mail.example.com/* Zone: example.com

    Alternatively, declare it in wrangler.jsonc under routes and 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.

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.

NameDefaultRequiredSource
POLARIS_SECRET_Ayeswrangler secret put
POLARIS_SECRET_Bno (for rotation)wrangler secret put
DEFAULT_REPLY_TO[email protected]nowrangler.jsoncvars
RATE_LIMIT_MAX5nowrangler.jsoncvars
RATE_LIMIT_WINDOW_SECONDS600nowrangler.jsoncvars
TS_WINDOW_MS300000nowrangler.jsoncvars
NONCE_TTL_SECONDS600nowrangler.jsoncvars

A few notes on the defaults:

  • RATE_LIMIT_MAX of 5 per 10 minutes is keyed on sha256("${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_MS and NONCE_TTL_SECONDS must 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_TO is what the Worker stamps onto outbound mail when the Fresh-side payload doesn’t specify replyTo. Point it at a monitored mailbox.

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.

  1. 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/invalid X-Polaris-Sig). Anything else — TLS error, 522, 404 — means the route binding in Step 7 didn’t take.

  2. Confirm logging is on. Tail live logs:

    Terminal window
    npx wrangler tail

    Replay 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_hash and nonce_hash.

  3. Confirm KV is wired up. From the dashboard, open the EMAIL_NONCE_DEDUP namespace. 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).

  4. End-to-end send (optional, after Phase 4). Once the Fresh app is up, trigger a magic-link login. The Worker should log send ok and the email should land within a few seconds. If you want to test before Phase 4, use the Fresh repo’s scripts/preview-email.ts [email protected] against a staging Fresh deployment.

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.

The Worker accepts a signature from either POLARIS_SECRET_A or POLARIS_SECRET_B. That’s how you rotate without downtime:

  1. Set the new value as POLARIS_SECRET_B on the Worker and redeploy.

    Terminal window
    npx wrangler secret put POLARIS_SECRET_B
    npx wrangler deploy
  2. 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.

  3. Confirm via wrangler tail that the Fresh app is signing with the new secret. Then retire the old one:

    Terminal window
    npx wrangler secret delete POLARIS_SECRET_A
    npx wrangler secret put POLARIS_SECRET_A # paste the same value as B
    npx wrangler secret delete POLARIS_SECRET_B
    npx wrangler deploy

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.