Module 12: Production and Deployment
Environment Management
Securely manage API keys, secrets, and environment-specific configurations
🎯 Learning Objectives
- Understand environment variable handling in Expo
- Securely manage API keys and secrets
- Set up multiple environments (dev, staging, production)
- Use EAS Secrets for sensitive data
- Access environment variables at runtime
- Implement best practices for secret management
Environment Variable Basics
Environment variables allow you to configure your app differently across environments without changing code. This is essential for managing API endpoints, feature flags, and secrets.
🚨 Critical Security Warning
Environment variables in React Native are bundled into your JavaScript code and can be extracted from your app. Never put truly sensitive secrets (like private API keys or database passwords) in environment variables accessible to the client.
What Goes Where
flowchart TD
subgraph Safe["Safe for Client (Env Vars)"]
A[API Base URLs]
B[Public API Keys]
C[Feature Flags]
D[Analytics IDs]
end
subgraph Unsafe["Keep on Server Only"]
E[Private API Keys]
F[Database Credentials]
G[JWT Secrets]
H[Payment Processor Secrets]
end
subgraph BuildOnly["Build-Time Only (EAS Secrets)"]
I[App Store Credentials]
J[Signing Certificates]
K[Service Account Keys]
end
style Safe fill:#e8f5e9,stroke:#4CAF50
style Unsafe fill:#ffebee,stroke:#f44336
style BuildOnly fill:#e3f2fd,stroke:#2196F3
Types of Secrets
| Secret Type | Example | Where to Store |
|---|---|---|
| Public API Key | Google Maps, Firebase | .env file / EAS env |
| API Base URL | https://api.yourapp.com | .env file / EAS env |
| Private API Key | Stripe Secret Key | Backend only! |
| Build Credential | App Store Connect API Key | EAS Secrets |
Expo Environment Variables
Expo SDK 49+ has built-in support for environment variables using the EXPO_PUBLIC_ prefix.
Creating .env Files
# .env (default/development)
EXPO_PUBLIC_API_URL=https://api-dev.yourapp.com
EXPO_PUBLIC_GOOGLE_MAPS_KEY=AIzaSyDev...
EXPO_PUBLIC_ENVIRONMENT=development
EXPO_PUBLIC_ENABLE_ANALYTICS=false
# .env.local (local overrides - git ignored)
EXPO_PUBLIC_API_URL=http://localhost:3000
# .env.production (production values)
EXPO_PUBLIC_API_URL=https://api.yourapp.com
EXPO_PUBLIC_GOOGLE_MAPS_KEY=AIzaSyProd...
EXPO_PUBLIC_ENVIRONMENT=production
EXPO_PUBLIC_ENABLE_ANALYTICS=true
Loading Environment Variables
// Environment variables with EXPO_PUBLIC_ prefix
// are automatically available
// Access in any file
const apiUrl = process.env.EXPO_PUBLIC_API_URL;
const mapsKey = process.env.EXPO_PUBLIC_GOOGLE_MAPS_KEY;
const isProd = process.env.EXPO_PUBLIC_ENVIRONMENT === 'production';
// TypeScript: Create env.d.ts for type safety
// env.d.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
EXPO_PUBLIC_API_URL: string;
EXPO_PUBLIC_GOOGLE_MAPS_KEY: string;
EXPO_PUBLIC_ENVIRONMENT: 'development' | 'staging' | 'production';
EXPO_PUBLIC_ENABLE_ANALYTICS: string;
}
}
}
export {};
Using in app.config.js
// app.config.js
export default {
expo: {
name: process.env.EXPO_PUBLIC_ENVIRONMENT === 'production'
? 'My App'
: `My App (${process.env.EXPO_PUBLIC_ENVIRONMENT})`,
// Non-prefixed env vars work in config only
extra: {
apiUrl: process.env.EXPO_PUBLIC_API_URL,
// You can also use non-EXPO_PUBLIC vars here
// They're available at build time but not runtime
buildNumber: process.env.BUILD_NUMBER,
},
android: {
config: {
googleMaps: {
apiKey: process.env.EXPO_PUBLIC_GOOGLE_MAPS_KEY,
},
},
},
},
};
.gitignore Setup
# .gitignore
# Environment files with secrets
.env.local
.env.*.local
# Keep these in version control (no secrets)
# .env
# .env.development
# .env.production
# Or ignore all and use EAS secrets
# .env*
EAS Secrets
EAS Secrets are encrypted environment variables stored in Expo's cloud. They're injected at build time and never stored in your repository.
Managing EAS Secrets
# Set a secret
eas secret:create --name API_SECRET --value "your-secret-value" --scope project
# Set from file
eas secret:create --name GOOGLE_SERVICES_JSON --value "$(cat google-services.json)" --scope project
# List all secrets
eas secret:list
# Delete a secret
eas secret:delete API_SECRET
# Scopes:
# --scope project : Available to this project only
# --scope account : Available to all projects in your account
Using EAS Secrets in Builds
// eas.json
{
"build": {
"production": {
"env": {
// Reference EAS secrets
"EXPO_PUBLIC_API_URL": "https://api.yourapp.com",
// Or use inline values for non-sensitive config
"EXPO_PUBLIC_ENVIRONMENT": "production"
}
},
"staging": {
"env": {
"EXPO_PUBLIC_API_URL": "https://api-staging.yourapp.com",
"EXPO_PUBLIC_ENVIRONMENT": "staging"
}
}
}
}
// EAS secrets are automatically available as environment variables
// No need to explicitly reference them if the name matches
Secrets for Native Configuration
// For files that need to be present during build
// Like google-services.json or GoogleService-Info.plist
// 1. Store the file content as an EAS secret
eas secret:create --name GOOGLE_SERVICES_JSON --value "$(cat google-services.json)"
// 2. Use a prebuild script to create the file
// eas.json
{
"build": {
"production": {
"env": {
"GOOGLE_SERVICES_JSON": "@GOOGLE_SERVICES_JSON"
},
"prebuildCommand": "node scripts/setup-google-services.js"
}
}
}
// scripts/setup-google-services.js
const fs = require('fs');
const googleServices = process.env.GOOGLE_SERVICES_JSON;
if (googleServices) {
fs.writeFileSync('./google-services.json', googleServices);
console.log('Created google-services.json');
}
Multiple Environments
Most apps need at least three environments: development, staging, and production. Here's how to set them up properly.
Environment Strategy
flowchart LR
subgraph Dev["Development"]
A[Local API]
B[Debug logging]
C[Test credentials]
end
subgraph Staging["Staging/Preview"]
D[Staging API]
E[Real services]
F[Test accounts]
end
subgraph Prod["Production"]
G[Production API]
H[Real users]
I[Analytics enabled]
end
Dev --> Staging --> Prod
style Dev fill:#e3f2fd
style Staging fill:#fff3e0
style Prod fill:#e8f5e9
Complete Environment Setup
// config/env.ts
type Environment = 'development' | 'staging' | 'production';
interface EnvConfig {
apiUrl: string;
wsUrl: string;
enableAnalytics: boolean;
enableCrashReporting: boolean;
logLevel: 'debug' | 'info' | 'warn' | 'error';
stripePublishableKey: string;
}
const ENV = (process.env.EXPO_PUBLIC_ENVIRONMENT || 'development') as Environment;
const configs: Record<Environment, EnvConfig> = {
development: {
apiUrl: 'http://localhost:3000',
wsUrl: 'ws://localhost:3000',
enableAnalytics: false,
enableCrashReporting: false,
logLevel: 'debug',
stripePublishableKey: 'pk_test_...',
},
staging: {
apiUrl: 'https://api-staging.yourapp.com',
wsUrl: 'wss://api-staging.yourapp.com',
enableAnalytics: true,
enableCrashReporting: true,
logLevel: 'info',
stripePublishableKey: 'pk_test_...',
},
production: {
apiUrl: 'https://api.yourapp.com',
wsUrl: 'wss://api.yourapp.com',
enableAnalytics: true,
enableCrashReporting: true,
logLevel: 'error',
stripePublishableKey: 'pk_live_...',
},
};
export const config = configs[ENV];
export const isDev = ENV === 'development';
export const isStaging = ENV === 'staging';
export const isProd = ENV === 'production';
EAS Build Profiles
// eas.json
{
"cli": {
"version": ">= 5.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"channel": "development",
"env": {
"EXPO_PUBLIC_ENVIRONMENT": "development"
}
},
"staging": {
"distribution": "internal",
"channel": "staging",
"env": {
"EXPO_PUBLIC_ENVIRONMENT": "staging"
},
"ios": {
"simulator": false
}
},
"production": {
"channel": "production",
"env": {
"EXPO_PUBLIC_ENVIRONMENT": "production"
},
"ios": {
"autoIncrement": true
},
"android": {
"autoIncrement": true
}
}
},
"submit": {
"production": {
"ios": {
"appleId": "your@email.com",
"ascAppId": "1234567890"
},
"android": {
"serviceAccountKeyPath": "./pc-api-key.json"
}
}
}
}
App Variants
// app.config.ts - Different app per environment
import { ExpoConfig, ConfigContext } from 'expo/config';
type Variant = 'development' | 'staging' | 'production';
const variant = (process.env.EXPO_PUBLIC_ENVIRONMENT || 'development') as Variant;
const variantConfig = {
development: {
name: 'MyApp Dev',
bundleId: 'com.company.myapp.dev',
icon: './assets/icon-dev.png',
},
staging: {
name: 'MyApp Staging',
bundleId: 'com.company.myapp.staging',
icon: './assets/icon-staging.png',
},
production: {
name: 'MyApp',
bundleId: 'com.company.myapp',
icon: './assets/icon.png',
},
};
const currentConfig = variantConfig[variant];
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: currentConfig.name,
slug: 'myapp',
icon: currentConfig.icon,
ios: {
bundleIdentifier: currentConfig.bundleId,
},
android: {
package: currentConfig.bundleId,
},
});
Runtime Access
Access your environment configuration safely throughout your app.
Central Config Service
// services/config.ts
import Constants from 'expo-constants';
class ConfigService {
private static instance: ConfigService;
readonly apiUrl: string;
readonly environment: string;
readonly version: string;
readonly buildNumber: string;
private constructor() {
// From EXPO_PUBLIC_ env vars
this.apiUrl = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:3000';
this.environment = process.env.EXPO_PUBLIC_ENVIRONMENT || 'development';
// From app.config.js extra
this.version = Constants.expoConfig?.version || '1.0.0';
this.buildNumber = Constants.expoConfig?.ios?.buildNumber ||
Constants.expoConfig?.android?.versionCode?.toString() ||
'1';
}
static getInstance(): ConfigService {
if (!ConfigService.instance) {
ConfigService.instance = new ConfigService();
}
return ConfigService.instance;
}
get isDevelopment(): boolean {
return this.environment === 'development';
}
get isProduction(): boolean {
return this.environment === 'production';
}
get fullVersion(): string {
return `${this.version} (${this.buildNumber})`;
}
}
export const Config = ConfigService.getInstance();
// Usage
import { Config } from '@/services/config';
console.log(Config.apiUrl);
console.log(Config.isDevelopment);
console.log(Config.fullVersion);
React Context for Config
// contexts/ConfigContext.tsx
import React, { createContext, useContext } from 'react';
import { Config } from '@/services/config';
interface ConfigContextType {
apiUrl: string;
environment: string;
isDev: boolean;
isProd: boolean;
version: string;
}
const ConfigContext = createContext<ConfigContextType | null>(null);
export function ConfigProvider({ children }: { children: React.ReactNode }) {
const value: ConfigContextType = {
apiUrl: Config.apiUrl,
environment: Config.environment,
isDev: Config.isDevelopment,
isProd: Config.isProduction,
version: Config.fullVersion,
};
return (
<ConfigContext.Provider value={value}>
{children}
</ConfigContext.Provider>
);
}
export function useConfig() {
const context = useContext(ConfigContext);
if (!context) {
throw new Error('useConfig must be used within ConfigProvider');
}
return context;
}
// Usage in components
function SettingsScreen() {
const { version, environment, isDev } = useConfig();
return (
<View>
<Text>Version: {version}</Text>
{isDev && <Text>Environment: {environment}</Text>}
</View>
);
}
Feature Flags
// services/featureFlags.ts
type FeatureFlag =
| 'NEW_CHECKOUT'
| 'DARK_MODE'
| 'BIOMETRIC_LOGIN'
| 'PUSH_NOTIFICATIONS';
const flags: Record<string, Record<FeatureFlag, boolean>> = {
development: {
NEW_CHECKOUT: true,
DARK_MODE: true,
BIOMETRIC_LOGIN: true,
PUSH_NOTIFICATIONS: true,
},
staging: {
NEW_CHECKOUT: true,
DARK_MODE: true,
BIOMETRIC_LOGIN: true,
PUSH_NOTIFICATIONS: true,
},
production: {
NEW_CHECKOUT: false, // Not ready yet
DARK_MODE: true,
BIOMETRIC_LOGIN: true,
PUSH_NOTIFICATIONS: true,
},
};
const environment = process.env.EXPO_PUBLIC_ENVIRONMENT || 'development';
export function isFeatureEnabled(flag: FeatureFlag): boolean {
return flags[environment]?.[flag] ?? false;
}
// Usage
if (isFeatureEnabled('NEW_CHECKOUT')) {
return <NewCheckoutFlow />;
} else {
return <LegacyCheckout />;
}
Security Best Practices
Protecting your secrets and API keys is crucial for app security.
Security Checklist
✅ Do
- Use EAS Secrets for build-time credentials
- Keep truly sensitive operations on your backend
- Use API keys that can be restricted (domain, app ID)
- Rotate compromised keys immediately
- Use different keys per environment
- Audit which keys are in your codebase
❌ Don't
- Commit .env files with real secrets to git
- Put private API keys in client code
- Share production credentials in Slack/email
- Use the same credentials across all environments
- Hardcode secrets directly in source code
Restricting API Keys
// Google Cloud Console / Firebase Console
// Restrict your API keys to:
// 1. Application restrictions
// - iOS: Bundle ID (com.company.app)
// - Android: Package name + SHA-1 fingerprint
// - Web: HTTP referrers (yourapp.com/*)
// 2. API restrictions
// - Only enable APIs you actually use
// - Maps SDK for iOS/Android, not all Google APIs
// 3. Quotas
// - Set daily limits to prevent abuse
// - Monitor usage and set alerts
Backend Proxy Pattern
// Instead of calling third-party APIs directly with secrets,
// proxy through your backend
// ❌ Bad: Secret key in mobile app
const response = await fetch('https://api.stripe.com/v1/charges', {
headers: {
'Authorization': `Bearer ${STRIPE_SECRET_KEY}`, // Exposed!
},
});
// ✅ Good: Call your backend, which has the secret
const response = await fetch(`${API_URL}/payments/charge`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${userToken}`,
},
body: JSON.stringify({ amount, currency }),
});
// Your backend (Node.js example)
app.post('/payments/charge', authenticate, async (req, res) => {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); // Safe!
const charge = await stripe.charges.create({
amount: req.body.amount,
currency: req.body.currency,
customer: req.user.stripeCustomerId,
});
res.json(charge);
});
Audit Your Repository
# Check for accidentally committed secrets
# Install gitleaks: https://github.com/gitleaks/gitleaks
# Scan entire history
gitleaks detect --source . -v
# Scan only staged changes (good for pre-commit hook)
gitleaks protect --staged
# Add to pre-commit hook
# .husky/pre-commit
#!/bin/sh
gitleaks protect --staged --verbose
if [ $? -ne 0 ]; then
echo "Secrets detected in staged files!"
exit 1
fi
Hands-On Exercises
Exercise 1: Multi-Environment Setup
Set up a complete multi-environment configuration with proper typing.
Show Solution
// 1. Create env.d.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
EXPO_PUBLIC_API_URL: string;
EXPO_PUBLIC_ENVIRONMENT: 'development' | 'staging' | 'production';
EXPO_PUBLIC_GOOGLE_MAPS_KEY: string;
EXPO_PUBLIC_SENTRY_DSN: string;
}
}
}
export {};
// 2. Create .env files
// .env.development
EXPO_PUBLIC_API_URL=http://localhost:3000
EXPO_PUBLIC_ENVIRONMENT=development
EXPO_PUBLIC_GOOGLE_MAPS_KEY=AIzaDev...
EXPO_PUBLIC_SENTRY_DSN=
// .env.staging
EXPO_PUBLIC_API_URL=https://api-staging.app.com
EXPO_PUBLIC_ENVIRONMENT=staging
EXPO_PUBLIC_GOOGLE_MAPS_KEY=AIzaStaging...
EXPO_PUBLIC_SENTRY_DSN=https://xxx@sentry.io/staging
// .env.production
EXPO_PUBLIC_API_URL=https://api.app.com
EXPO_PUBLIC_ENVIRONMENT=production
EXPO_PUBLIC_GOOGLE_MAPS_KEY=AIzaProd...
EXPO_PUBLIC_SENTRY_DSN=https://xxx@sentry.io/prod
// 3. Create config service
// config/index.ts
export const Config = {
apiUrl: process.env.EXPO_PUBLIC_API_URL!,
environment: process.env.EXPO_PUBLIC_ENVIRONMENT!,
googleMapsKey: process.env.EXPO_PUBLIC_GOOGLE_MAPS_KEY!,
sentryDsn: process.env.EXPO_PUBLIC_SENTRY_DSN || null,
get isDev() { return this.environment === 'development'; },
get isStaging() { return this.environment === 'staging'; },
get isProd() { return this.environment === 'production'; },
} as const;
// 4. Update eas.json
{
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"env": { "EXPO_PUBLIC_ENVIRONMENT": "development" }
},
"staging": {
"distribution": "internal",
"channel": "staging",
"env": { "EXPO_PUBLIC_ENVIRONMENT": "staging" }
},
"production": {
"channel": "production",
"env": { "EXPO_PUBLIC_ENVIRONMENT": "production" }
}
}
}
Exercise 2: Secure API Key Setup
Set up Google Services files securely using EAS Secrets.
Show Solution
# 1. Store files as EAS secrets
# For Android
eas secret:create --name GOOGLE_SERVICES_JSON \
--value "$(cat google-services.json)" \
--scope project
# For iOS
eas secret:create --name GOOGLE_SERVICE_INFO_PLIST \
--value "$(cat GoogleService-Info.plist)" \
--scope project
# 2. Create prebuild script
// scripts/setup-firebase.js
const fs = require('fs');
// Android
const googleServicesJson = process.env.GOOGLE_SERVICES_JSON;
if (googleServicesJson) {
fs.writeFileSync('./google-services.json', googleServicesJson);
console.log('✓ Created google-services.json');
}
// iOS
const googleServicePlist = process.env.GOOGLE_SERVICE_INFO_PLIST;
if (googleServicePlist) {
fs.writeFileSync('./GoogleService-Info.plist', googleServicePlist);
console.log('✓ Created GoogleService-Info.plist');
}
# 3. Update eas.json
{
"build": {
"production": {
"env": {
"GOOGLE_SERVICES_JSON": "@GOOGLE_SERVICES_JSON",
"GOOGLE_SERVICE_INFO_PLIST": "@GOOGLE_SERVICE_INFO_PLIST"
},
"prebuildCommand": "node scripts/setup-firebase.js"
}
}
}
# 4. Add to .gitignore
google-services.json
GoogleService-Info.plist
Summary
🎯 Key Takeaways
- EXPO_PUBLIC_ prefix — Required for runtime access to env vars
- Client code is not secure — Anyone can extract bundled secrets
- EAS Secrets — Use for build-time credentials and sensitive files
- Multiple environments — Dev, staging, and production with different configs
- Centralize config — Create a config service for consistent access
- Backend for secrets — Proxy sensitive API calls through your server
- Audit regularly — Use tools like gitleaks to prevent leaks
In the next lesson, we'll learn how to build your app for distribution using EAS Build.