Hosting
A site runs as a Cloudflare Worker (Astro SSR) with D1 for the database and a Cloudflare R2 bucket for its files. Accounts are the site’s own, in its database.
Create a private bucket for the site (npx wrangler r2 bucket create <site>) and put its name in
wrangler.jsonc (bucket_name): the Worker reaches it through the BUCKET binding and serves the media
library from it at /media/. Scripts working on the deployed files reach it over R2’s S3 API instead:
an API token with Object Read & Write (R2 > Manage API tokens), with STORAGE_ENDPOINT
https://<account-id>.r2.cloudflarestorage.com, STORAGE_BUCKET, STORAGE_REGION auto and the
token’s STORAGE_ACCESS_KEY_ID / STORAGE_SECRET_ACCESS_KEY, in their env file only.
Give the Worker and its database names of the site’s own in wrangler.jsonc (name,
database_name), then create the database and apply the migrations:
npx wrangler loginnpx wrangler d1 create <site>npx wrangler d1 migrations apply DB --remotePut the database’s id in wrangler.jsonc (database_id). The site reads and writes it only on the
server; the admin screens’ queries go through the site, which checks each person’s role. Accounts are
the site’s own (in its database). Invitations (7 days), password links (an hour) and members’
confirmation links (a day) are sent by the site’s email (Cloudflare Email Service, else SMTP); without
either, admins are shown invitation and password links to pass on themselves.
3. Seed and create an admin
Section titled “3. Seed and create an admin”Scripts reach the deployed database through Cloudflare’s API. In a copy of .env, set
CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN (a token with D1 edit) and D1_DATABASE_ID, then run
the same commands as locally:
npx tsx --env-file=.env.production scripts/seed.tsnpx tsx --env-file=.env.production scripts/create-editor.ts you@example.org 'a-password' adminA site moving from Netlify and Supabase copies its files with scripts/copy-storage.ts (resumable) and
its data with scripts/pg-to-d1.ts instead (docs/cloudflare-migration.md in the repository).
4. The Worker
Section titled “4. The Worker”Give the queue a name of the site’s own in wrangler.jsonc, then:
npx wrangler queues create <site>-jobsTurn on Cloudflare Images for the account. Set the
secrets with npx wrangler secret put <NAME> (AUTH_SECRET, SYNC_SECRET and the optional Compose,
payment and Mailgun keys) and the other settings as vars in wrangler.jsonc.
For the site’s email, add "send_email": [{ "name": "EMAIL" }] to wrangler.jsonc, onboard the
sender’s domain under Email Service > Email Sending in the Cloudflare dashboard, and set
MAIL_FROM to an address on it. Without that binding the site sends over SMTP (SMTP_PASS as a
secret, the other SMTP_* settings as vars). Deploy with npm run deploy, then add your domain under the Worker’s Domains &
Routes.
To deploy from git instead (the Worker’s Settings > Build, connected to the repository), set the
build command to npm run build and the deploy command to npm run deploy:remote, which applies any new
database migrations and then deploys. The API token Cloudflare makes for builds can’t change D1: add
D1 Edit to it under My Profile > API Tokens. A migration that changes stored blocks has to go
out after its code; deploy that one with npm run deploy and apply it straight after. SMTP, when used, goes through the Worker’s sockets: use port 587 or 465 (25 is closed to Workers).
The Worker does the background work itself (src/worker.ts):
| What | When |
|---|---|
| Compose turns, backup restores, a campaign’s sending run | The jobs queue (up to 15 minutes each) |
| Ticket reminders and follow-ups, expired holds, automatic waitlists | Hourly |
| Scheduled snapshots | Daily at 07:00 UTC |
| Mailing: scheduled campaigns, weekly digest issues, issues not approved in time | Every 5 minutes |
| Mailing: Mailgun’s bounces, complaints and unsubscribes; pruning | Nightly at 06:20 UTC |
Payments
Section titled “Payments”To take payments on forms or tickets, set one provider’s keys and add its webhook. One endpoint per provider serves both:
- Stripe:
https://<your-domain>/api/payments/stripe, with thecheckout.session.*,charge.refundedandcharge.refund.updatedevents; its signing secret isSTRIPE_WEBHOOK_SECRET. - Square:
https://<your-domain>/api/payments/square, withpayment.created,payment.updated,refund.createdandrefund.updated; its signature key isSQUARE_WEBHOOK_SIGNATURE_KEY, andSQUARE_WEBHOOK_URLmust be that exact address (the signature covers it).
Free tickets need no provider. See Tickets and Forms.
Mailing
Section titled “Mailing”To send Mailing campaigns and digests, set up Mailgun:
- Add a sending domain for marketing, a subdomain such as
news.<your-domain>, separate from the domain the site’s other mail uses, so its reputation stays its own. - Add the DNS records Mailgun gives for it: SPF, DKIM and the tracking CNAME (such as
email.news.<your-domain>), and publish a DMARC record (p=noneat first,quarantineonce the reports are clean). - Turn on open tracking for that domain. Leave click and unsubscribe tracking off: the site tracks
clicks itself and handles unsubscribing, with its own one-click
List-Unsubscribeheader. - Add a webhook to
https://<your-domain>/api/mailgun/webhookfor delivered, opened, permanent failure, temporary failure, spam complaint and unsubscribed events, and set its signing key asMAILGUN_WEBHOOK_SIGNING_KEY. - Set
MAILGUN_API_KEY,MAILGUN_DOMAIN,MAILGUN_REGION(eufor EU accounts) andMAIL_LINK_SECRET.MAILGUN_TEST_MODE=truemakes Mailgun accept emails and deliver none. - Before the first real campaign, send tests to Gmail, Outlook and Yahoo addresses, and check that Gmail shows its Unsubscribe link next to the sender.
A shared IP is enough to start; a dedicated one is worth it only past roughly 50,000 emails a month, with a warmup plan.
5. Invite editors
Section titled “5. Invite editors”Admins invite editors from /admin/users/. See Editor and roles.
Environment variables
Section titled “Environment variables”| Variable | Purpose |
|---|---|
SYNC_SECRET |
Shared with another copy of the site for Settings > Sync (32 characters or more) |
SYNC_REMOTE_URL, SYNC_REMOTE_NAME |
On the copy that starts syncs: the other copy’s address and name |
CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN, D1_DATABASE_ID |
Scripts only: the deployed database through Cloudflare’s API |
STORAGE_ENDPOINT, STORAGE_BUCKET, STORAGE_REGION, STORAGE_ACCESS_KEY_ID, STORAGE_SECRET_ACCESS_KEY |
Scripts only: the deployed files over R2’s S3 API (set, they win over the binding) |
AUTH_SECRET |
Signs sessions and the site’s links: a long random value (openssl rand -hex 32) |
AUTH_CONFIRM_EMAIL |
false lets member sign-ups in without confirming their address |
DATABASE_URL |
Moving a site only: the Postgres database scripts/pg-to-d1.ts copies from |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_SECURE or SMTP_URL |
Outgoing mail over SMTP, when the Worker has no EMAIL binding (Cloudflare Email Service) |
MAIL_FROM |
Sender, overriding the config’s mailFrom |
FORMS_ADMIN_EMAIL |
Where form notifications go when a form doesn’t say |
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET |
Stripe payments |
SQUARE_ACCESS_TOKEN, SQUARE_LOCATION_ID, SQUARE_ENVIRONMENT, SQUARE_WEBHOOK_SIGNATURE_KEY, SQUARE_WEBHOOK_URL |
Square payments |
TICKETS_NOTIFY_EMAIL |
Where the copy of each ticket order goes (default FORMS_ADMIN_EMAIL, then adminEmail) |
SITE_URL |
The site’s address for links in emails (default the request’s address). Required for Mailing: campaigns and digests are sent without a request |
SITE_NOINDEX |
1 or true on a demo or preview copy: every answer, static files included, tells search engines to stay away (X-Robots-Tag: noindex). Set it in wrangler.jsonc vars, which the build reads for static files. After changing it, save anything in the admin to clear the cached pages |
ANTHROPIC_API_KEY, ANTHROPIC_MODEL |
Compose (ANTHROPIC_MODEL overrides the default model, claude-sonnet-5-5) |
ANTHROPIC_SEO_MODEL |
SEO text generation (default Haiku 4.5) |
MAILGUN_API_KEY, MAILGUN_DOMAIN, MAILGUN_REGION, MAILGUN_WEBHOOK_SIGNING_KEY, MAILGUN_TEST_MODE |
Mailing through Mailgun |
MAIL_LINK_SECRET |
The key for signed links in mailing emails (default: one made from AUTH_SECRET) |
LOCAL_ADMIN_EMAIL, LOCAL_ADMIN_PASSWORD |
The admin the e2e scripts sign in as |