Phase 2 — Configure DNS + Cloudflare email
This phase prepares the DNS surface and outbound email path for Polaris
Express. By the end of it, your domain will resolve to your Traefik
entry, mail sent from Polaris will be SPF/DKIM/DMARC-authenticated, and
the Cloudflare Email Worker will be live at mail.<your-domain> ready
to accept signed POST /send requests from the Fresh app.
This phase runs after you’ve provisioned a host and decided on a domain (Phase 1), and before you start the Fresh container and flip Traefik (Phase 3). Skipping it means magic links never reach inboxes — there is no fallback SMTP path.
Prerequisites
Section titled “Prerequisites”- Phase 1 (host provisioning) complete. You know the public IPv4 / IPv6 of your Traefik entry.
- DNS control over the domain you intend to use (referred to below as
example.com). You can edit A, AAAA, CNAME, and TXT records. - A Cloudflare account on the Workers Paid plan (~$5/mo). The free
plan cannot bind
send_email. - The zone added to Cloudflare with Email Routing enabled.
- An R2 bucket (or other public-readable origin) for brand assets,
reachable at
assets.<your-domain>. npmandnpx wrangleravailable locally — you’ll deploy the Worker from your workstation, not the production host.- A mailbox you can read for
dmarc-reports@<your-domain>(a forwarding rule via Email Routing is fine).
-
Add DNS records for the three hostnames
Create A (and AAAA, if you have v6) records pointing the customer, admin, and mail hostnames at the same Traefik entry. Add a CNAME for the assets host pointing at your R2 bucket.
zone: example.com manage.example.com 3600 IN A <traefik-ipv4>example.com 3600 IN A <traefik-ipv4>mail.example.com 3600 IN A <traefik-ipv4>assets.example.com 3600 IN CNAME <r2-bucket>.r2.cloudflarestorage.com.The
mail.example.comrecord is the public route for the Email Worker; Cloudflare’s edge will intercept it once you add a Worker Route in step 4. Until then it harmlessly resolves at Traefik and 404s. -
Publish SPF, DKIM, and DMARC records
These three TXT records authorize Cloudflare to send on behalf of your domain and tell receiving mail servers what to do with messages that fail authentication.
zone: example.com ;; SPF — authorize Cloudflare's outbound mailexample.com. 3600 IN TXT"v=spf1 include:_spf.mx.cloudflare.net -all";; DMARC — start in monitoring mode_dmarc.example.com. 3600 IN TXT"v=DMARC1; p=none; rua=mailto:[email protected]; fo=1; aspf=r; adkim=r; pct=100"DKIM is not added by hand. Once Email Service is enabled on the zone (next step) Cloudflare auto-generates a selector and shows a CNAME in the dashboard — add that record then.
-
Enable Cloudflare Email Service on the zone
In the Cloudflare dashboard for the zone:
-
Navigate to Email → Email Routing and confirm routing is enabled (it should be from the prerequisites).
-
Navigate to Email → Email Sending (or Email Service) and enable the service. This is currently in public beta on Workers Paid.
-
Cloudflare will display a DKIM CNAME. Add it to your zone:
<selector>._domainkey.example.com. 3600 IN CNAME <selector>.dkim.cloudflare.net. -
Wait for the dashboard to mark DKIM as Verified before continuing. This typically takes a few minutes.
-
-
Upload brand assets to the R2 origin
The email templates inline-reference four PNGs by URL. They must exist before the first send or images will appear broken in customer inboxes (Outlook strips inline SVG, so PNG is mandatory).
Upload to
https://assets.example.com/email/:Directoryemail/
- polaris-logo-160.png 160×40, customer brand
- polaris-logo-320.png 320×80, retina
- expressync-logo-160.png 160×40, admin brand
- expressync-logo-320.png 320×80, retina
Verify each is reachable over HTTPS:
image/png curl -I https://assets.example.com/email/polaris-logo-160.png# HTTP/2 200 -
Create the KV namespace for nonce dedup
The Email Worker rejects replayed requests by hashing each request’s nonce into a Cloudflare KV namespace. Create the namespace (production and preview) from your workstation:
Terminal window npx wrangler kv namespace create EMAIL_NONCE_DEDUPTerminal window pnpm wrangler kv namespace create EMAIL_NONCE_DEDUPTerminal window yarn wrangler kv namespace create EMAIL_NONCE_DEDUPTerminal window npx wrangler kv namespace create EMAIL_NONCE_DEDUP --previewTerminal window pnpm wrangler kv namespace create EMAIL_NONCE_DEDUP --previewTerminal window yarn wrangler kv namespace create EMAIL_NONCE_DEDUP --previewEach command prints an ID. Open
cloudflare/email-worker/wrangler.jsoncand replace the placeholders:cloudflare/email-worker/wrangler.jsonc {"kv_namespaces": [{"binding": "EMAIL_NONCE_DEDUP","id": "REPLACE_WITH_KV_NAMESPACE_ID","preview_id": "REPLACE_WITH_KV_PREVIEW_ID"}]}with the real IDs from the two
wranglercommands. -
Generate and store the signing secret
The Fresh app HMAC-signs every request to the Worker; the Worker verifies the signature before sending. Generate a fresh secret and push it to the Worker:
Terminal window openssl rand -base64 32# Copy the output — you'll paste it twice.cd cloudflare/email-workernpx wrangler secret put POLARIS_SECRET_A# Paste the secret when prompted.Save the same value — you’ll set it as
CF_EMAIL_WORKER_SECRETon the Fresh app in Phase 3. The two sides must match exactly. -
Deploy the Worker
Confirm the config parses, then deploy:
Terminal window npx wrangler deploy --dry-runTerminal window pnpm wrangler deploy --dry-runTerminal window yarn wrangler deploy --dry-runIf that succeeds:
Terminal window npx wrangler deployTerminal window pnpm wrangler deployTerminal window yarn wrangler deployThe output will include the Worker’s
*.workers.devURL. Don’t use that URL from the Fresh app — bind it to your domain instead. -
Bind the Worker to mail.example.com
In the Cloudflare dashboard:
- Open Workers & Pages → polaris-email-worker → Settings → Triggers.
- Add a Custom Domain or Route:
- Pattern:
mail.example.com/* - Worker:
polaris-email-worker
- Pattern:
- Wait for the route to propagate (usually under a minute).
Configure environment variables
Section titled “Configure environment variables”These vars belong on the Worker side (set via wrangler secret put
or in wrangler.jsonc vars). The Fresh-side counterparts are set in
Phase 3.
| Name | Default | Required | Source |
|---|---|---|---|
POLARIS_SECRET_A | (none) | ✅ | wrangler secret put |
POLARIS_SECRET_B | (none) | ⛔ optional — only set during rotation | wrangler secret put |
TS_WINDOW_MS | 300000 (5 min) | ⛔ | wrangler.jsonc vars |
NONCE_TTL_SECONDS | 600 | ⛔ | wrangler.jsonc vars |
RATE_LIMIT_MAX | 10 | ⛔ | wrangler.jsonc vars |
RATE_LIMIT_WINDOW_SECONDS | 3600 | ⛔ | wrangler.jsonc vars |
If a value is unset, the Worker falls back to the defaults shown above — suitable for production. Override only if you have a specific reason (e.g. tighter replay window for a high-value tenant).
The from: allowlist ([email protected],
[email protected]) is hard-coded against the
send_email bindings in wrangler.jsonc. Update both the binding
names and the allowlist if you use different sender addresses.
Verify
Section titled “Verify”DNS:
dig +short example.comdig +short manage.example.comdig +short mail.example.comdig +short assets.example.com# All four should return your Traefik IP (or the R2 host for assets).
dig +short TXT example.com | grep spf1# "v=spf1 include:_spf.mx.cloudflare.net -all"
dig +short TXT _dmarc.example.com# "v=DMARC1; p=none; rua=mailto:[email protected]; …"Worker reachability:
curl -i https://mail.example.com/send# HTTP/2 405 (Method Not Allowed) or 401 (unsigned) — both prove the# Worker is bound. A 522 or 404 means the route hasn't propagated.Worker signature enforcement:
curl -i -X POST https://mail.example.com/send \ -H 'Content-Type: application/json' \# HTTP/2 401 (missing X-Polaris-Sig)DKIM:
In the Cloudflare dashboard, Email → Email Sending should show DKIM as Verified and SPF as Aligned.
A real end-to-end mail test waits until Phase 3, when the Fresh app exists to sign requests.
If something goes wrong
Section titled “If something goes wrong”wrangler deploy fails with “send_email binding not supported” —
Email Service isn’t enabled on the zone. Re-run step 3 and wait for
DKIM verification before redeploying.
mail.example.com returns 522 / Cloudflare error — the Worker
Route in step 8 hasn’t propagated, or the DNS record from step 1 is
missing. Verify both.
DKIM stuck on “Pending” for >1 hour — the CNAME in step 3 was
copied incorrectly. Compare the trailing .dkim.cloudflare.net. part
exactly. Some DNS UIs auto-append the zone — paste only the host part.
DMARC reports never arrive — [email protected] doesn’t
resolve. Add an Email Routing rule in Cloudflare forwarding it to a
real inbox.
POLARIS_SECRET_A accidentally committed — rotate immediately
following the secret-rotation runbook.
The two-secret design exists precisely for this.
Brand assets return 403 from R2 — the bucket isn’t public. Either enable public access on the bucket or front it with a Cloudflare Worker that serves from R2.
Next phase
Section titled “Next phase”With DNS, mail authentication, and the Worker live, you’re ready for
Phase 3 — Deploy the Fresh container and flip Traefik,
which brings up the customer and admin surfaces and wires
CF_EMAIL_WORKER_URL + CF_EMAIL_WORKER_SECRET
into the app.