Environment Variables on a Static Site Are Not Secret
Environment variables feel like configuration, and configuration feels private. On a static site that intuition is wrong in a specific and important way, and the platform documentation does not come out and say so.
Here is the sentence I could not find in any vendor’s docs: a value read during a static build ends up in the files you ship. Not necessarily verbatim, but in a form that ends up on a public CDN where anyone can read it. There is no runtime that could keep it back, because a static site has no runtime.
Why the distinction does not exist on a static site
On a server-rendered application, there are two places a variable can live:
- Build time — read while the code is being compiled or generated
- Runtime — read when a request arrives, on the server, and sent to the client only if you choose
Static sites only have the first. The output is files. If a value influences those files, it is in those files.
This is why the platforms’ documentation is organised the way it is. Cloudflare’s Pages documentation describes variables as available “for both your production and preview environments at runtime and build-time” — the “runtime” part referring to Functions, which are a separate execution model from static assets. For a site with no Functions, only build time exists.
The practical rule that follows: if it is a secret, it cannot influence the build. If it must influence the build, it is not a secret.
What is safe, and what is not
| Variable | Safe on a static site? |
|---|---|
| Analytics site ID | Yes — public by design |
| A public API base URL | Yes |
| Feature flags | Yes, if you accept they are discoverable |
| A build hook URL | No — anyone with it can trigger deploys |
| An API key for a paid service | No — it will be in the bundle or the requests |
| A database connection string | No |
| Anything you would not paste in a public chat | No |
The middle rows are where people slip. A build hook URL in a front-end script is a live credential: it can trigger a deployment, repeatedly. An API key used in a client-side fetch is visible in the network panel of every visitor.
The documented guidance, and what it actually covers
The platform documentation does say useful things, just not the sentence above.
Cloudflare’s Pages documentation states that variables are “stored as plain text” and that you set them per environment. Separately, it documents secrets as a distinct binding type: “encrypted text values… You cannot see secrets after you set them and can only access secrets programmatically on context.env.”
Note what that describes. Encryption here protects the value from other people with access to your dashboard — it is write-only after creation. It does not change what happens at build time, and it does not make a value safe to inline into output.
Cloudflare’s Workers documentation is blunter and applies the distinction you actually need, even though it is written for a runtime context: “Do not use plaintext environment variables to store sensitive information. Use secrets instead.” And the Pages documentation gives the operational rule directly: “Do not commit secrets to git”, with .dev.vars* and .env* named for gitignore.
So the guidance is real but incomplete: it tells you how to store values safely and how to keep them out of version control. It leaves the inference — that build-time values are public in the output — to you.
The two operational rules that catch people
A changed variable does not update an existing deployment. This is documented explicitly by Vercel and it is true of the model generally: “Any change you make to environment variables are not applied to previous deployments, they only apply to new deployments.”
So changing a variable in the dashboard does nothing until you rebuild. On Cloudflare Pages, secrets specifically have a documented ordering constraint: they must be set before the deployment that uses them. Set a secret after the deploy and it is simply absent for that build — no error, just a missing value.
The symptom is that your change “did not work,” which reads as a platform bug and is a rebuild you have not triggered.
Rotation has an order, and getting it wrong causes an outage. The documented sequence for rotating a credential is: update the value in the platform first, then invalidate the old credential, then redeploy. Do it in the other order and every deployment — including the ones already serving traffic on their next build — reaches for a credential you have just killed.
Vercel documents this dependency and the reason: old deployments keep the old value until they are redeployed. Which means during a rotation window, some deployments are using the old credential and some the new one, and both need to be valid simultaneously. That is what makes “update, then invalidate, then redeploy” a sequence rather than three independent actions.
What the platforms inject for you
Worth knowing, because it is free context you can build on. Cloudflare Pages provides injected build variables including CI, CF_PAGES, CF_PAGES_COMMIT_SHA, CF_PAGES_BRANCH and CF_PAGES_URL.
The commit SHA is the useful one — it lets you embed a build identifier, generate a canonical URL for the exact version, or surface “deployed from commit abc1234” in a build log. It costs nothing and it is available without configuration.
The scope trap
All three platforms let you define a variable per environment — production, preview, and often development or a per-branch context. That is a feature, and it is also a documented source of confusion.
Vercel’s documentation is explicit that values are “encrypted at rest and visible to any user that has access to the project” — so the audience includes everyone on the team, in every environment. And Netlify’s documentation spans five contexts (production, deploy previews, branch deploys, preview server, local development) with separate scopes for builds, functions, runtime and post-processing.
The trap is setting a value for production and forgetting the preview scope, then testing on a preview deployment with a stale or absent value. Everything looks right in the dashboard and the preview build does something different. When a variable “works in production and not in preview,” this is the first thing to check.
Netlify’s own recommendation is worth adopting as a default even if you use a different platform: “We recommend using the Netlify UI, CLI, or API, where possible, to avoid storing sensitive values in your repository.”
A short policy for static sites
- Anything that influences the build is public. Assume it, and design accordingly. The only secrets on a static site are secrets that no build step has ever seen.
- Put genuinely secret work behind a function or a server. If you need a private API key, the request has to be made by something that is not the visitor’s browser.
- Gitignore
.envand.dev.varsbefore your first commit. Adding them later means the value is already in history. - Treat build hooks as credentials. They usually live in a CI secret, not in a front-end file.
- Remember the rebuild requirement. A changed variable needs a new deployment, not just a dashboard save.
- Rotate in the order: update, invalidate, redeploy.
The headline is not that static sites are insecure. It is that the security model is different and narrower, and the documentation tells you how to store values without telling you what happens to the ones the build reads.
Written by TestedHost. Every recommendation on this site comes from running the setup described, on a live deployment — not from a vendor spec sheet. Spotted something out of date? Tell us.