Environments and Secrets
Per-environment values belong in app.config.ts; EAS holds build-time secrets, and anything in the bundle is public.
export default ({ config }: ConfigContext): ExpoConfig => ({ ...config })A staging build and a production build are the same code pointing at different servers. The values that differ belong in configuration, not in an if somewhere in the app.
Dynamic config
Rename app.json to app.config.ts and export a function. It receives the static config and returns the final one, so it can read process.env while the build is being prepared. Values you put under extra are readable at runtime through expo-constants.
Where values come from
Locally, from .env and .env.local files; commit the first, never the second. On EAS, from environment variables stored per environment: development, preview and production. A build profile picks one with its environment field, and eas env:pull copies them down for local work. Each variable has a visibility: plain, sensitive, or secret, which EAS never shows again after you set it.
The part people get wrong
Since SDK 49, any variable named EXPO_PUBLIC_* is inlined into the JavaScript bundle at build time. That is convenient and it is also publication. An EAS secret is hidden from your repository and your logs, not from the shipped app: once a value reaches JavaScript, anyone can unzip the binary and read it. Keys that must stay private stay on your server.
Example
// app.config.ts - per-environment values, with nothing secret in the bundle
import type { ConfigContext, ExpoConfig } from "expo/config";
// Set by the EAS environment the build profile selects, or by .env.local.
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
// A Sentry DSN is designed to be public; an API key is not. Nothing private
// belongs below this line.
const sentryDsn = process.env.SENTRY_DSN ?? null;
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: "Trailhead",
slug: "trailhead",
version: "1.2.0",
// extra ships inside the binary and is readable with expo-constants.
extra: {
...config.extra,
apiUrl,
sentryDsn,
},
});When to use it
- Point a preview build at staging and a production build at the live API without changing code.
- Keep a third-party upload token out of the repository but available during the build.
- Show a staging banner only in non-production builds.
- Let a new teammate run the app with the same values as everyone else using eas env:pull.
More examples
Reading the config once, typed
The rest of the app imports appConfig and never touches Constants again. The throw is deliberate: a missing value should break the build you are testing, not quietly produce broken requests weeks later. Your API layer imports appConfig.apiUrl as its base URL.
// lib/appConfig.ts - one typed entry point for everything in extra
import Constants from "expo-constants";
type AppExtra = {
apiUrl: string;
sentryDsn: string | null;
};
// expoConfig is null in rare cases (a bare build without the config embedded),
// and extra is untyped, so assert once here rather than at every call site.
const extra = Constants.expoConfig?.extra as AppExtra | undefined;
if (!extra?.apiUrl) {
// Failing at startup beats a fetch to "undefined/trails" in production.
throw new Error("apiUrl is missing from app config extra");
}
export const appConfig: AppExtra = extra;
// Output: appConfig.apiUrl === "https://api.example.com" in a production buildEnv files and EAS variables
Use this split when a value is needed to build (an upload token for a source-map service) versus needed to run (a public API base URL). Secret visibility means EAS will not print it back to you or to build logs, so store it in a password manager as well.
# .env - committed, safe defaults for local work
EXPO_PUBLIC_API_URL=http://localhost:3000
# .env.local - gitignored, this machine only
API_URL=http://192.168.1.20:3000
# A value the build needs but the app must never contain
eas env:set --name SENTRY_AUTH_TOKEN --value "sntrys_..." \
--environment production --visibility secret
# What exists, and a local copy of the development set
eas env:list --environment production
eas env:pull --environment developmentPointing a profile at an environment
With environment set, you stop listing values in eas.json at all: the build pulls them from the EAS environment of that name. This is what makes a staging build reproducible by a teammate who has never seen your .env.local.
{
"build": {
"preview": {
"distribution": "internal",
"channel": "preview",
"environment": "preview"
},
"production": {
"channel": "production",
"environment": "production",
"autoIncrement": true
}
}
}
Discussion