Environment Variables & Secrets
Give your app config and secrets — injected at build and runtime, encrypted at rest, masked in the API, scoped to production or previews, and never leaked to logs or PR comments.
Apps read configuration from environment variables. PandaStack supports two kinds:
- Plain vars — non-sensitive config (
PUBLIC_URL,NODE_ENV). Stored as-is. - Secrets — sensitive values (
DATABASE_URL,API_KEY). Encrypted at rest with AES-256-GCM, returned masked from the API, and redacted from build logs and PR comments.
Both are injected into both the build and the runtime — so a build-time NPM_TOKEN (private registry) and a runtime DATABASE_URL both work.
Secrets require the control plane to have a master key (PANDASTACK_ENV_MASTER_KEY) configured. Without it, plain vars work but secret writes are refused (503) — PandaStack never stores a "secret" as plaintext.
Setting variables
On the app page, the Environment variables section lists your vars (secrets shown as ••••••••). Add a row with a key, value, a secret toggle, and a scope; or click Paste .env to bulk-import.
# Plain var
curl -X PUT https://api.pandastack.ai/v1/apps/$APP/env/PUBLIC_URL \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"value":"https://example.com","scope":"all"}'
# Secret (encrypted at rest, masked on read)
curl -X PUT https://api.pandastack.ai/v1/apps/$APP/env/DATABASE_URL \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"value":"postgres://...","secret":true,"scope":"production"}'
# List (secrets masked)
curl https://api.pandastack.ai/v1/apps/$APP/env -H "Authorization: Bearer $KEY"
# Bulk import a .env
curl -X POST https://api.pandastack.ai/v1/apps/$APP/env/import \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"dotenv":"A=1\nB=2","secret":false,"scope":"all"}'
# Delete
curl -X DELETE https://api.pandastack.ai/v1/apps/$APP/env/DATABASE_URL \
-H "Authorization: Bearer $KEY"from pandastack import Client
client = Client()
client.apps.set_env(app_id, "PUBLIC_URL", "https://example.com")
client.apps.set_env(app_id, "DATABASE_URL", "postgres://...", secret=True, scope="production")
client.apps.list_env(app_id) # secrets masked
client.apps.import_env(app_id, "A=1\nB=2")
client.apps.unset_env(app_id, "DATABASE_URL")import { Client } from "@pandastack/sdk";
const client = new Client();
await client.apps.setEnv(appId, "PUBLIC_URL", "https://example.com");
await client.apps.setEnv(appId, "DATABASE_URL", "postgres://...", { secret: true, scope: "production" });
await client.apps.listEnv(appId); // secrets masked
await client.apps.importEnv(appId, "A=1\nB=2");
await client.apps.unsetEnv(appId, "DATABASE_URL");Scopes — production vs. preview
Every var has a scope that controls which deploys see it:
| Scope | Production deploy | PR-preview deploy |
|---|---|---|
all (default) | ✅ | ✅ |
production | ✅ | ❌ |
preview | ❌ | ✅ |
This lets a production secret (your real DATABASE_URL) stay out of throwaway preview environments, while a preview-only value (a test DB) is applied only to PR previews.
Preview env per repo
For PR previews, you usually want a separate set of values (a sandbox database, test API keys) shared by every preview of a repo — so a PR never touches production data. Set them on the repo:
curl -X PUT https://api.pandastack.ai/v1/repos/$REPO_ID/preview-env/DATABASE_URL \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"value":"postgres://test-db/...","secret":true}'client.apps.set_preview_env(repo_id, "DATABASE_URL", "postgres://test-db/...", secret=True)
client.apps.list_preview_env(repo_id)await client.apps.setPreviewEnv(repoId, "DATABASE_URL", "postgres://test-db/...", { secret: true });
await client.apps.listPreviewEnv(repoId);Every PR preview of that repo inherits these. Resolution precedence (lowest → highest): repo preview env → the app's plain vars → the app's secrets (a secret with the same key wins).
How secrets are protected
- Encrypted at rest. Secret values are sealed with AES-256-GCM under a master key, bound to their
(app, key)row (so a ciphertext can't be moved to another row). A database backup contains only ciphertext — secret plaintext is never inpg_dump. - Masked in the API.
GET .../envreturns••••••••for secrets, never the value. Updating a secret without supplying a value is a no-op (it won't blank an existing secret). - Decrypted once, at deploy time. Secrets are decrypted only in the deploy worker and written to a
0600file inside the sandbox that build/runtime source — values never appear on a command line or in/proc. - Redacted from logs and PR comments. Any secret value that would appear in a build log or a preview PR comment is replaced with
‹redacted›. - Not baked to disk. Secrets are never written into a baked disk image or snapshot as plaintext; the sleep/wake path re-injects them at launch.
Secrets are still visible to your own app's process at runtime (that's the point) and to anyone who can exec into the sandbox. Scope production secrets to production so they never reach a preview a reviewer can open.
PR Previews
Every pull request gets its own ephemeral PandaStack environment — built from the PR's code, served behind a stable URL, and posted as a comment on the PR. Opt-in per repo; auto-torn-down when the PR closes.
Snapshot & Restore
How a sandbox boots in 49 ms — the path from snapshot file on disk to sshd accepting connections.