Deploy the app
Kestrel runs as one Cloudflare Worker, with a D1 database for posts and subscribers and an R2 bucket for images.
In this guide, you make your own copy of the repository, create the database and the bucket, and point Kestrel’s production settings at them and at your hostname. Then you deploy, and check that the app answers on your hostname. You need what the Overview lists.
1. Get your own copy of the repository
Your copy holds your instance’s configuration, so it needs a repository of its own. Kestrel’s repository stays connected as upstream, where new releases come from.
On GitHub, create a new repository named
kestrel. It can be private. Leave it empty, with no README, license, or.gitignore.Clone Kestrel, point it at your new repository, and push:
git clone https://github.com/kurtbruns/kestrel.git cd kestrel git remote rename origin upstream git remote add origin https://github.com/YOUR_USERNAME/kestrel.git git push -u origin mainReplace
YOUR_USERNAMEwith your GitHub username.Confirm the two remotes:
git remote -vorigin https://github.com/YOUR_USERNAME/kestrel.git (fetch) origin https://github.com/YOUR_USERNAME/kestrel.git (push) upstream https://github.com/kurtbruns/kestrel.git (fetch) upstream https://github.com/kurtbruns/kestrel.git (push)Install the dependencies:
npm install(Optional) Try Kestrel on your computer before you deploy it. Local development sends email to a stand-in, so nothing reaches a real inbox:
cp .dev.vars.example .dev.vars npm run devOpen http://localhost:8787/dashboard/ to see the editor. To fill it with demo content, run
npm run seedin a second terminal. Press Ctrl+C to stop the server. The README covers local development in full.(Optional) In
package.json, setrepository.urlto your repository. The editor links the running build to its commit through that field. Without the change, those links open Kestrel’s repository instead of yours.
2. Sign in to Cloudflare
Every command from here on acts on your Cloudflare account through the Wrangler CLI.
Sign in. A browser window opens for you to approve the login:
npx wrangler loginConfirm which account you’re signed in to:
npx wrangler whoamiIt prints your email and the account name. If you belong to more than one account, make sure it’s the one that holds your domain.
3. Create the database and the image bucket
Create the database:
npx wrangler d1 create kestrel-productionIt prints the new database’s settings, including a
database_id. Copy the id for the next section. If Wrangler offers to add the database to your configuration for you, decline: you add it to the production settings yourself, below.Create the bucket for images:
npx wrangler r2 bucket create kestrel-media-production
4. Configure production
wrangler.jsonc holds two sets of settings. The top level is for local development, so leave it alone. The production block under env is yours to fill in.
Open
wrangler.jsoncand change the values marked here in theproductionblock:"production": { "routes": [{ "pattern": "newsletter.example.com", "custom_domain": true }], // ← your app's hostname "d1_databases": [ { "binding": "DB", "database_name": "kestrel-production", "database_id": "REPLACE_WITH_PRODUCTION_D1_ID", // ← the id from section 3 "migrations_dir": "migrations" } ], "vars": { "PROVIDER": "fake", // ← leave as is for now "APP_ORIGIN": "https://newsletter.example.com", // ← your app's hostname "ARCHIVE_BASE_PATH": "/archive", "SENDING_DOMAIN": "send.example.com", // ← your sending hostname "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>" // ← who your mail is from } }What each one does:
routesputs the app on your hostname when you deploy. Cloudflare creates the DNS record and the certificate. Your domain must be in the same Cloudflare account, and the hostname can’t already have a CNAME record; delete one if it does. The block also turns off theworkers.devaddresses, so the app answers only on your hostname.PROVIDERstaysfakeuntil you connect Resend in step 4. Thefaketransport delivers nothing, so don’t schedule a post before then.ARCHIVE_BASE_PATHis permanent once you send. Every post you send carries its/archive/…link, and changing the prefix later breaks the links you’ve already mailed. Keep/archiveunless you have a reason not to.FROM_ADDRESSis the address every email comes from. Keep it on your sending hostname, and setSENDING_DOMAINto the part after the@.
Every other setting has a working default. Configuration lists them all, and what the app refuses.
Check the file. This regenerates the binding types, and fails if the configuration doesn’t parse:
npm run typecheckCommit your settings and push them to your repository. Upgrades merge new releases into this branch, so your settings stay with it:
git commit -am "Configure production" git push
5. Apply the schema
Create Kestrel’s tables in the production database:
npm run migrate:remote -- --env production
It lists the migrations it’s about to apply and asks you to confirm. It applies only the ones the database hasn’t seen, so running it again is harmless.
6. Deploy
npm run deploy -- --env production
This builds the editor, stamps the build with its version, and deploys it. The output ends with the hostname the app now answers on. Deploying also registers the once-a-minute schedule that sends posts, which you can see under the Worker’s Triggers tab in the Cloudflare dashboard.
Check it
The app answers on your hostname. A new hostname can take a few minutes to get its certificate.
curl https://newsletter.example.com/health{"status":"ok","service":"kestrel"}The editor is locked. Until you set up Access in the next step, the app can’t tell who is asking, so it lets no one in:
curl -s -o /dev/null -w "%{http_code}\n" https://newsletter.example.com/api/whoami401In a browser,
https://newsletter.example.com/shows your newsletter’s public landing page.
If every request instead answers 500 with a body like {"error":"invalid_config","variable":"APP_ORIGIN",…}, the setting it names is missing or malformed. Fix it in wrangler.jsonc, commit, and deploy again.