PandaStack

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:

ScopeProduction deployPR-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 in pg_dump.
  • Masked in the API. GET .../env returns •••••••• 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 0600 file 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.

On this page