Environment Variables
Every variable, which service reads it, and its default. Sourced from each service's config module and .env.example, cross-checked against infra/k8s/*-deployment.yaml.
Core
| Variable | Services | Default | Notes |
|---|---|---|---|
NODE_ENV | all | development | production in the cluster |
ENV | api | development | Separate from NODE_ENV; only the API reads it |
PORT | api, esu, anansi, igdb-heartbeat, web | varies | api 3000→set to 4000, esu 4010, anansi 4005, igdb 4006, web 3000 |
DATABASE_URL | api, esu, ogun, anansi, igdb-heartbeat | — | Required. postgresql://user:pass@host:5432/db |
Redis
| Variable | Services | Default | Notes |
|---|---|---|---|
REDIS_URL | api, esu, ogun | — | Takes precedence over host/port where both are read |
REDIS_HOST | api, esu, ogun, anansi, queue | localhost | |
REDIS_PORT | api, esu, ogun, anansi, queue | 6379 | |
REDIS_PASSWORD | api, esu, ogun, anansi, queue | — | playpalz locally — the Compose Redis requires it |
Authentication
| Variable | Services | Default | Notes |
|---|---|---|---|
JWT_SECRET | api, esu | "supersecretkey" (dev/test only) / "" | Must be identical. Outside dev/test the API won't start unless it's ≥ 32 characters |
RESET_SECRET | api | "supersecretkey" (dev/test only) | HMAC key for stored password-reset codes. Same ≥ 32-character rule |
JWT_EXPIRATION_MINUTES | api | 60 | Lifetime of refreshable (mobile) access tokens; legacy tokens stay 7 days |
REFRESH_TOKEN_TTL_DAYS | api | 90 | Sliding lifetime of a refresh session, extended on every refresh |
JWT_ALG | esu | HS256 |
The JWT defaults are publicly known
apps/api/src/configs/index.ts falls back to "supersecretkey" only when ENV is development or test. Anyone who knows that string can mint a valid token for any user, so any other ENV throws at startup when either secret is missing or shorter than 32 characters. Production sets both from the api-secrets sealed secret.
Email
| Variable | Services | Default | Notes |
|---|---|---|---|
RESEND_API_KEY | api | — | Resend API key for transactional email (password reset). Unset → emails are logged in development and dropped with an error log elsewhere |
EMAIL_FROM | api | PlayPalz <no-reply@playpalz.gg> | Sender. Its domain must be verified in Resend |
Interservice
| Variable | Services | Default | Notes |
|---|---|---|---|
REALTIME_SERVICE_URL | api, ogun | — | Means two different things — see below |
REALTIME_ADMIN_TOKEN | api, esu, ogun | "" | Shared secret for /admin |
API_BASE_URL | esu | http://localhost:3000/api/v1 | ⚠️ Set to :4000 locally |
API_SERVICE_TOKEN | esu | "" | Sent as X-Service-Token |
WEB_APP_URL | api | http://localhost:3000 | CORS origin |
ANANSI_URL | api | http://playpalz-anansi:4005 | anansi, for the admin panel's Payouts and Jobs pages. http://localhost:4005 locally |
ANANSI_ADMIN_SECRET | api | — | anansi's ADMIN_SECRET. Unset → the Payouts page's runs and retries answer 503 |
REALTIME_SERVICE_URL is inconsistent
The API's helper appends /admin; ogun's does not:
fetch(`${REALTIME_SERVICE_URL}/admin${path}`) // apps/api/src/lib/interservice.ts
fetch(`${REALTIME_SERVICE_URL}${path}`) // apps/ogun/src/utils/interservice.tsBoth deployments set http://playpalz-esu:4010, which is correct for the API and wrong for ogun. Both .env.example files ship http://localhost:4010/admin, which is correct for ogun and wrong for the API. See Service Topology.
Object storage
The variable names differ between services — an artifact of them being written at different times.
| Variable | Services | Notes |
|---|---|---|
SPACES_REGION | ogun, igdb-heartbeat | e.g. nyc3 |
SPACES_ENDPOINT | api, ogun, igdb-heartbeat | Regional S3 API endpoint, https://nyc3.digitaloceanspaces.com |
SPACES_URL | api, ogun, igdb-heartbeat | Bucket origin, https://<bucket>.nyc3.digitaloceanspaces.com |
SPACES_ACCESS_KEY | ogun, igdb-heartbeat | |
SPACES_SECRET_KEY | ogun, igdb-heartbeat | |
SPACES_BUCKET | api, ogun, igdb-heartbeat | |
PUBLIC_CDN_URL | api, igdb-heartbeat | CDN base for public files. Production maps it from CDN_ENDPOINT, a bare host; igdb-heartbeat adds https:// |
AWS_ACCESS_KEY_ID | api | Alternative naming in .env.example |
AWS_SECRET_ACCESS_KEY | api | |
AWS_REGION | api | |
AWS_S3_BUCKET | api |
The spaces-credentials Kubernetes secret (created by Terraform) supplies AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, S3_ENDPOINT, S3_URL, S3_BUCKET, and CDN_ENDPOINT. Deployments map those into whichever names their service expects. Set both families locally to avoid surprises.
Creator KYC storage
A separate private bucket for government ID images. See Creator Onboarding & KYC. There is no fallback to SPACES_*: ID uploads return 503 unless every variable here is set.
| Variable | Services | Notes |
|---|---|---|
KYC_SPACES_BUCKET | api | playpalz-kyc in production. Never the media bucket |
KYC_SPACES_REGION | api | Falls back to SPACES_REGION |
KYC_SPACES_ENDPOINT | api | Regional endpoint, e.g. https://nyc3.digitaloceanspaces.com |
KYC_SPACES_ACCESS_KEY | api | Bucket-scoped key from Terraform |
KYC_SPACES_SECRET_KEY | api | |
KYC_ENCRYPTION_KEY | api | 32 random bytes, base64 (openssl rand -base64 32). Wraps every per-image key. Losing it makes every stored ID unreadable |
KYC_ENCRYPTION_KEY_VERSION | api | Default 1. Bump on rotation |
KYC_ENCRYPTION_KEY_V<n> | api | Previous keys, kept until no rows use version n |
Mux
| Variable | Services | Notes |
|---|---|---|
MUX_ACCESS_TOKEN | api, ogun | |
MUX_SECRET_KEY | api, ogun | |
MUX_SIGNING_KEY | api | Mux signing key id, for signed playback of VIP videos. Unset → private videos can't play |
MUX_PRIVATE_KEY | api | That signing key's private key (base64), as Mux issues it |
MUX_WEBHOOK_SECRET | api | Webhook signing secret. Unset in production → the webhook answers 503; see Webhooks |
Two secret shapes for Mux
ogun-deployment.yaml still reads a mux-credentials secret with TOKEN_ID / TOKEN_SECRET, while the sealed mux-secrets uses MUX_ACCESS_TOKEN / MUX_SECRET_KEY. Reconcile onto one.
LiveKit
| Variable | Services | Notes |
|---|---|---|
LIVEKIT_HOST | api | HTTP API base |
LIVEKIT_WS_URL | api | Client WebSocket URL |
LIVEKIT_API_KEY | api | devkey locally |
LIVEKIT_API_SECRET | api | secret locally |
Payments
| Variable | Services | Default | Notes |
|---|---|---|---|
REVENUECAT_WEBHOOK_SECRET | api | — | The webhook's Authorization value. Unset in production → the webhook answers 503; elsewhere it accepts unauthenticated calls |
REVENUECAT_V2_SECRET_KEY | api | — | RevenueCat API v2 secret key with customer_information:purchases:read, for /coins/purchases/sync |
REVENUECAT_PROJECT_ID | api | — | RevenueCat project id, for the same |
COIN_PURCHASE_SYNC_ENABLED | api | false in production, true elsewhere | Verify-and-credit on /coins/purchases/sync. Keep off in production until the transaction ids are confirmed to match (see Monetization) |
COIN_ALLOW_SANDBOX_PURCHASES | api | false in production, true elsewhere | Credit sandbox (free) store purchases. Never enable in production |
EARNINGS_HOLD_DAYS | api | 30 | How long a PlayPal's earnings (tips, and USD earnings from passes) are held before they can be paid out |
STORE_COMMISSION_DEFAULT_PCT | api | 30 | Store commission assumed for a pass's earnings until the webhook reports the real one |
CHECKOUT_INTENT_TTL_MINUTES | api | 30 | How long a checkout waits for its store purchase |
PURCHASE_CREDIT_VALID_DAYS | api | 365 | How long an unused purchase credit lasts |
COIN_PAYOUT_CENTS_PER_1000 | api | 450 | What 1,000 cashed-out coins pay, in cents (the platform's take lives here) |
CASHOUT_MIN_COINS / CASHOUT_STEP_COINS | api | 2000 / 100 | Smallest cash-out, and the step requests come in |
STRIPE_TRANSFER_WEBHOOK_SECRET | api | — | Signing secret of the Stripe event destination for transfer events. Unset → /webhooks/stripe-transfers answers 503 |
TIP_MAX_COINS | api | 10000 | Largest single tip |
TIP_CONFIRM_ABOVE | api | 500 | Tips above this need a confirmation step |
TIP_DAILY_CAP / TIP_DAILY_CAP_NEW_ACCOUNT / TIP_DAILY_CAP_MINOR | api | 20000 / 2000 / 1000 | Coins a sender can tip in 24 hours |
TIP_NEW_ACCOUNT_DAYS | api | 7 | How long an account counts as new |
TIP_PER_MINUTE | api | 30 | Tips a sender can send per minute |
TIP_MESSAGE_BLOCKLIST | api | — | Comma-separated terms refused in tip messages (whole words; phrases match anywhere) |
FRAUD_HOLD_EXTENSION_DAYS | api | 30 | Extra hold on earnings from a flagged sender |
FRAUD_NEW_ACCOUNT_COINS / FRAUD_CONCENTRATION_MIN_COINS / FRAUD_CONCENTRATION_PERCENT | api | 1000 / 2000 / 80 | Abuse-signal thresholds |
FRAUD_REFUND_MIN_REFUNDS / FRAUD_REFUND_PERCENT | api | 2 / 30 | Flag a buyer with this many refunds in 90 days covering this share of their purchases |
STRIPE_SECRET_KEY | api, anansi | "" | Platform account. Use a restricted key (rk_…). The api needs Connect accounts and external accounts write. Creator onboarding returns 503 when unset |
STRIPE_PUBLISHABLE_KEY | api | — | |
STRIPE_WEBHOOK_SECRET | api | — | Event destination signing secret. Unset → the Stripe webhook answers 503 (fails closed) |
PLATFORM_FEE_PCT | api, anansi | 20 | Platform's cut of store-purchase proceeds, integer percent. The api applies it when recording earnings |
MIN_PAYOUT_CENTS | anansi | 100 | $1.00 |
PAYOUT_LOCK_TTL_MS | anansi | 3600000 | Redis lock TTL |
ADMIN_SECRET | anansi | "" | Empty default — set it |
IGDB
| Variable | Services | Notes |
|---|---|---|
IGDB_CLIENT_ID | igdb-heartbeat | Twitch application |
IGDB_CLIENT_SECRET | igdb-heartbeat |
esu
| Variable | Default | Notes |
|---|---|---|
CORS_ORIGIN | * | Comma-separated |
SOCKET_EVENT_LOGGING | true | Logs every socket event |
ADMIN_UI_ENABLED | false | Socket.IO Admin UI |
ADMIN_UI_USERNAME | "" | Required when enabled |
ADMIN_UI_PASSWORD_HASH | "" | bcrypt hash; generate with pnpm --filter @playpals/esu admin-ui:creds |
web (build-time)
| Variable | Production | Notes |
|---|---|---|
NEXT_PUBLIC_API_URL | https://api.playpalz.gg/api/v1 | Must include /api/v1 |
NEXT_PUBLIC_API_TIMEOUT | 10000 | ms |
HOSTNAME | 0.0.0.0 | Required in a container |
NEXT_TELEMETRY_DISABLED | 1 |
NEXT_PUBLIC_* values are inlined at build time. Changing one requires a rebuild — setting it in the deployment manifest alone does nothing for client code.
mobile (build-time)
| Variable | Notes |
|---|---|
EXPO_PUBLIC_ENV | development | staging | production — drives app name and scheme |
EXPO_PUBLIC_API_URL | Must include /api/v1 |
EXPO_PUBLIC_REALTIME_URL | esu base URL, no path |
EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY | pk_test_… / pk_live_…. Tokenizes creator PII and bank details on the device. Without it the payout steps are unavailable |
EXPO_PUBLIC_POSTHOG_KEY | PostHog project key (phc_…) for crash/error reporting. Unset → nothing is reported |
EXPO_PUBLIC_POSTHOG_HOST | Default https://us.i.posthog.com (https://eu.i.posthog.com for EU Cloud) |
EXPO_PUBLIC_IOS_REVENUECAT_PUBLIC_KEY | RevenueCat public SDK key for the App Store (appl_…), used on iOS. Unset → purchases are unavailable on iOS |
EXPO_PUBLIC_ANDROID_REVENUECAT_PUBLIC_KEY | RevenueCat public SDK key for Google Play (goog_…), used on Android. Unset → purchases are unavailable on Android |
Inlined into the JS bundle. Builds and over-the-air updates read them from the EAS environment of the same name as the profile (eas env:list --environment production), never from eas.json. Locally, apps/mobile/.env. Both RevenueCat keys ship in every bundle and lib/env.ts picks one by Platform.OS. For simulator testing with RevenueCat's Test Store key (test_…), put it in both. See Builds & Releases.
Minimum local set
To get the stack running with everything else stubbed:
# packages/database/.env
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
# apps/api/.env
PORT=4000
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
REDIS_URL="redis://:playpalz@localhost:6379"
JWT_SECRET="local-dev-secret"
RESET_SECRET="local-dev-reset"
REALTIME_SERVICE_URL="http://localhost:4010"
REALTIME_ADMIN_TOKEN="changeme"
WEB_APP_URL="http://localhost:3000"
# apps/esu/.env
PORT=4010
JWT_SECRET="local-dev-secret" # must match the API
API_BASE_URL="http://localhost:4000/api/v1"
REALTIME_ADMIN_TOKEN="changeme"
REDIS_URL="redis://:playpalz@localhost:6379"
# apps/mobile/.env
EXPO_PUBLIC_ENV=development
EXPO_PUBLIC_API_URL=http://localhost:4000/api/v1
EXPO_PUBLIC_REALTIME_URL=http://localhost:4010Note the two deviations from the shipped .env.example files: REALTIME_SERVICE_URL without /admin, and API_BASE_URL on port 4000.
Adding a variable
Four places, every time:
- The service's config module (
src/configs/index.tsorsrc/config/index.ts) - That service's
.env.example infra/k8s/<service>-deployment.yaml— plus a sealed secret if it is sensitive- This page
