Two Kinds of Variables in Cloudflare Workers
ยทโ๏ธ๏ธ 13 mins read
Working with Nuxt makes it super easy to work with environment variables. You just need to add them to the .env file and they will be available in the process.env object. More so, if you are using the runtimeConfig feature, you can access them in your code using the useRuntimeConfig(). Also, nuxt populates the process.env object AND the runtimeConfig object with the variables from the .env file (or if you expose them in other ways in your machine).
But one day, you want to add them in Cloudflare Workers because you want to use them in production, and you discover that Cloudflare has 2 different environments for variables: build variables and runtime variables. Workers treat secrets differently depending on where they live.
Combining the 2 had more nuances than I expected, and it took me a while to figure out why my CUSTOM_ENV_VARS were not available in my Cloudflare Workers runtime code.
The mental model about build and runtime variables
Before we dive into the details, let's understand one basic difference. There are two distinct phases, and each has its own environment:
- Build time โ your CI runs the build command (
nuxt build, thenwrangler deploy). Build variables exist here and only here. - Runtime โ the deployed Worker serves traffic. Runtime variables and secrets are bound to the Worker and available on every request.
Build variables are like the oven settings. They are used to configure the build process. Runtime variables and secrets are like the ingredients in the dish. They are used to configure the runtime behavior of the Worker.
Side by side
Two things worth pinning down. A "build secret" only means encrypted for the build โ it is still gone at runtime. And a runtime Secret is write-only: you set it once and can never read it back through the dashboard or API.
(*) The env binding is a special binding that is available in the Worker's runtime. Example API:
// inside Worker code
import { env } from 'cloudflare:workers'
const { MY_VARIABLE } = env
Difference between "deployments" and "builds" (the more you know...)
This is something that clicked when I was debugging environment variables. Since one of the latest Cloudflare UI redesigns, I was confused about the difference between the "Deployment" and "Build" sections in "Workers > Deployments".
The fact that you need an extra click to see builds and trigger one (as far as I know) was and still is confusing to me. But at least, it made me realize that builds and deployments are different things.
- Deployments: include all the deployments to production, including changes in the code, variables and manual deployments (either via
wranglerCLI or the dashboard). - Builds: include all the builds triggered by code changes or manual builds (via
wranglerCLI or the dashboard). - If you need new runtime variables, a deployment (no build) suffices. โจ
- If you need new build variables, you need to create a new build.
Runtime variables and secrets: the limits
- Absent during the build. Build-time codegen can't see them โ they don't exist yet when
nuxt build(for example) runs. - Secrets are write-only. You can't read a Secret back. Lost it? Rotate it.
- โ ๏ธ Text vars are visible. Plaintext Text variables are readable in the dashboard. Don't put credentials in them โ use a Secret. It's the equivalent of writing down the password on a piece of paper and leaving it on the desk.
Build variables: the limits
- Invisible at runtime. The docs are clear: If runtime code tries to get it from
process.env, it will find nothing. - A build freezes the value. If you inline a build variable into your bundle, it's frozen at the value present during that build. It stays unchanged until the next build.
- โ ๏ธ Secrets can leak into the artifact. Marking a build variable
is_secret: truemasks it in the build logs. It does not stop your bundle from inlining the value into the output build that ships.
The Nuxt trap: runtimeConfig vs process.env
This is where SSR developers get bitten, because Nuxt exposes two different surfaces for configuration and they don't resolve to the same values by default and less so in Cloudflare Workers.
runtimeConfig is resolved at build โ Nuxt reads process.env during nuxt build and bakes the values in. At runtime, Nuxt overrides those keys from environment variables using the NUXT_-prefix convention. Uppercase, underscores mark nested keys:
Crucially, that override only rewrites the object returned by useRuntimeConfig(). It does not write the value back into process.env if you only configured the Nuxt config file.
If you want to read how Nuxt populates the
runtimeConfigobject, you can read .
๐ก The important thing to remember is that:
- Nuxt reads from
process.envwhen buildingruntimeConfig. - Your code (or a Worker) can also read
process.envdirectly.
The mental model in local development is easier. But it can be confusing when working with Cloudflare Workers.
โ In Cloudflare Workers:
process.env on a Worker is empty by default. It gets auto-populated from the Worker's runtime variables and secrets only when nodejs_compat is enabled and your compatibility date is โฅ 2025-04-01 (the nodejs_compat_populate_process_env default; it's on by default at/after that date, and can also be set explicitly). Build variables never appear there.
// wrangler.jsonc
{
"compatibility_date": "2025-07-20",
"compatibility_flags": ["nodejs_compat"],
}
The consequence: these two reads do not see the same thing.
const config = useRuntimeConfig() // NUXT_-prefixed overrides + exposed env vars in commands + runtimeConfig (build-baked) values
const direct = process.env.FOO // only if `nodejs_compat` + compat date โฅ 2025-04-01 + only if it's a runtime variable (not a build variable)
A value can be present via one path and absent via the other depending on the context. ๐ตโ๐ซ
A concrete example: Nuxt Studio
Here's a real-world example of how this can bite you.
Note: This is just an example of how a third-party could be affected by the Cloudflare environment variables nuance.
Nuxt Studio accepts the same GitHub OAuth credential under two runtime names, through two different means:
NUXT_STUDIO_AUTH_GITHUB_CLIENT_ID(Or manualruntimeConfig.studio.auth.github.clientIdor module options). This is (at the time of writing).STUDIO_GITHUB_CLIENT_ID(un-prefixed) โ read straight fromprocess.envby a , which backfills the same config field:
// studio-env.ts โ runs per request, bridges process.env into runtimeConfig
const config = useRuntimeConfig(event)
// ...
const github = config.studio.auth.github
github.clientId = github.clientId || process.env.STUDIO_GITHUB_CLIENT_ID || '' // depends on what you confgured in CF and in your code
But then, both the and then read useRuntimeConfig(event).studio โ neither reads process.env directly.
If no provider resolves, Nuxt Studio () throws:
throw createError({ statusCode: 404, message: 'No authentication provider found' })
๐งจ Now the trap. Whether the credential reaches the code comes down to two questions: did you manually map runtimeConfig to process.env in nuxt.config, and is nodejs_compat on?
- You manually mapped
runtimeConfigโprocess.env(innuxt.config), and used a build var withnodejs_compatand a sufficient compat date โ โ- missing any of those steps โ ๐ฅ
- No manual
runtimeConfigmapping (you rely on the module's defaults), withnodejs_compaton:- only build vars โ ๐ฅ
- only
STUDIO_GITHUB_CLIENT_ID(whether in build vars or runtime vars):config.studio.auth.githubcould break in the future if the module ever stops falling back toSTUDIO_GITHUB_CLIENT_IDโ risky on their side, IMO- but
STUDIO_GITHUB_CLIENT_IDalone in runtime variables in CF works now โ โ
- only
NUXT_STUDIO_AUTH_GITHUB_CLIENT_ID? โ โ TBH, I did not try this path. The it should work, but I can't see why: (โ) I'd expect it to be transformed toclient.id, notclientId. Module options haveclientIdwhen used directly, but I don't know whether Nuxt maps the env var to module options under the hood.
Checklist
- Does the value need to be available while serving requests? โ runtime variable / secret.
- Only needed during the build? โ build variable.
- Reading it via
process.envin a Worker? โ confirmnodejs_compatis on, your compatibility date is โฅ2025-04-01, and that it's a runtime value โ not a build one. - Mixing
useRuntimeConfig()andprocess.env? โ they're different surfaces. Verify the specific one your code path actually reads. - Did you map
runtimeConfigtoprocess.envvariables in thenuxt.configfile? โ The value you will get withuseRuntimeConfig()may not be the same as the one you will get withprocess.env.runtimeConfigis meant for fallback values and the values you pass cannot be overridden byprocess.env. If you want to override a value you used in theruntimeConfigobject, you need to useNUXT--prefixed variables in Runtime variables / secrets. If you want to know more about this, see this .
โน๏ธ One caveat: the 2025-04-01 compatibility-date threshold is current as of writing, but Cloudflare's defaults evolve โ check the before you rely on it.
Conclusion
- Today I learned something new about Cloudflare Workers and how to work with their environment variables.
- I'm glad I debugged Nuxt Studio and made the basic setup with GitHub OAuth work within Cloudflare Workers.
- Cloudflare should improve their UI.
- I should contribute to Nuxt Studio to improve the docs, specially for Cloudflare users.
I hope this article helped you. If you found it useful, please consider sharing it with your friends and colleagues. If you have any questions or comments, please feel free to . ๐๐ฝโโโจ