Skip to content

Setting Up Netlify Environment Variables for a Model API Key

9 min read · updated August 11, 2026

Netlify scopes environment variables along two independent axes: which deploy context a value applies to, and which parts of the platform may read it. Most guides use the first and ignore the second, which is how a production key ends up readable from a build triggered by a pull request.

Two axes, not one

Deploy contexts decide which value a deployment sees. Netlify documents these as production, deploy-preview, branch-deploy, dev, and branch:<name> for a specific branch, with wildcards such as release/* supported.

Scopes decide which part of the platform may read it at all. Netlify documents four: builds (the site build and build configuration), functions (serverless functions, edge functions and on-demand builders), runtime (forms and signed proxy redirects) and post-processing (snippet injection). By default a variable applies to all scopes. Scopes are documented as a Pro and Enterprise feature.

The reason the second axis matters for a provider key is that the build is the leakiest place a secret can live. A build runs arbitrary package scripts, writes a log, and — with a bundler — can inline a value into client-side JavaScript. A key that only ever needs to be read by a function at request time has no business being in the builds scope, whatever its context.

Setting the key

Netlify’s CLI reference documents env:set with a --context flag accepting the context names above, a --scope flag accepting builds, functions, post-processing or runtime and defaulting to all scopes, and a --secret boolean described as indicating whether the value can be read again.

  1. Link the directory to the site once with netlify link. Every env: subcommand acts on the linked site.
  2. Set the production value, narrowed on both axes and marked secret:
    netlify env:set OPENAI_API_KEY "$PROD_KEY" \
      --context production \
      --scope functions \
      --secret
  3. Set a different, restricted value for preview deploys. A separate key with a low spend cap is the point — see the last section for why an empty value is worse than a restricted one:
    netlify env:set OPENAI_API_KEY "$PREVIEW_KEY" \
      --context deploy-preview \
      --scope functions \
      --secret
  4. Set a local value for netlify dev if you want one, using the dev context. Alternatively keep local values in an untracked .env file, which netlify dev reads and which never touches the platform at all.
  5. Trigger a new deploy. As on every platform of this kind, a running deployment holds the values it was built and configured with; a change is not retroactive.

Verifying the scoping worked

This is the step that turns the tutorial into a fix. env:list takes the same --context and --scope flags, so you can ask the specific question rather than eyeballing a dashboard:

netlify env:list --context deploy-preview --scope builds
netlify env:list --context deploy-preview --scope functions

OPENAI_API_KEY should be absent from the first and present in the second. If it appears in the first, the variable was created before you started passing --scope and still carries the default of all scopes; env:set it again with the scope, or env:unset and recreate. Note that Netlify documents env:unset as deleting the variable and all its contextual values when no context is given, so pass --context unless you mean to remove every value.

env:list --plain emits .env format, which is useful for diffing two contexts and dangerous everywhere else — it prints values. Do not run it in CI where the output is captured into a log.

Reading it at runtime

In a Netlify Function, process.env works as expected. In an Edge Function — Deno, not Node — the platform-provided accessor is Netlify.env.get:

// netlify/functions/complete.mts
export default async (req: Request) => {
  const key = process.env.OPENAI_API_KEY;
  if (!key) {
    return Response.json(
      { error: "OPENAI_API_KEY missing in this deploy context" },
      { status: 500 },
    );
  }
  // ...
};
// netlify/edge-functions/complete.ts
const key = Netlify.env.get("OPENAI_API_KEY");

The explicit missing-key branch is worth the four lines. Without it a deploy preview with no value produces an Authorization: Bearer undefined header and a 401 from the provider, and the error you have to debug is the provider’s rather than yours.

Knowing which context you are in

Setting different values per context solves half the problem. The other half is code that should behave differently — a preview that must not send email, must not write to the production index, and should cap spend regardless of which key it was handed.

Netlify sets a read-only variable named CONTEXT whose value is the deploy context name: production, deploy-preview, branch-deploy or dev. It sits alongside BRANCH, COMMIT_REF, PULL_REQUEST, URL, DEPLOY_PRIME_URL, DEPLOY_URL, SITE_NAME, SITE_ID and DEPLOY_ID in the set Netlify documents as build environment variables.

That gives you a single switch to hang guard rails on, rather than inferring the environment from a hostname:

const isProduction = process.env.CONTEXT === "production";

const maxOutputTokens = isProduction ? 4096 : 512;
if (!isProduction) {
  // never touch the live index from a preview
  indexName = indexName + "-preview";
}

Two caveats. Netlify documents these under build environment variables and does not, on that page, state which are also present at function runtime — so verify by logging process.env.CONTEXT once from the deployed function rather than assuming, and fall back to an explicitly set variable of your own if it is absent. And a value read at build time is baked into the artefact, so if your framework inlines it the value describes the build, not the request; for a function that distinction rarely matters, but for anything cached it does.

What preview deploys should get

The instinct is to leave the key out of deploy-preview entirely. That is safer than sharing the production key and worse than the third option, because it means previews cannot exercise the code path that matters and the first real execution of a change happens in production.

  • A separate provider key with its own budget. Most providers support multiple keys per organisation with independent spend limits. This is the version that keeps previews useful and bounds the damage from a fork PR.
  • Never the production key. A deploy preview can be built from a contributor’s branch, and a build step is code execution with whatever the builds scope can see. This is the entire argument for the --scope functions flag above.
  • Rotate on exposure, not on suspicion of exposure. If a key was ever set without --secret and without a scope, treat it as read. Marking it secret afterwards does not retract a value that was already printed in a build log.

Rotation is worth rehearsing before you need it, because the naive order takes the site down. A running deployment holds the value it was configured with, so revoking the old key at the provider before a new deploy has landed leaves every in-flight function authenticating with a credential that no longer exists. The order that does not:

  1. Create the replacement key at the provider, leaving the old one active. For a few minutes both are valid, which is the entire trick.
  2. netlify env:set OPENAI_API_KEY "$NEW_KEY" --context production --scope functions --secret, matching the context and scope of the value you are replacing. Getting either wrong creates a second variable rather than updating the first.
  3. Trigger a deploy and wait for it to publish. Until it does, nothing has changed for live traffic.
  4. Confirm the new key is in use — a request that succeeds is not proof, since the old key would also succeed. Check the provider’s own per-key usage, which is the only place the two are distinguishable.
  5. Only then revoke the old key. If anything still authenticates with it, you find out now, deliberately, rather than during an incident.