Skip to content

Get started

Deployment

The app ships as one Cloudflare Worker. A GitHub Actions workflow deploys main after CI passes, and pnpm deploy:cf does the same from your machine. You work here once to create the Cloudflare resources and set the secrets, and again when you add a binding or a domain.

How it works

deploy.yml(Licensees only) runs when the ci workflow succeeds on main, or when you start it by hand on main.

  1. It checks out the commit CI tested. If main has moved past that commit, it writes a line in the run summary and deploys nothing.
  2. scripts/db/deploy-preflight.mjs(Licensees only) stops the run while wrangler.jsonc still holds an all-zeros placeholder id or DATABASE_URL is empty, and while the Hyperdrive has query caching on.
  3. pnpm build writes the Worker and merges wrangler.jsonc into .output/server/wrangler.json.
  4. The migrations run over DATABASE_URL, straight to the database.
  5. wrangler deploy uploads the Worker with the bindings from that file.

Any failed step ends the run. If the migration fails, the old Worker stays live on the old schema.

Create the Cloudflare resources

Create a Postgres database first, with any provider Hyperdrive can reach (Database lists what it must allow). Then create everything wrangler.jsonc names:

pnpm exec wrangler hyperdrive create nuxter --caching-disabled --connection-string="postgres://USER:PASSWORD@HOST:5432/DATABASE"
pnpm exec wrangler d1 create nuxter
pnpm exec wrangler kv namespace create CACHE
pnpm exec wrangler r2 bucket create nuxter-avatars
pnpm exec wrangler queues create nuxter-jobs
pnpm exec wrangler queues create nuxter-jobs-dlq
pnpm exec wrangler email sending enable <your-domain>

Paste the ids they print over the placeholders under hyperdrive, d1_databases and kv_namespaces, then run pnpm cf-typegen. The D1 database holds Nuxt Content's pages and nothing else. The last command onboards the domain your mail is sent from, for the cloudflare mail provider (Email).

The create commands append a new binding to wrangler.jsonc instead of filling in the one already there, and some Wrangler versions reindent the file with tabs. Read the diff, delete the stanza they added, and paste the id into the existing one.

Type the connection string into the Hyperdrive command rather than exporting DATABASE_URL. A DATABASE_URL exported in your shell outranks .env, so pnpm dev and pnpm db:migrate run from that shell would write to production.

Set secrets and variables

In GitHub, under Settings > Secrets and variables > Actions:

NameKindValue
CLOUDFLARE_API_TOKENsecretA token from the Edit Cloudflare Workers template, plus Account > Queues > Edit and Account > Hyperdrive > Read
CLOUDFLARE_ACCOUNT_IDsecretYour Cloudflare account id
DATABASE_URLsecretThe database's own connection string, the one the Hyperdrive was created with. The migrations can't go through Hyperdrive
NUXT_SITE_URLvariablehttps://your-domain. Baked into the build and every mailed link
NUXT_PUBLIC_TURNSTILE_SITE_KEYvariableFrom a Turnstile widget created for your hostname

The blog workflows use the same CLOUDFLARE_API_TOKEN, so if you run them the token also needs their permissions (Blog).

Everything else goes on the Worker as a secret. Secrets survive deploys, so you set each one once:

pnpm exec wrangler secret put NUXT_SESSION_PASSWORD --name <worker>

--name is required because wrangler.jsonc has no name. Nitro derives one from the origin git remote, or the package.json name without one, and pnpm build prints it as "Using auto generated worker name".

VariableNeeded when
NUXT_SESSION_PASSWORDAlways. 32 characters or more
NUXT_OG_IMAGE_SECRETAlways. Any random string. Unset, each build signs OG image URLs with its own, and older URLs answer 403
NUXT_TURNSTILE_SECRET_KEYAuth or the contact page is on. Forms skip the check until both Turnstile keys are set, and /contact answers 503
NUXT_EMAIL_FROM, NUXT_EMAIL_FROM_NAMEEmail is on. Without a sender every mail is refused with a 501, so nobody can verify an address
NUXT_PAYMENTS_STRIPE_SECRET_KEY, NUXT_PAYMENTS_STRIPE_WEBHOOK_SECRET, NUXT_PAYMENTS_STRIPE_REFS_*Payments are on, as shipped. Checkout answers 500 until they are set (Payments)
NUXT_OAUTH_<PROVIDER>_CLIENT_ID, NUXT_OAUTH_<PROVIDER>_CLIENT_SECRETOne pair per provider in auth.oauth, which ships empty
NUXT_MONITORING_<PROVIDER>_DSNmonitoring.enabled is on (Observability)
NUXT_CONTACT_ADDRESSThe contact page is on, as shipped. /contact answers 503 until it is set (Contact)
NUXT_BLOG_AUTHOR_EMAILOptional. Read per request, so changing it needs no rebuild

.env.example(Licensees only) lists every variable the kit reads and where each value comes from. After a cold start the Worker logs one [env] line naming everything starter.config.ts turns on that this deploy has no credentials for. Read it in wrangler tail after the first deploy.

Add your domain

The kit ships no routes, so the Worker answers on its *.workers.dev address. Add your domain at the top level of wrangler.jsonc:

