Mobile CI/CD · Secrets

    Managing .env files in mobile CI/CD on GitHub

    Nathan Claiborne
    Every mobile project ends up with a .env file full of API keys, base URLs, feature flags, and third-party tokens. Locally it just works. The moment you try to build on GitHub Actions, EAS, Bitrise, or Corenna, you hit the same wall: how do I get this file onto the runner without committing it? This guide walks through the pattern that works for Capacitor, Expo, React Native, Flutter, and native Android/iOS projects.

    Why .env files break CI

    .env is usually gitignored, which is correct. But CI runners start from a clean checkout — if the file isn't in the repo and isn't generated at build time, your bundler reads undefinedfor every variable and your app ships with broken auth, missing analytics, or a pointer to localhost.

    The fix is a two-step pattern: store secrets in GitHub Actions Secrets, then write a .env file at the start of the workflow using those secrets.

    Step 1 — move secrets out of the repo

    In your GitHub repository, go to Settings → Secrets and variables → Actions and add one secret per environment variable. Use the same names as your .env keys so the mapping is obvious:

    API_BASE_URL
    SUPABASE_URL
    SUPABASE_ANON_KEY
    SENTRY_DSN
    POSTHOG_KEY

    For Expo and React Native, prefix client-visible keys with EXPO_PUBLIC_ or whatever your bundler expects (NEXT_PUBLIC_, VITE_, etc.) so they actually reach the bundle.

    Step 2 — write the .env file inside the workflow

    Add a step at the top of your build job that materializes .env from secrets. This runs on every build and never persists outside the ephemeral runner:

    - name: Create .env
      run: |
        cat > .env <<'EOF'
        API_BASE_URL=${{ secrets.API_BASE_URL }}
        SUPABASE_URL=${{ secrets.SUPABASE_URL }}
        SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}
        SENTRY_DSN=${{ secrets.SENTRY_DSN }}
        POSTHOG_KEY=${{ secrets.POSTHOG_KEY }}
        EOF

    Heredoc with single-quoted 'EOF' prevents shell expansion of dollar signs inside the values — important for tokens that contain $. The file is created fresh per run and disappears with the runner.

    Per-environment configs (dev, staging, prod)

    For multiple environments, suffix your secret names (API_BASE_URL_PROD, API_BASE_URL_STAGING) and pick the right one based on the branch or workflow input:

    - name: Create .env
      env:
        ENV_NAME: ${{ github.ref == 'refs/heads/main' && 'PROD' || 'STAGING' }}
      run: |
        cat > .env <<EOF
        API_BASE_URL=${{ secrets[format('API_BASE_URL_{0}', env.ENV_NAME)] }}
        EOF

    GitHub's secrets[...] dynamic lookup is the cleanest way to switch secret sets without duplicating jobs. Keep production secrets in a protected environment with required reviewers.

    Android — wiring .env into Gradle

    Capacitor and React Native CLI projects read .env at JS bundle time, so the workflow step above is enough. For Gradle-only values (signing config, version codes, native API keys baked into BuildConfig), pass them as -P properties instead:

    ./gradlew bundleRelease \
      -PSENTRY_DSN=${{ secrets.SENTRY_DSN }} \
      -PVERSION_CODE=${{ github.run_number }}

    In build.gradle, read them with project.findProperty("SENTRY_DSN"). Avoid echoing the values in any log step.

    iOS — wiring .env into xcodebuild

    For iOS, the same .env file covers the JS layer. For native Info.plist values, use xcodebuild with -xcconfig or environment variables consumed by a build phase script:

    - name: Build IPA
      env:
        SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
      run: |
        xcodebuild -workspace ios/App.xcworkspace \
          -scheme App -configuration Release \
          -archivePath build/App.xcarchive archive

    Things to never do

    • Don't cat .env in a log step. GitHub masks known secrets but won't catch derived values.
    • Don't commit a "template" .env.example that contains real placeholder values for production endpoints — split it into .env.example with dummy values only.
    • Don't store secrets in repository variables (the non-secret kind). They're plaintext in the UI and logs.
    • Don't upload the .env file as a build artifact. Artifacts are downloadable by anyone with repo read access.

    How Corenna handles this for you

    When you build with Corenna, signing materials (keystores, .p12 certs, provisioning profiles) are pushed into your repo's GitHub Actions Secrets using GitHub's sealed-box encryption — they're decrypted only inside ephemeral runners and never logged. For app-level .env values, you add them once in the project's signing screen and Corenna writes them to GitHub Secrets the same way, then injects them at the top of every build. No YAML to maintain, no plaintext anywhere.

    Keep reading