← Blog
/POST · ALFIE MILLS

Deploying this Nuxt site to Cloudflare Workers

This very website is a Nuxt 3 app: Nuxt Content for the blog you're reading, Nuxt Image for assets, @vueuse/motion for the page transitions, and JetBrains Mono pulled in at build time. For a long time it lived on Netlify. This post is about moving it to Cloudflare Workers, and specifically about the one part that fought me the hardest: getting a secret to the server at runtime.

From Netlify to a Worker

The original deploy was the boring, happy path: push to a repo, Netlify builds it, Netlify Forms handles the contact form. Migrating to Cloudflare meant picking a Nitro preset, and that took a few false starts. I tried cloudflare-pages, reverted, fiddled with pages_build_output_dir, and eventually landed on the one that actually fits a full Nuxt app with server routes:

// nuxt.config.ts
nitro: {
  preset: 'cloudflare-module',
},

That preset builds a single Worker entry point. The wrangler.toml points at it and serves the built static files through an assets binding:

name = "alfiemills-website"
main = ".output/server/index.mjs"
compatibility_date = "2024-11-01"
compatibility_flags = ["nodejs_compat", "nodejs_compat_populate_process_env"]

workers_dev = false

[assets]
directory = ".output/public"
binding = "ASSETS"

One non-obvious line there is workers_dev = false. The site is served on a custom domain, not a *.workers.dev subdomain, and leaving the subdomain enabled makes wrangler fire a post-deploy API call to provision it. That call intermittently 503s and fails the whole deploy for no good reason. Turning it off skips the call entirely.

The contact form, take two

Netlify Forms obviously don't exist on Cloudflare, so the contact form became a Nitro server route that posts to Resend. It's small: a honeypot field to swat bots, basic validation, then a fetch to the Resend API.

// Honeypot
if (body['bot-field']) {
  return { ok: true }
}

const { name, email, message } = body
if (!name?.trim() || !email?.trim() || !message?.trim()) {
  throw createError({ statusCode: 400, message: 'All fields are required' })
}

Simple enough. Except it kept failing in production with a 500, because the Resend API key was never reaching the handler.

The runtime-config fight

In Nuxt, secrets live in runtimeConfig and you read them with useRuntimeConfig(). Locally, Nuxt populates that from a NUXT_RESEND_API_KEY environment variable and everything works. On Cloudflare Workers, it just wasn't there. The key was set as a Worker secret, but useRuntimeConfig() came back empty.

The reason is that Workers don't expose secrets on process.env the way Node does. Nitro hands you the request's environment through the H3 event instead, and Nuxt's auto-imported helper doesn't reach for it unless you give it the event. So the first fix was passing the event in:

const config = useRuntimeConfig(event)

That helped, but not everywhere, so the next move was telling the Workers runtime to actually populate process.env from bindings, via a compatibility flag:

compatibility_flags = ["nodejs_compat", "nodejs_compat_populate_process_env"]

Still flaky across environments. The honest truth is I stopped trying to find the one correct source and instead read from all of them, in order, falling through until something is non-empty:

const config = useRuntimeConfig(event)
const globalEnv = (globalThis as any).__env__ as Record<string, string | undefined> | undefined
const cfEnv = (event.context.cloudflare as any)?.env as Record<string, string | undefined> | undefined

const resendApiKey =
  config.resendApiKey ||
  globalEnv?.NUXT_RESEND_API_KEY ||
  cfEnv?.NUXT_RESEND_API_KEY ||
  process.env.NUXT_RESEND_API_KEY ||
  ''

It's belt, braces, and a second belt. Not elegant, but event.context.cloudflare.env is the one that reliably has the value on Workers, and the fallback chain means the same code path also works in local dev and in a plain Node preview without me thinking about it. That whole saga was five commits I'd rather not relive, all to move one string from a secret store into a request handler.

Seeing what the Worker is doing

The last piece was turning on observability so I'm not debugging blind. Workers can persist logs and invocation traces, which is the difference between "the form is broken" and "Resend returned a 422 because of the from address":

[observability]
enabled = true
head_sampling_rate = 1

[observability.logs]
enabled = true
persist = true
invocation_logs = true

With that on, the console.error('Resend error:', ...) in the catch path actually shows up somewhere I can read it.

What I'd tell past-me

Two things. First, on Cloudflare, reach for event.context.cloudflare.env directly for secrets and stop fighting useRuntimeConfig; the abstraction leaks here. Second, turn on observability before you need it, not after the third failed deploy. The migration itself was straightforward once the preset was right. It was the invisible runtime environment, the thing you can't see until it's wrong, that ate the time.