"routes": [{ "pattern": "example.com", "custom_domain": true }],

wrangler deploy creates the DNS record and the certificate, but only for a zone in the same Cloudflare account. Anywhere else the deploy fails. www needs a second entry or a redirect rule, or it won't resolve.

Before the first deploy on that zone, turn off two settings Cloudflare enables by default:

  • Speed Brain (Speed > Settings > Content Optimization). Cloudflare answers its prefetches only from cache and never from a Worker, so every prefetched page and /_nuxt/ chunk gets a 503.
  • Web Analytics automatic setup (Web Analytics > your site > Manage site). It injects a beacon script that the kit's Content-Security-Policy blocks, so every page logs a CSP violation. The kit's own analytics run on the server (Analytics).

Point NUXT_SITE_URL at the domain and deploy again.

Deploy from your machine

cp .env.production.example .env.production
pnpm deploy:cf

It runs the same steps as the workflow: preflight, build, migrate, deploy. .env.production holds DATABASE_URL, NUXT_SITE_URL and NUXT_PUBLIC_TURNSTILE_SITE_KEY, and the build reads it instead of .env, so the http://localhost:3000 there never ends up in a mailed link. Wrangler deploys with your wrangler login, or with CLOUDFLARE_API_TOKEN when it is set. pnpm db:migrate:remote and pnpm db:studio:remote read the same file.

Ship a migration safely

Migrations run before the new Worker is uploaded, so for a short time the old code runs against the new schema. Adding tables, columns and indexes is safe. Dropping or narrowing something the live code still uses, such as a column it reads or the unique index an upsert names as its conflict target, breaks the old Worker until the new one lands, and for good if the deploy fails.

Split such a change across two releases: add the new shape and deploy, then drop the old one in the next pull request.

There is no rollback step. Revert the commit on main and let it deploy.

wrangler.jsonc

wrangler.jsonc(Licensees only) declares every binding the Worker gets. Run pnpm cf-typegen after you add or rename one.

KeyBindingWhat it is for
hyperdriveHYPERDRIVEPostgres. localConnectionString is the database pnpm preview uses
d1_databasesDBNuxt Content's pages. No app table lives there
kv_namespacesCACHENitro's cache, behind the swr route rules such as /blog
r2_bucketsAVATARSUploaded avatars. The build checks that every storage.purposes entry names a bucket declared here
queuesQUEUEBackground jobs and their dead letter queue (Background jobs)
durable_objects, migrationsAUTH_THROTTLEThe counters behind requireRateLimit and sign-in lockout (Rate limiting)
ratelimitsAUTH_RATE_LIMIT, ANALYTICS_RATE_LIMITCoarse limits in front of those counters and the analytics ingest. period takes only 10 or 60, and namespace_id is any number you pick
analytics_engine_datasetsANALYTICSThe cloudflare analytics sink. The dataset is created on its first write
aiAIWorkers AI. Always remote, so calls from pnpm dev are billed too (AI)
send_emailEMAILThe cloudflare mail provider
triggers.cronsnoneWhen scheduled tasks run. The build fails without one while auth or activity retention is on

name, main and assets are left out because nitro sets them. compatibility_flags is empty, but the build adds nodejs_compat, which the kit needs: the pg driver and runtime config both depend on it. observability.enabled turns on Workers Logs, and keep_vars keeps plain-text variables you set in the dashboard across deploys.

Configuration

Key or variableWhereDefaultWhat it does
namewrangler.jsoncderived by nitroPins the Worker's name
routeswrangler.jsoncnoneCustom domains. Without one the Worker is on *.workers.dev
NUXT_SITE_URLGitHub variable, .env.productionnoneThe site's URL, read at build
NUXT_PUBLIC_SITE_URLWorker variablenoneOverrides NUXT_SITE_URL without a rebuild
NUXT_IMAGE_PROVIDERbuild environmentnoneTurns on image optimization with a @nuxt/image provider, such as cloudflare
DATABASE_URLGitHub secret, .env.productionnoneWhere the deploy migrates. The Worker itself never reads it

Good to know

  • Two shipped defaults need the Workers Paid plan. On Free, the cloudflare mail provider delivers only to addresses verified in your account, so list resend in email.providers instead. Password hashing takes about 17 ms of CPU against Free's 10 ms limit, so steady password sign-ins end in Error 1102.
  • Images ship unoptimized: a 4 MB file in public/ reaches every visitor at 4 MB. NUXT_IMAGE_PROVIDER=cloudflare at build time needs Image Transformations on the zone and does nothing on *.workers.dev.
  • The first deploy starts with an empty database. The first account to sign up and verify its address becomes superadmin, so register yours right away.
  • The preflight's caching check needs Account > Hyperdrive > Read on the token. Without it the run logs a warning and deploys anyway, so check the setting once with pnpm exec wrangler hyperdrive get <id>.
  • pnpm dev runs on Node, not on workerd. Try Worker-only behaviour with pnpm preview, which builds the Worker and runs it under wrangler dev against a real Postgres (Database).

A link with a lock opens the kit’s private repository, which every licensee can read.