Architecture

The parts that run in one Worker and one D1 database, and how one contact submission is handled.

This page shows the parts of goodnight-web and follows one contact submission from the request to the work done after the response. The website, the contact API, and the admin screen run in one Worker with Static Assets and one D1 database.

Three constraints shape the design:

  • One Worker and one D1 database. No Queues, Cron Triggers, or KV.
  • Cloudflare and GitHub are the only required services.
  • Fail closed. When a setting or secret is missing, the feature that needs it is turned off.

Architecture: visitors and admins reach one Worker. Static Assets serve the site. A Hono app handles the API and the admin UI and uses D1, the Rate Limiting binding, Turnstile, and an optional Slack webhook. Cloudflare Access can protect the admin paths.

Visitorsread pages, send the form
Adminsread inquiries
Cloudflare Access (optional)/admin/*, /api/admin/*
One Worker
Static AssetsAstro build: /, /blog/, /contact/served without running Worker code
Hono appcreateGoodnightWorker()
/api/contact/*
form fields, submissions
/api/setup
first admin only
/api/auth/*
Better Auth, passkeys
/api/admin/*
inquiry API
/admin/*
prebuilt admin UI
D1inquiries, form, settings, auth
Rate Limitingbinding, keyed by ip_hash
Turnstilesiteverify on the server
Slack (optional)count, time, linkctx.waitUntil
One Worker serves everything. Dashed boxes are optional. Only the API and admin paths run Worker code.

The parts

PartRole
WorkerOne Worker per website. It serves the static pages through Static Assets and runs the API and the admin screen.
Static AssetsThe Astro build (/, /blog/, /contact/, and so on). Cloudflare serves these files without running Worker code.
Hono appThe web framework that runs inside the Worker, created by createGoodnightWorker(options) from @goodnight-web/server. Only /api/*, /admin, and /admin/* reach it, through run_worker_first.
D1One SQLite database for inquiries, settings, and the Better Auth tables. The migrations ship in the npm package.
TurnstileCloudflare's bot check, used on the contact form. The Worker verifies each token on the server side.
Rate Limiting bindingA Workers binding that limits contact submissions, setup attempts, and passkey sign-ins per ip_hash (the hashed IP address). It works on the free plan.
Better Auth with passkeysAdmin sign-in with passkeys only. Email and password sign-in is disabled.
Admin screenA React app (TanStack Router, Form, and Table) built with Vite. It ships prebuilt in @goodnight-web/server and is served by the Worker.
Slack webhook (optional)Sends a short message when a new inquiry arrives. It contains no personal data.
Cloudflare Access (optional)An extra sign-in layer in front of /admin, /admin/*, /api/admin/*, /api/setup, and /api/auth/*. Recommended, not required. With ACCESS_AUD and ACCESS_TEAM_DOMAIN set, the Worker also checks the Access token.
Cloudflare WAF (optional)Rules you set in Cloudflare that filter requests to your domain before they reach the Worker. goodnight-web works the same without it.

Routes

PathUsed byWhat it does
/, /blog/*, /contact/, ...VisitorsStatic pages. No Worker code runs.
GET /api/contact/formThe contact pageReturns the current form fields and whether the form is available. Public and read-only.
POST /api/contactThe contact formChecks a submission, applies the rate limit, and saves it.
/api/setupThe first adminCreates the first admin. Closes once a passkey is registered.
/api/auth/*AdminsBetter Auth: passkey registration and sign-in.
/api/admin/*The admin screenListing, viewing, and deleting inquiries, marking them unread/read or suspected spam, CSV export, and the form editor with its versions. Also a status endpoint that reports missing contact form settings and whether Slack notifications are on.
/admin, /admin/*AdminsThe prebuilt admin screen (a React app). Every data request needs a session.

The inquiry list in the admin screen shows 50 inquiries per page, sorted by received time. The list API also returns the unread and suspected spam counts, and you can search it by email address.

A contact submission, step by step

POST /api/contact runs these steps in this order:

  1. Origin and size. The request must come from the website's own origin, use the expected Content-Type, and be at most 32 KB. Anything else is rejected before the body is parsed.
  2. Honeypot. The form has a field that people cannot see, gnw_hp_x. The Worker notes whether it is filled in and continues. A filled honeypot never changes the response. It only flags the submission in step 5.
  3. Turnstile. The Worker sends the token to the Turnstile siteverify API and checks success, hostname, and action. If the check fails, the Worker returns 400 and saves nothing.
  4. Rate limit. The Rate Limiting binding counts requests per ip_hash. Over the limit, the Worker returns 429.
  5. Validation and spam score.
    • zod checks the email address and consent.
    • The form fields are checked with a zod schema built from the current form definition in D1, never from a definition the browser sent. Unknown fields and unknown keys in the request body are rejected.
    • If the visitor's page showed an older version of the form, the Worker returns 409 form_outdated.
    • Simple rules add to a spam score. A score of 3 or more, or a filled honeypot, flags the submission as suspected spam. A flagged submission is still saved, with the status unread.
  6. Save, then respond. If 500 or more inquiries were stored in the last hour (all senders together), the Worker returns 503 contact_busy and stores nothing. Otherwise the row is inserted into D1. The visitor gets a 200 response only after the insert succeeds. Nothing after this step can change the response.
  7. After the response (in ctx.waitUntil).
    • If the submission is not suspected spam and a Slack webhook is set, the Worker posts a notice with the site name, the page path, the received time, and the admin URL, and records notified_at.
    • If the last cleanup was more than an hour ago, the Worker deletes inquiries older than the retention period (180 days by default), expired passkey challenges, and expired sessions.

The contact API fails closed. In any of these cases it returns 503, and the form says "The contact form is not available yet":

  • TURNSTILE_SECRET or TURNSTILE_SITE_KEY is not set.
  • APP_SECRET is missing, shorter than 32 characters, or clearly not random, and IP_HASH_SECRET is not set either. A set IP_HASH_SECRET that is too short or clearly not random also turns it off.
  • The Rate Limiting binding is missing.

To set them up, see Set up Turnstile.

In the code

The website's Worker is three lines. It only passes the default language to the factory function from @goodnight-web/server:

templates/starter/src/worker.ts
import { createGoodnightWorker } from '@goodnight-web/server';
import { siteConfig } from '../site.config';

export default createGoodnightWorker({ locale: siteConfig.defaultLocale });

wrangler.jsonc sends only the API and admin paths to the Worker. Static Assets serves every other path without running Worker code:

templates/starter/wrangler.jsonc
"assets": {
  "directory": "./dist",
  "binding": "ASSETS",
  "not_found_handling": "404-page",
  // The contact API and the admin screen run in the Worker. Every other
  // path is a page from ./dist.
  "run_worker_first": ["/api/*", "/admin", "/admin/*"],
},

The factory builds one Hono app. Middleware for security headers, HTTPS, and Cloudflare Access runs first, then the routes of each feature. A request that matches no route falls through to the ASSETS binding:

packages/server/src/app.ts
app.use('*', securityHeaders);
// The admin UI and /api/*: HTTPS only (except on localhost). Then the
// admin UI, admin API, setup, and Better Auth: Cloudflare Access when
// ACCESS_AUD and ACCESS_TEAM_DOMAIN are set.
app.use('*', requireHttps);
app.use('*', requireAccess);
app.route('/api/contact', contactRoutes);
app.route('/api/setup', setupRoutes);
app.route('/api/auth', authRoutes);
app.route('/api/admin', adminRoutes);
app.all('/api/*', (c) => apiError(c, 404, 'not_found'));
app.on(['GET', 'HEAD'], ['/admin', '/admin/*'], (c) =>
  serveAdminFile(c.req.raw),
);

The code for contact, setup, auth, the admin API, and the admin screen lives in one folder per feature under packages/server/src/features/.

Secrets and bindings

NameKindPurpose
DBD1 bindingAll stored data
ASSETSStatic Assets bindingThe pages built by Astro
CONTACT_RATE_LIMITERRate Limiting bindingRate limits per ip_hash (contact submissions, setup attempts, passkey sign-ins)
TURNSTILE_SITE_KEYVariableThe public Turnstile site key, in wrangler.jsonc. Without it the contact API is off.
APP_SECRETSecretThe setup token for the first admin, and the source of the session key and the IP hash key. At least 32 characters. Keep it after setup.
ADMIN_EMAILSSecret or variableEmail addresses allowed to become admins
TURNSTILE_SECRETSecretThe Turnstile secret key, used for siteverify. Without it the contact API is off.
SLACK_WEBHOOK_URLSecret, optionalNew-inquiry notification
ACCESS_AUDVariable, optionalCloudflare Access application audience tag. Set together with ACCESS_TEAM_DOMAIN.
ACCESS_TEAM_DOMAINVariable, optionalCloudflare Access team domain, such as your-team.cloudflareaccess.com.
SETUP_TOKENSecret, optionalA separate setup token. When set, setup accepts only this value. Delete it after setup.
BETTER_AUTH_SECRETSecret, optionalA separate Better Auth signing key, used instead of the one derived from APP_SECRET.
IP_HASH_SECRETSecret, optionalA separate key for HMAC-SHA256(ip, key), used instead of the one derived from APP_SECRET.

When BETTER_AUTH_SECRET or IP_HASH_SECRET is not set, the Worker derives that key from APP_SECRET with HKDF in Web Crypto, with a different fixed label for each purpose. A secret shorter than 32 characters, or clearly not random (a single character repeated, for example), counts as missing, and the features that use it are turned off; a set value that is too short is not replaced by a derived key. Secrets are used only inside the Worker. They are never sent to the browser or written to D1.

/admin, /admin/*, and every path under /api/, including the contact API, work only over https:// (except on localhost). See Security model.

Styles and fonts

The website, the admin screen, and this documentation site load no web fonts. Text uses the fonts the visitor's device already has, for example Hiragino on macOS and iOS, and Yu Gothic or Meiryo on Windows.

Each of the three defines a system font stack once, as a CSS token. There are no font files and no font services, so the Content Security Policy lists no font hosts.

The starter has its own design tokens (CSS variables). Its three theme colors come from theme.primary, theme.background, and theme.foreground in site.config.ts, and are set on <html> as CSS variables.

The other shades are mixed from these three colors. Change the three values and the whole site changes color:

templates/starter/src/styles/global.css
:root {
  --site-primary: #2f6b4e;
  --site-background: #fbfaf5;
  --site-foreground: #253229;
}

@theme inline {
  /* Fonts: the visitor's system fonts. The site loads no web fonts. */
  --font-sans:
    system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Hiragino Sans',
    'Hiragino Kaku Gothic ProN', 'Noto Sans JP', 'Yu Gothic UI', Meiryo,
    sans-serif;

  --color-background: var(--site-background);
  --color-foreground: var(--site-foreground);

The admin screen has its own tokens in @goodnight-web/server, with a light and a dark mode. Changing the site's colors does not change the admin screen.

On this page