How to configure redirects, headers, and env vars in netlify.toml

netlify.toml controls builds, redirects, headers, and environment variables. The rules, the precedence order, and the six footguns that cost people an afternoon.

Reference

Key takeaways

netlify.toml sits at your repo root and controls build settings, redirects, headers, and build-time environment variables. It overrides the Netlify UI when the two conflict. The _headers and _redirects files live in your publish directory and are processed before netlify.toml rules. Redirects match top-down, first match wins.

  • Env vars in netlify.toml don’t reach your functions. They only get the Builds and Post processing scopes. Runtime vars go through the UI or netlify env:set.
  • [[redirects]] and [[headers]] are global. They can’t be scoped to a branch or deploy context.
  • Headers apply only to files Netlify serves itself. Functions, proxied content, and SSR pages set their own.
  • The SPA fallback is /* /index.html 200. Status 200, not 301.
  • Never put a secret in a client-prefixed var (VITE_, NEXT_PUBLIC_, PUBLIC_). They’re inlined into your bundle, and --secret doesn’t help.
  • .env isn’t read by the Netlify build system. Import with netlify env:import first.

The file that decides everything

Most Netlify questions that start with “why is it doing that” end up in this file. A redirect that fires when it shouldn’t. A header that’s silently ignored. An environment variable that’s set, visible in the dashboard, and undefined in your function.

The reason those get confusing is that netlify.toml isn’t one system. It’s four, with different rules about scoping, precedence, and where they apply. Once you know which of the four you’re touching, the surprising behavior stops being surprising. Let’s go through them, and lead with the footguns rather than burying them.

Footguns first

These account for most of the lost time. Read them before you write config.

Env vars in netlify.toml are not available to functions or edge functions at runtime. Reading them there returns undefined. Variables declared in netlify.toml are locked to the Builds and Post processing scopes only. Set runtime variables in the UI or with netlify env:set.

Never put secrets in client-prefixed variables. VITE_, NEXT_PUBLIC_, PUBLIC_, and friends are inlined into the client bundle by your framework. Marking them --secret does not protect them. They ship to the browser.

.env is not read by the Netlify build system. Import your variables into Netlify first with netlify env:import. The CLI reads .env only for local builds.

Direct env injection into netlify.toml doesn’t work. key = "$VAR" is unsupported, with one exception: signed proxy redirects. Use a build plugin or sed in the build command instead.

[[redirects]] and [[headers]] are global and not context-aware. You can’t scope them to a branch or deploy context. The workaround is a per-context build command that copies a different file into the publish directory.

Proxy rewrites time out at 26 seconds, and HTTP 307 is unsupported. Use 302.

Worked example: core structure

[build]
base = "project/" # base directory
publish = "build-output/" # relative to base, default /
command = "npm run build" # runs in Bash shell
[build.environment]
NODE_VERSION = "24"
[context.production] # production deploy context
command = "make publish"
environment = { NODE_VERSION = "24" }
[context.deploy-preview] # PR/MR previews
publish = "dist/"
[context.branch-deploy] # non-production branches
command = "echo branch"
[context.dev.environment] # local dev env vars ONLY
NODE_ENV = "development"
[context.staging] # a specific branch name
command = "echo staging"
[context."feat/branch"] # quote branches with special chars
command = "echo special"

Precedence, least to most specific: UI settings, then the base context-aware key, then [context.production|deploy-preview|branch-deploy|dev], then [context.branchname]. Only [build] and [[plugins]] are context-aware. All paths are absolute relative to the base directory.

Netlify looks for the config file in this order: package directory, base directory, root.

Redirects and rewrites

One rule per line in _redirects, or a [[redirects]] block in netlify.toml. Rules process top-down and the first match wins. File-based rules run before netlify.toml rules.

/home / 301
/my-redirect / 302
/store id=:id /blog/:id 301
/news/* /blog/:splat
/* /index.html 200 # SPA fallback
[[redirects]]
from = "/old-path"
to = "/new-path"
status = 302 # default 301
force = true # default false; shadow an existing URL
query = { id = ":id" }
conditions = { Language = ["en"], Country = ["US"], Role = ["admin"] }
[redirects.headers]
X-From = "Netlify"

Things that catch people:

  • You can’t shadow an existing URL by default. Append ! in _redirects or set force = true.
  • Splats only work at the end of a path segment. /jobs/*.html won’t match. And you can’t exclude a path from a splat; order a more specific rule above it.
  • query = { id = ":id" } matches URLs with only id and no other parameters. If you need optional-parameter variants, list the most general last.
  • You cannot add or remove a trailing slash with a redirect. URLs are normalized before rules run, so you’d get an infinite loop. Pretty URLs, on by default, handle standardization.
  • Country and Language conditions take no spaces: Country=au,nz. Country is ISO 3166-1 alpha-2. Language matches the first Accept-Language entry. The nf_country and nf_lang cookies override.
  • Domain redirects need separate HTTP and HTTPS rules unless you’re forcing SSL, and the domain has to be assigned to the site.
  • Role-based redirects with external auth are Enterprise only.
  • At 10,000+ redirects, favor wildcards and placeholders. Serialization across _redirects plus netlify.toml can get large enough to fail the deploy; consider Edge Functions instead.

Rewrites and proxies

Status 200 proxies rather than redirects:

/api/* https://api.example.com/:splat 200
/netlify-site/* https://my-other-site.netlify.app/:splat 200
[[redirects]]
from = "/search"
to = "https://api.mysearch.com"
status = 200
force = true
headers = { X-From = "Netlify" }

No cross-team rewrites between Netlify sites. Infinite-loop rules where from equals to are ignored. Internal rewrites are limited to one hop. The proxy timeout is 26 seconds, so go async for anything longer. And rewrites break relative-path assets, so use absolute paths or a <base> tag. To proxy to another Netlify site, use its .netlify.app subdomain; rewrites into a separate password-protected site aren’t allowed.

Signed proxy redirects

netlify.toml only, and the one place $VAR-style injection is allowed:

[[redirects]]
from = "/search"
to = "https://api.mysearch.com"
status = 200
force = true
signed = "API_SIGNATURE_TOKEN_PLACEHOLDER"

The variable’s scope must include Runtime. Not supported for Netlify-to-Netlify proxying. Netlify sends the JWS as HMAC HS256 in the x-nf-sign header.

Custom headers

/*
X-Frame-Options: DENY
cache-control: max-age=0
cache-control: no-cache # multi-value collapses comma-joined
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
Basic-Auth = "someuser:somepassword anotheruser:anotherpassword"
cache-control = '''
max-age=0,
no-cache,
no-store'''

Headers apply only to files Netlify serves from its own store. Proxied content, functions, and edge or SSR pages have to return their own headers. That’s the single most common reason a header appears to do nothing.

Netlify controls some header names and ignores yours if you set them: Content-Length, Content-Encoding, Location (use redirects), Set-Cookie (may be overridden), Server, Date, Age, Connection, Transfer-Encoding, and others.

Basic-Auth headers are Pro and Enterprise. Cross-subdomain cookies aren’t possible on *.netlify.app because it’s on the Public Suffix List; you need a custom domain.

Environment variables

Set runtime and scoped variables through the CLI, UI, or API rather than netlify.toml:

netlify env:set MY_KEY value --secret # --secret marks an env var secret
netlify env:import .env # site-level, all scopes, all contexts
netlify env:list --plain --context production > .env
netlify env:unset MY_KEY

Keep any .env snapshot gitignored. Never commit it.

Types. Site variables belong to one site. Shared variables cover the whole team, are Pro and Enterprise, and only Team Owners can set them.

Scopes (Pro and Enterprise; default is all): Builds, Functions (which also covers Edge Functions and On-demand Builders), Runtime (forms and signed proxy redirects), and Post processing (snippet injection). Variables from netlify.toml are locked to Builds plus Post processing.

Scope precedence resolves independently per scope. This one is subtle and worth stating carefully: a site variable scoped only to Builds does not shadow a shared variable for the Functions scope. The shared value still applies there. A site variable beats a shared variable only within the scopes it actually carries.

Deploy-context values: Production, Deploy Previews, Branch deploys (overridable per branch with a Branch value, wildcard suffix like release/*), Preview server, Local development.

Limits: keys up to 255 characters, alphanumeric plus underscore, first character a letter (KEY1 is valid; 1KEY and _KEY1 are not). Values up to 5,000 characters, with functions still bound by AWS limits. Reserved read-only names can’t be overridden.

Injecting values into headers and redirects

Because $VAR isn’t supported directly, substitute at build time:

[build]
command = "sed -i \"s|HEADER_PLACEHOLDER|${PROD_API_LOCATION}|g\" netlify.toml && yarn build"

This works because [[headers]] and [[redirects]] are read after the build. It isn’t available to build plugins. The alternative is mutating netlifyConfig in a local build plugin.

Secrets Controller

Mark a variable secret with --secret on the CLI, is_secret: true via the API, or in the UI. The policy is enforced and not customizable:

  • Values are write-only. There’s no readable version after setting, and removing the flag won’t reveal it.
  • They must be set to explicit deploy contexts and scopes, and cannot carry the Post processing scope.
  • Only code running on Netlify reads unmasked values. Outside code gets masked. The dev context value is unmasked and exempt.
  • Secret scanning runs on the next build after you mark a variable secret. Resolve a detection by removing the value at the location named in the deploy log, then redeploy. Safelist false positives with SECRETS_SCAN_SMART_DETECTION_OMIT_VALUES (comma-separated), then redeploy.

Sensitive variable policy applies to public repos only. Untrusted deploys from unrecognized authors default to Require approval. The alternatives are Deploy without sensitive variables or Deploy without restrictions. Not available for GitHub Enterprise Server or GitLab self-managed, which are treated as private.

Common failure modes

Your function reads undefined for a variable you definitely set. It’s in netlify.toml. Move it to netlify env:set or the UI, with a scope including Functions.

A header you set is being ignored. Either it’s on a function, proxied, or SSR response (those set their own headers) or it’s one of the reserved names Netlify controls.

Your SPA 404s on refresh. You need the fallback with status 200, not a redirect:

[build]
command = "npm run build"
publish = "dist" # varies by framework
[[redirects]]
from = "/*"
to = "/index.html"
status = 200 # required for pushState routing to avoid 404s

Uncaught SyntaxError: Unexpected token after a deploy. Hashed and code-split filenames combined with atomic deploys can break asset references. Disable hashed filenames, use permalinks, or add a service worker.

Your monorepo builds the wrong thing. Set the site’s subdirectory as the package directory and keep netlify.toml there, leaving base at the repo root. Note that package directory is UI-only (Build settings > Configure) and can’t be set in netlify.toml. Base directory can be set in a root-level netlify.toml and overrides the UI.

Your ignore command isn’t stopping builds. Check the exit codes, which are the opposite of what many people assume: exit 0 means no changes and the build stops; exit 1 means changed and the build continues.

[build]
ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF packages/blog"

It runs from the base directory on a fixed Node.js 18 that you can’t change, your site’s package.json dependencies aren’t available, and referenced file paths must start with ./. It also won’t cancel a build triggered by a build hook, whatever it returns.

You set local env vars under [dev] and nothing happened. [dev] has no environment property. Use [context.dev.environment].

Reference

Functions config

[functions]
directory = "functions/" # default: YOUR_BASE_DIR/netlify/functions
node_bundler = "esbuild" # recommended; zisi is the JS default but esbuild is required for TypeScript
external_node_modules = ["package-1"]
included_files = ["files/*.md", "!files/skip.md"]
[functions."api_*"] # glob filter; values CONCATENATE across matches
external_node_modules = ["package-2"]

esbuild produces smaller, faster artifacts, and TypeScript functions always use it. external_node_modules only applies with esbuild. In included_files, * is a wildcard and ! excludes; paths are absolute to base.

Build variables settable in [build.environment]

NODE_VERSION, NODE_ENV, NPM_VERSION, NPM_FLAGS, NPM_TOKEN, YARN_VERSION, PNPM_FLAGS, BUN_VERSION, RUBY_VERSION, PHP_VERSION, PYTHON_VERSION, GO_VERSION, HUGO_VERSION, NETLIFY_USE_YARN, CI.

Must be set in the UI or CLI, not netlify.toml (which is read after clone): AWS_LAMBDA_JS_RUNTIME, GIT_LFS_ENABLED, GIT_LFS_FETCH_INCLUDE, NETLIFY_BUILD_DEBUG.

Read-only build metadata

NETLIFY, BUILD_ID, CONTEXT (production / deploy-preview / branch-deploy / dev), BRANCH, HEAD, COMMIT_REF, CACHED_COMMIT_REF, PULL_REQUEST, REVIEW_ID, URL, DEPLOY_URL, DEPLOY_PRIME_URL, DEPLOY_ID, SITE_NAME, SITE_ID, ACCOUNT_ID.

Access them as $VAR_NAME in build and ignore commands, or process.env.VAR_NAME in Node scripts and plugins. The scope must include Builds.

Env var scopes

ScopeCovers
BuildsBuild commands, plugins, ignore commands
FunctionsFunctions, Edge Functions, On-demand Builders
RuntimeForms, signed proxy redirects
Post processingSnippet injection

Variables declared in netlify.toml get Builds and Post processing only.

Plugins, extensions, and dev

[[plugins]]
package = "@netlify/plugin-lighthouse"
[plugins.inputs]
breeds = ["pomeranian"]
[[integrations]] # extensions; install on team first
name = "abc-performance-extension"
[integrations.config]
output_path = "reports/perf.html"
[dev] # Netlify Dev — NOT run in Bash; no `environment` key here
command = "yarn start"
targetPort = 3000 # if command + targetPort both set, framework must be "#custom"
port = 8888
publish = "dist"
[dev.https]
certFile = "cert.pem"
keyFile = "key.pem"

framework accepts #auto (default), #static, or #custom. For Deploy-to-Netlify buttons, use [template] and [template.environment].

Pretty URLs:

[build.processing.html]
pretty_urls = true

FAQs

How do I set up a redirect on Netlify? Add a [[redirects]] block to netlify.toml with from, to, and status, or a line in a _redirects file in your publish directory. Rules match top-down and the first match wins; _redirects is processed before netlify.toml.

Why is my environment variable undefined in a Netlify Function? Because you declared it in netlify.toml. Those variables only get the Builds and Post processing scopes. Set it with netlify env:set or in the UI, with a scope that includes Functions, then redeploy.

How do I add a SPA fallback so client-side routes don’t 404? Redirect /* to /index.html with status 200. A 301 or 302 will break pushState routing.

Why isn’t my custom header being applied? Headers in netlify.toml and _headers only apply to files Netlify serves from its own store. Functions, proxied content, and SSR pages must set their own headers. Also check whether it’s a reserved name Netlify controls.

Can I scope redirects or headers to a specific branch? Not directly. [[redirects]] and [[headers]] are global. The workaround is a per-context build command that copies a different rules file into the publish directory.

Does netlify.toml override the Netlify UI settings? Yes, on conflict. _headers and _redirects are processed before netlify.toml rules.

Is it safe to put an API key in VITE_API_KEY? No. Client-prefixed variables are inlined into your browser bundle. Marking them --secret does not change that. Keep secrets server-side and read them in a function.

Why did my build not stop when my ignore command returned 0? It should have. Exit 0 means no changes and stops the build; exit 1 continues. If it was triggered by a build hook, the ignore command won’t cancel it regardless.

Where does Netlify look for netlify.toml? Package directory, then base directory, then repo root.

This article is generated from Netlify’s open-source agent guidance at netlify/context-and-tools, the same reference our AI coding agents use.


Ready to configure something? Start at netlify.new.