How to manage environment variables and secrets
Secret management gets treated as a security topic, which is why most teams do it badly. The failures I actually see are operational: the staging Stripe key ends up in production because someone copied the whole env block across, a preview deploy for a fork of your repo gets handed your live database URL, or a secret rotation misses one of four places it was written down and pages someone at midnight.
None of those are cryptography problems. They're problems of where values live and who can see them. This is how to set that up so the wrong value physically can't reach the wrong environment.
Start by separating plain config from secrets
There are two kinds of environment variable and treating them the same is the root of most of the mess. NODE_ENV, LOG_LEVEL, and NEXT_PUBLIC_API_URL are configuration — you'd happily paste them into a ticket. DATABASE_URL, STRIPE_SECRET_KEY, and your JWT signing key are credentials: once revealed, they stay revealed until rotated.
The distinction should be structural, not a naming convention. On PandaStack, a plain var lives in the app record as JSON; a secret lives in a separate encrypted store, and membership in that store is what makes it a secret — there's no is_secret boolean that can drift out of sync with reality. The practical upshot is that a database backup of the app table is provably ciphertext-only for anything sensitive.
# Plain config — readable back in full
curl -X PUT https://api.pandastack.ai/v1/apps/$APP_ID/env/LOG_LEVEL \
-H "Authorization: Bearer $PANDASTACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"value":"info","secret":false,"scope":"all"}'
# Secret — encrypted at rest, masked on read, never echoed back
curl -X PUT https://api.pandastack.ai/v1/apps/$APP_ID/env/STRIPE_SECRET_KEY \
-H "Authorization: Bearer $PANDASTACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"value":"sk_live_...","secret":true,"scope":"production"}'Reading them back gives you the shape without the payload, which is what you want a teammate — or a support engineer looking at your app — to be able to see:
{
"env": [
{ "key": "LOG_LEVEL", "value": "info", "secret": false, "scope": "all" },
{ "key": "STRIPE_SECRET_KEY", "secret": true, "scope": "production",
"updated_at": "2026-08-19T11:04:00Z", "created_by": "ajay@example.com" }
]
}Scope is the setting that actually prevents incidents
Encryption protects you from a stolen backup. Scoping protects you from yourself, which is the far more likely event. Every variable carries a scope — all, production, or preview — and the deploy only resolves the ones that apply.
- all — the boring default. Same value everywhere: log level, feature flag, region name.
- production — the live keys. A preview deploy never reads these, so a PR from a fork cannot exfiltrate your Stripe key by adding a console.log.
- preview — test-mode keys and throwaway credentials, used only by pull-request environments.
import { Client } from "@pandastack/sdk";
const client = new Client({ apiKey: process.env.PANDASTACK_API_KEY });
// Live key: production only
await client.apps.setEnv(appId, "STRIPE_SECRET_KEY", liveKey,
{ secret: true, scope: "production" });
// Test key: previews only. Same variable name, different value,
// and the two can never be resolved by the same deploy.
await client.apps.setEnv(appId, "STRIPE_SECRET_KEY", testKey,
{ secret: true, scope: "preview" });Preview environments need their own layer
Per-app preview scope covers an app you already created. It doesn't cover the case that generates the most pull-request environments: a repo where every PR spins up a brand-new throwaway app that didn't exist when you configured anything.
For that, config attaches to the repo rather than the app, and every preview born from that repo inherits it. Set the test-mode keys once and every future PR environment gets them:
curl -X PUT https://api.pandastack.ai/v1/repos/$REPO_ID/preview-env/DATABASE_URL \
-H "Authorization: Bearer $PANDASTACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"value":"postgres://...","secret":true}'This store is preview-only by construction rather than by a filter someone can forget: a production deploy never queries the table at all. A throwaway credential added for one PR is structurally incapable of reaching production, which is a much stronger property than a well-intentioned if statement.
Getting off .env without a copy-paste marathon
Nobody migrates thirty variables through a web form, which is exactly why teams keep the .env file around 'just for now'. Import the file in one call, then delete it:
curl -X POST https://api.pandastack.ai/v1/apps/$APP_ID/env/import \
-H "Authorization: Bearer $PANDASTACK_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --rawfile dotenv .env.production \
'{dotenv: $dotenv, secret: true, scope: "production"}')"
# -> {"imported": 31, "total": 31}import pathlib, pandastack
client = pandastack.Client(api_key="pds_...")
client.apps.import_env(
app_id,
dotenv=pathlib.Path(".env.production").read_text(),
secret=True,
scope="production",
)Then make the file impossible to re-add. A gitignore entry is the minimum; a pre-commit hook that greps staged diffs for key-shaped strings is what actually catches it, because the leak is almost never the .env file itself — it's a config sample, a test fixture, or a debug script someone committed at 6pm.
# .git/hooks/pre-commit
if git diff --cached -U0 | grep -nE '(sk_live_|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY)'; then
echo "refusing: credential-shaped string in staged diff" >&2
exit 1
fiThe leak nobody plans for: build logs
Env vars are injected at build time as well as at runtime, because half of what you build needs them — a Next.js build inlines NEXT_PUBLIC_ values, a Django collectstatic wants settings, a Vite build reads its own prefix. That means your secrets are present in the build environment, and build tooling is enthusiastic about printing things.
A build script that runs with set -x, an npm postinstall that dumps env for debugging, a stack trace with the connection string in it — all of these end up in a build log that is more widely readable than the secret store. PandaStack redacts known secret values from build logs and from the comment it posts on your pull request, which handles the common case. It cannot redact a value that got base64'd or split across lines first, so the discipline still matters:
- Never echo env in a build step, even temporarily. 'Temporarily' means it's in the log forever.
- Keep secrets out of NEXT_PUBLIC_, VITE_, or any client-visible prefix — those are compiled into the JavaScript bundle you ship to browsers, which no server-side redaction can help with.
- If a build genuinely needs a credential, prefer a short-lived token over a long-lived one, so the blast radius of a log leak has an expiry.
Rotating without a maintenance window
The reason rotation gets deferred is that people picture it as: update the value, redeploy, hope. The safe version is boring and takes ten minutes, and it works because most providers let two credentials be valid at once.
- Issue a new credential at the provider. Both old and new are now valid.
- Update the variable on the app. Changing env invalidates the baked deploy artifact, so the next deploy or the next wake picks it up — there is no stale-cache window to reason about.
- Deploy, and confirm the app is healthy on the new value.
- Revoke the old credential at the provider.
- Grep your other systems for it — CI secrets, a teammate's laptop, the cron job nobody owns. Step 5 is the one that gets skipped and the one that causes the 3am page.
If a credential has actually leaked, invert the order: revoke first and accept the downtime. A leaked key with a five-minute overlap window is a leaked key.
What 'encrypted at rest' should mean
Every platform claims it, and the phrase covers a wide range of actual guarantees. Three questions separate the meaningful implementations from the marketing:
- Is the value encrypted, or is the disk encrypted? Full-disk encryption protects against someone stealing the drive. It does nothing about a leaked database dump, which is the realistic threat.
- Is the ciphertext bound to its owner? PandaStack seals each value with AES-256-GCM using the app or repo identity as additional authenticated data, so a ciphertext row copied from one app to another fails to decrypt rather than silently working. Without that binding, a database-level write is a privilege escalation.
- Can the value be read back through the API? If yes, the encryption is protecting the storage layer and nothing else. Read paths should return masked values, and the only component that decrypts should be the deploy itself.
The setup, condensed
- Mark credentials as secrets; leave plain config plain. Don't encrypt LOG_LEVEL and feel safe.
- Scope production keys to production and test keys to preview. Same variable name, different values, no branching in your code.
- Put repo-level preview config on the repo, so PR environments inherit it without anyone configuring anything.
- Import .env once, then delete it and add a pre-commit guard.
- Never echo env in a build step; never put a secret behind a client-visible prefix.
- Rotate by adding-then-revoking, and finish by grepping every other system for the old value.
None of this is exotic. It's mostly about making the unsafe thing structurally impossible rather than merely discouraged — which is why scoping earns its keep long before encryption does.
Frequently asked questions
What's the difference between an environment variable and a secret?
Functionally nothing — both arrive in your process as process.env entries. The difference is how the platform stores and exposes them. A plain variable can be read back in full through the API and the dashboard, appears in listings, and is fine in a config export. A secret is encrypted at rest, masked on every read path, redacted from build logs, and decrypted only by the deploy that needs it. The useful rule: if you'd be comfortable pasting the value into a public issue, it's config; if revealing it would require you to rotate something, it's a secret.
How do I stop preview deployments from using production credentials?
Scope the variables. Set the live keys with scope production and the test keys with scope preview, under the same variable name, so a preview deploy resolves only the test value and never sees the live one. This matters most for pull requests from forks, where the code being built is not code you reviewed — with scoping, a contributor adding a line that prints every environment variable gets test-mode keys and nothing else. For repos where each PR creates a fresh throwaway app, attach the preview config to the repo rather than to any one app, so every future preview inherits it automatically.
Are environment variables available during the build, or only at runtime?
Both, on most platforms including PandaStack — and that's necessary, because frameworks inline configuration at build time. Next.js bakes NEXT_PUBLIC_ values into the bundle, Vite does the same for its prefix, and plenty of Python builds read settings during collectstatic or migration steps. The consequence is that your secrets exist in the build environment, so build logs are a real leak surface. Treat any command in a build step that might print its environment as a credential disclosure, and keep genuinely sensitive values out of client-visible prefixes entirely.
Do I need a dedicated secrets manager like Vault or AWS Secrets Manager?
For most application teams, no. A platform-native encrypted store with scoping and audit metadata covers the actual failure modes — wrong value in wrong environment, credential in a log, nobody knows who changed what. Dedicated secret managers earn their complexity when you need dynamic short-lived credentials issued per request, cross-cloud distribution, or a compliance regime that requires a specific key-management story with an HSM behind it. Adopting one before you need it usually means you now have secrets in two places, which is worse than having them in one.
What happens to a running app when I change an environment variable?
It doesn't change under the running process — a Unix process's environment is fixed at exec time, so a live app keeps the values it started with until it restarts. On PandaStack, updating env invalidates the baked deploy artifact, so the next deploy or the next wake from idle picks up the new values, and there's no stale-cache window to reason about. If you need the change live immediately, trigger a deploy explicitly rather than waiting for the next one. This is also why rotation should add the new credential before switching the variable: for a short period, both old and new must work.
Keep reading
- App hosting on PandaStack — Git-driven deploys with scoped env and encrypted secrets
- Preview environments for every pull request
- How to debug a failed deployment
- How to roll back a bad deployment
- How to add a custom domain to your app
49ms p50 cold start. Fork, snapshot, and scale to zero.