DevOps & CI
If you are searching for preview secrets, or for Vercel environment variables, you are typically in one place: a pull request has a clickable URL, and someone put the same keys on it as on production, 'so it works'. Stripe live. A database with real customers. The shop webhook secret. A cron that touches production. The preview-deploys post is why you want a URL on the branch at all. The staging post is when you owe a copy that looks like production. This one is the keys. A preview that can do what production can is not an environment. It is a leak you call convenience.
A preview URL is shared: in the PR, in Slack, in a mail to the client, sometimes indexable if you forget robots. It is not a VPN. It is not 'just for us'. If it can charge a card, read personal data or call the webhook you use in the shop, you have given the person who opened the link what you thought you had locked behind the deploy button. The public-site security post is headers and admin on www. This is the duty to let preview be a different set of secrets — or to let it be poor enough that it cannot hurt.
TL;DR
The main takeaways
- Preview is shared. Production is what you owe yourself to keep secret. The same key in both is a leak.
- Test keys, a copy database and a dummy webhook beat 'it works like live' on a PR URL.
- Vercel, Cloudflare and GitHub Environments are shelves. They are not a split until you actually put different values in them.
- A cron, a migration or a Stripe live call on preview is how you hit real customers from a branch.
- The staging post is a copy that looks like prod. Preview deploys are the URL on the PR. This is refusing to share secrets across them.
- You have split the environments when you can say what preview cannot do — not when you have three URLs with the same .env.
Why this matters at all
A preview deploy is a promise that someone can see the branch without running it locally. The promise rots when you fill the URL with what production uses to take money, store customers and listen to webhooks. You have not made review easier. You have moved production access onto a link you paste in a comment. The person you invited to 'just check the button' can now do what you thought only the pipeline could.
Teams do it because the first preview that fails on a missing env is met with 'put the same as prod so we can move on'. It works. It becomes the habit. Three months later every PR has Stripe live, a connection string to the real base, and a webhook secret you rolled last year. You do not have a preview environment. You have production with a different hostname and without the gate you put on main.
The damage is concrete. A test checkout on preview charges a real card. A seed script empties or writes the real base. A webhook from Stripe hits both prod and the preview you forgot, because you pointed the endpoint at the wrong place. An AI key on a public preview gets scraped. You notice when someone asks why there are two charges, or why a branch 'just needed prod data'. You fix that one PR. You do not touch the shelf.
We write this because 'env is set' has become a tick you add when the build is green. An honest setup is boring: preview has test keys and a base you are allowed to break. Production has the real ones, and only the pipeline to main (or the host you actually own) can read them. Without that split you have a .env you copied. You do not have environments.
What a preview secret actually is — and what an env shelf is not
A secret is a value that opens something you do not want out. An API key, a database URL, a webhook secret, a signing key, a service account. A preview secret is the variant you are willing to let follow a branch URL. A production secret is the variant you only want the host that takes real users to read. The difference is not the variable name. The difference is which door the key opens.
It is not the same as 'we have Preview, Preview, Production in Vercel'. The scopes are shelves. If you put the same value on all three, you have one shelf with three labels. Cloudflare, Netlify, GitHub Environments, Doppler, Infisical — same job. A split exists when the preview value cannot do what the prod value can. A `STRIPE_SECRET_KEY` that is `sk_live_…` on preview is production. You just hung it on another URL.
It is also not the same as a staging environment. The staging post is a long-lived copy that should look like production in shape: the same migrations, the same jobs, often anonymised data. Preview is short-lived and PR-bound. They can share the principle — their own keys — but they are not the same job. Calling preview staging because the URL 'looks like it' is how you give a feature branch access to the base you use for UAT with real customers.
And it is not 'NEXT_PUBLIC_ is fine, it is public anyway'. What is public in the bundle is public on preview and on prod. A publishable Stripe key is designed for that. A service role, an internal API token, a database connection in a client component is not. Preview makes the leak easier to find, because the URL lives in a PR you share. Build the habit that what lands in the client may stand in a screenshot. The rest lives only on the server, and only in the environment you meant.
What typically goes wrong when someone says env is set
The first failure is copying production 'so preview looks like it'. Checkout works. Webhooks work. Cron works. You have proved the branch can do what production can — with production's keys. That is not a review. It is an extra production window. The direction is the opposite: preview should look like enough that you can see the UI and the paths you can drive with test keys. What you cannot drive without live, you turn off or point at a sandbox.
The second failure is letting preview call real side effects. A 'send receipt' button that hits Resend/Postmark with the prod domain. A 'run migration' in a preview build. A sync to the ERP. A Slack incoming you use for alarms. Every side effect preview can fire is a side effect you have given to whoever has the URL. Staging may have a controlled sync. Preview should, as a default, have none.
The third failure is believing that 'Protected' on Vercel or environment reviewers on GitHub is the split. That is a gate in front of who can read or deploy. It is good. It does not replace different values. A protected shelf with prod keys that you also put on Preview so 'the designer can test payment' is still a leak. The gate was for main. You opened it for a branch because the demo had to work.
The fourth failure is secrets in the repo, in preview logs, or in a client bundle you called server. A `.env` you committed 'only for preview'. A debug page that prints `process.env`. A server action you sent to the client. Preview logs are more often open to more eyes than prod logs. Write the habit: keys live in the host's shelf, not in git; logs must not echo them; what is `NEXT_PUBLIC_` is a screenshot. The security post is headers on www. This is refusing to let the PR URL be the one that leaks the rest.
Own preview keys vs. the same .env everywhere
The honest split
Preview opens test doors. Production opens the real ones. You can point at what preview cannot do.
- Test keys, sandbox, a base you may break — or the integration dark on preview
- No cron, no live webhook, no mail to real customers from a PR URL
- Shelves in Vercel/Cloudflare/GitHub with different values, not only different labels
- Is boring to maintain, and that is the whole point — a branch cannot charge a card
The copied leak
Three URLs, one .env, and a PR that can do what main can.
- Stripe live, the real database, the shop webhook — 'so the designer can click all the way'
- Cron and migrations run on preview because you reused the same job definition
- Protected environments, but the same value behind the gate — the gate was theatre
- Is a hope you call environment. The person you sent the preview link already has the keys in practice
# Preview / pull request — doors you may break
STRIPE_SECRET_KEY=sk_test_preview_only
DATABASE_URL=postgres://preview-copy.invalid/app
WEBHOOK_SECRET=preview-dummy
CRON_ENABLED=false
# Production — doors you owe yourself to keep closed
STRIPE_SECRET_KEY=sk_live_only_on_production_host
DATABASE_URL=postgres://production.invalid/app
WEBHOOK_SECRET=production-only
CRON_ENABLED=trueHow to decide whether preview may have a key at all — and which
Without this order you get another shelf you call a split.
- 01
List the side effects preview must not be able to do
Charge a card. Write the real customer base. Mail real addresses. Call the ERP. Empty a bucket. If you cannot write that list, you do not have a secret problem. You have a 'someone said it had to work' problem. Everything on the list is either off on preview, or pointed at a sandbox you own.
- 02
Give preview its own set — or give it nothing
A test key beats a live key. A copy database beats the prod URL. A dummy webhook beats the shop secret. If you have no sandbox, leave the integration dark on preview and show an honest 'not available here' state. That is review. A live call 'just this once' is how the habit starts.
- 03
Put the values in the shelf the host actually splits
Vercel: Preview ≠ Production. Cloudflare: preview alias ≠ production. GitHub Environments: review gates on production, other values on preview. One place you can point at. A .env in the repo, 'only for preview', is a secret in git. A value you put in both shelves 'to move on' is not a split.
- 04
Stop cron, migrations and webhooks on the PR URL
Jobs that run because you deployed should ask which environment they are in — and refuse to run when the answer is preview. Vendor webhooks should point at production, or at a staging you own, not at `*.vercel.app` from a branch. Cutover and relaunch are other posts. This is refusing to let a PR be an endpoint Stripe knows.
- 05
Give the leak an interval, not only a go-live
When you add an integration, you decide the preview value the same day. When you rotate a key, you do not rotate it by copying prod back onto preview. When a designer needs to 'see checkout', they get test mode or a recording — not sk_live on a URL in Slack. The CI post is the pipeline. This is the habit in the shelf.
“You have split the environments when preview cannot reach what production owes you to keep secret.”
Checkout, webhooks, cron and the shared demo — same word, four jobs
Checkout is the job that most often gets the live key 'so the client can click'. It is the job that most often charges twice. Preview gets test mode, or it gets a screen that says payment does not run here. A designer who needs to see the receipt page gets a fixture or a staging you own — not a PR with sk_live. Stripe, Quickpay, Reepay: the sandbox exists because this job is real. Use it.
Webhooks are the job you forget, because the endpoint 'just has to point somewhere'. The vendor calls the URL you set in the dashboard. If you set a preview URL, the endpoint dies with the branch — or you leave an old preview standing, and it gets events production also gets. Point production at production. Point sandbox at preview or at a staging you meant. Two endpoints. Two secrets. One dashboard field you do not share.
Cron and background jobs are the job that most often runs because you reused the same definition. A preview deploy that 'just starts the app' also starts the sync you only wanted at night on prod. Set a flag. Read the environment. Refuse to run. Staging may have jobs you chose. Preview should, as a default, sleep. A branch that sends a pile of mail because you tested 'reminders' is not an environment. It is a breach.
The shared demo is the job you call preview when you mean staging. A URL you send to the board for three weeks, with real (or real-looking) data, is not a PR. The staging post is that copy. Preview dies with the branch and must tolerate being deleted. If the demo must live, give it its own environment and its own keys — not prod, and not 'this PR we keep open'. An eternal preview with prod secrets is production without the gate you put on main.
What you should typically do in the situation you are in
The left is what you typically say. The right is the next step we typically recommend.
Situation
Preview failed on a missing env, so you copied the prod keys
Next step
Delete them from preview. Set test keys or turn the integration off. The green build was the wrong goal
Situation
The designer needs to see checkout, and test mode 'looks wrong'
Next step
Fixture, screenshot, or staging you own. Not sk_live on a URL in Slack
Situation
The Stripe dashboard points the webhook at a *.vercel.app from last sprint
Next step
Point production at production. Preview gets sandbox or nothing. A dead alias is a leak you forgot
Situation
Cron runs on every preview because the job definition is the same
Next step
Read the environment. Refuse to run. Preview is a review, not a night shift
Situation
You have three shelves in Vercel and the same value in all three
Next step
You have one shelf. Split the values, or delete the shelves you do not mean
Checklist before you call them split environments
If you are missing several of these, the sentence is env is set — the setup is a .env you copied.
- You can point at a preview value that does not open the door the prod value opens — or at an integration that is dark on preview
- No cron, migration or live webhook runs because you deployed a branch
- NEXT_PUBLIC_ and the client bundle may stand in a screenshot. The rest lives only on the server in the environment you meant
- The host's shelves have different values, not only different labels
- An eternal demo is staging with its own keys — not a PR you keep open with prod secrets
- The CI post is the pipeline. The staging post is the copy. This is refusing to share the keys
Questions we hear again and again
May preview use the same environment variables as production?
No — not the values that open real doors. The names may be the same. The values must not. A preview URL is shared in a PR and in chat. If it can charge a card, read customers or call the shop webhook, you have given the link access to production. Set test keys, a copy you may break, or turn the integration off. 'So it works' is how the leak becomes the habit.
Is this the same as having a staging environment?
No. The staging post is a long-lived copy that should look like production in shape — migrations, jobs, often anonymised data. Preview is short-lived and dies with the branch. Both should have their own keys. They are not the same job. Keeping a PR open for three weeks with prod secrets because 'that is our staging' is production without a gate. Give the demo an environment. Give the branch a preview.
How do we split secrets in Vercel or Cloudflare?
With the shelves the host already has: Preview and Production, or the matching alias. Put different values in them. A Protected gate on production is good — it does not replace preview having test keys. GitHub Environments can review-gate production deploys. The gate is who may. The split is what sits behind it. The same value behind two labels is one shelf.
What do we do when sandbox does not look enough like live for a review?
Then that path's review is not a preview job. Use a fixture, a recording, or a staging you own with test data. Do not copy sk_live onto the PR URL 'just today'. You will copy it again tomorrow. An honest 'payment does not run on preview' screen beats a live call you cannot explain in the audit log. The client can see the UI. They do not need to be able to charge a card from a branch.
Are NEXT_PUBLIC_ variables a secret problem?
They are a public problem. What you prefix for the client lands in the bundle on preview and on prod. A publishable key is designed for that. A service role, an internal token, a connection string is not — and preview makes them easier to find, because the URL is shared. The security post is headers on www. This is the habit: what is public may stand in a screenshot. The rest never comes along to the client.
If preview can do what production can, and you call it convenience
Let us separate test keys from what you owe yourself to keep secret.
A split in the shelf beats a .env you copied so the build went green.
