Skip to main content

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.