Documentation

$env

Access environment variables with type safety.

Only PUBLIC_* variables are compiled into your app. Referencing any other variable from .wh code is a build error. Whitehall has no private variables — an APK cannot hold a secret.

Basic Usage

val apiUrl = $env.PUBLIC_API_URL
val debug = $env.PUBLIC_DEBUG

<Text>{$env.PUBLIC_APP_NAME}</Text>

Environment Files

Define environment variables in .env files:

.env

PUBLIC_API_URL=https://api.example.com
PUBLIC_APP_NAME="My App"
PUBLIC_DEBUG=false
PUBLIC_MAX_RETRIES=3
PUBLIC_TIMEOUT=1.5

.env.local

For per-machine overrides (git-ignored by default):

PUBLIC_API_URL=http://10.0.2.2:8080

Git-ignoring keeps a value out of your repository. It does nothing to keep it out of your app.local files are compiled in exactly like committed ones.

.env.debug

Debug-specific overrides:

PUBLIC_API_URL=https://staging.example.com
PUBLIC_DEBUG=true

.env.release

Production-specific values:

PUBLIC_API_URL=https://production.example.com
PUBLIC_DEBUG=false

Load Order

Environment files are loaded in this order (later files override earlier ones):

  1. .env - Base configuration
  2. .env.local - Local overrides
  3. .env.debug - Debug build overrides
  4. .env.debug.local - Local debug overrides

For release builds, .env.release and .env.release.local are used instead of debug files.

Why there are no private variables

Whitehall borrows SvelteKit's PUBLIC_ prefix but deliberately stops there. There is no $env/static/private equivalent, and that is a design decision rather than a missing feature.

SvelteKit's private variables have somewhere to live: a server process the user never touches. An Android app has no such place. Everything shipped in the APK is on the user's device, and Kotlin const val strings are inlined into classes.dex verbatim — unzip app.apk && strings classes.dex | grep finds them in seconds. R8/ProGuard renames symbols; it does not encrypt constants.

Every value SvelteKit keeps private resolves, on mobile, to “this belongs on a server the app calls”:

SvelteKit private varOn Android
DATABASE_URLThe app should never reach your database — it calls an API
STRIPE_SECRET_KEYThe app uses the publishable key; the secret key charges cards server-side
OAUTH_CLIENT_SECRETAndroid OAuth clients aren't issued one
SUPABASE_SERVICE_ROLE_KEYBypasses Row Level Security — server only
OPENAI_API_KEYShips = anyone spends your credits

So private here means refuse and explain, never hide somewhere safer. No configuration makes a secret safe to bundle, because none is possible.

What is safe to ship are values designed to be public — secured by server-side restrictions rather than secrecy:

PUBLIC_GOOGLE_WEB_CLIENT_ID=123-abc.apps.googleusercontent.com
PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
PUBLIC_SUPABASE_ANON_KEY=...        # guarded by Row Level Security
PUBLIC_SENTRY_DSN=https://...

Build-machine secrets don't go in .env either. Keystore passwords and publishing tokens stay shell environment variables — exactly as SvelteKit leaves NPM_TOKEN to CI rather than modelling it in $env.

Worked example: Google OAuth

On the web you'd have both halves:

PUBLIC_GOOGLE_CLIENT_ID=...      # public
GOOGLE_CLIENT_SECRET=GOCSPX-...  # private, used server-side

On Android the private half does not exist. Creating an Android OAuth client in Google Cloud Console gives you a client ID and no secret — Google doesn't issue one. Two mechanisms replace it: the signing certificate fingerprint (you register package name + SHA-1, so forging requires your keystore) and PKCE, which binds the authorization code to the app instance that started the flow.

So a complete OAuth config is one line:

PUBLIC_GOOGLE_WEB_CLIENT_ID=123-abc.apps.googleusercontent.com

Credential Manager wants the Web client ID as serverClientId, not the Android one. It's still public; its paired secret stays on your backend.

Type Inference

Values are automatically typed based on their format:

FormatTypeExample
Quoted stringStringPUBLIC_NAME="Alice"
Unquoted textStringPUBLIC_URL=https://example.com
true or falseBooleanPUBLIC_DEBUG=true
IntegerIntPUBLIC_MAX_RETRIES=3
DecimalDoublePUBLIC_TIMEOUT=1.5

Common Patterns

API Configuration

// .env
PUBLIC_API_URL=https://api.example.com
PUBLIC_API_TIMEOUT=30

// In code
val apiUrl = $env.PUBLIC_API_URL
val timeout = $env.PUBLIC_API_TIMEOUT

val response = $fetch.get(
  url = "$apiUrl/users",
)

Feature Flags

// .env
PUBLIC_FEATURE_DARK_MODE=true
PUBLIC_FEATURE_ANALYTICS=false

// In code
@if ($env.PUBLIC_FEATURE_DARK_MODE) {
  <Switch bind:checked={darkMode} label="Dark Mode" />
}

Build Configuration

// .env.debug
PUBLIC_LOG_LEVEL=verbose
PUBLIC_MOCK_API=true

// .env.release
PUBLIC_LOG_LEVEL=error
PUBLIC_MOCK_API=false

// In code
@if ($env.PUBLIC_MOCK_API) {
  // Use mock data
} else {
  val data = $fetch($env.PUBLIC_API_URL)
}

Errors and warnings

Referencing a non-public variable fails the build:

error: 1 environment variable(s) are not marked public

  OPENAI_API_KEY is not public
    used at src/routes/+screen.wh:19
    This looks like a server-side credential. Anything compiled into an
    APK is extractable — `strings classes.dex` will find it. Move it
    behind an API you control.

Merely declaring one is only a warning, since a monorepo may share a single .env between a backend and the mobile app. Reading it is the error.

Configuration in whitehall.toml

Customize which env files are loaded:

[env]
debug = ".env.debug"
release = ".env.release"

The prefix rule can be disabled for projects that predate it:

[env]
public_prefix = ""     # every variable is treated as public

Setting public_prefix = "" removes the only guard against compiling a secret into your APK. Migrating your keys to PUBLIC_* is strongly preferred.

See Also