Mobile Apps

Expo EAS Build Profiles That Keep CI Reproducible

Configure EAS build profiles to ensure identical builds across local machines and CI runners, avoiding environment drift and cache poisoning.

Mohammed Saqib9 min read
A clean and stylish workspace featuring dual monitors, a lamp, and office supplies.
Photo by Lee Campbell on Pexels · Pexels License

You push a commit, CI starts an EAS build, and forty minutes later the binary crashes on launch. You reproduce the same build locally—it works. The difference? Your EAS build profile didn't pin the Node version, so CI ran on 20.x while you developed on 18.17.0. Or your lockfile wasn't frozen, so a transitive dependency resolved differently. Default EAS profiles are optimised for convenience, not determinism, and that gap is where environment drift lives.

Why default EAS build profiles are not reproducible

When you run eas build --profile development without touching the generated eas.json, the profile inherits a set of defaults. Those defaults resolve tool versions dynamically: node defaults to "latest" on the EAS build worker, and yarn follows whatever version ships with the worker image. Your local machine might run Node 18.17.0 via nvm, but the worker picks up 20.11.0 because that's what the image provides this week. Same profile, different runtime.

CI base images compound the problem. EAS build workers are ephemeral VMs that come with a preinstalled set of tools—Xcode, CocoaPods, Android SDK, Gradle wrappers—at specific patch levels. Expo publishes a list of installed software for each worker image, but those versions change over time. If your local system has CocoaPods 1.15.0 and the worker has 1.14.3, pod installation can produce different Podfile.lock files, which means different native code.

The third subtle failure is dependency resolution. Even with a checked-in yarn.lock, a yarn install that isn't run with --frozen-lockfile will silently update the lockfile if any resolution rule changes. This is rare but real when a package publishes a new version that satisfies the same semver range. CI that doesn't freeze the lockfile can install a dependency that was never tested locally.

Anatomy of an EAS build profile: the keys that matter

A profile in eas.json is a JSON object under build.<profileName>. The keys that directly affect reproducibility are:

  • node: controls the Node.js version installed on the worker. Accepts a semver string or "latest".
  • yarn: pins the Yarn version. Requires a specific semver, not "latest".
  • expo: sets the Expo CLI version used during the build.
  • platform: "ios", "android", or "all". Determines which native toolchain runs.
  • cache: an object with key and paths that controls which directories are cached between builds.
  • env: a map of environment variables injected at build time.
  • customBuildCommands: a list of shell commands that replace the default build flow entirely.

The customBuildCommands key is your escape hatch. When you define it, EAS stops using its default build pipeline and runs your commands verbatim on the worker. This gives you full control over every step—dependency installation, prebuild, native compilation, asset bundling—but also means you are responsible for reproducing every step that happens on a developer's machine.

Here is a minimal profile that pins every tool version and freezes the lockfile:

{
  "build": {
    "production": {
      "node": "18.17.0",
      "yarn": "1.22.19",
      "expo": "50.0.0",
      "platform": "all",
      "cache": {
        "key": "expo-build-cache-v1-{{ checksum \"yarn.lock\" }}",
        "paths": [
          "~/.npm",
          "~/.cache/yarn",
          "node_modules"
        ]
      },
      "env": {
        "EXPO_NO_DOTENV": "1"
      },
      "customBuildCommands": [
        "yarn install --frozen-lockfile",
        "npx expo prebuild --platform all --clean",
        "npx expo export --platform all --output-dir dist"
      ]
    }
  }
}

Notice the cache.key uses a Handlebars-style template {{ checksum "yarn.lock" }}. EAS evaluates this at build time and hashes the file to produce a unique cache key. When yarn.lock changes, the key changes, and the cache is invalidated.

Locking tool versions explicitly

The safest approach is to pin node, yarn, and expo to exact semver strings in every profile. Do not rely on "latest" or omit the key. A difference of one minor version in Node can change Intl behaviour, V8 optimisation patterns, or native module compilation flags.

Set the node field to the exact version you use locally, verified with node --version. For Yarn, use the Classic or Berry version you have in your project. The expo field should match the version in your package.json under expo dependency.

As a fallback, keep a .nvmrc file and a packageManager field in package.json. The CI worker won't use them if the profile overrides node, but they document intent for anyone running builds manually.

{
  "build": {
    "staging": {
      "node": "18.17.0",
      "yarn": "1.22.19",
      "expo": "50.0.0"
    }
  }
}

Do not use a range like "^18". A range resolves to the latest matching version on the worker, which changes over time. Use an exact version.

Using custom build commands for deterministic flows

The default EAS build pipeline runs yarn install, then npx expo prebuild, then the platform-specific native build. That sequence is opaque; you cannot inject flags or reorder steps. Custom build commands replace it entirely and let you replicate the exact sequence a developer runs locally.

A typical custom command sequence for a production build:

{
  "customBuildCommands": [
    "yarn install --frozen-lockfile",
    "npx expo prebuild --platform all --clean",
    "npx expo export --platform all --output-dir dist --no-dev --minify"
  ]
}

The --clean flag on prebuild removes and regenerates the ios/ and android/ directories, which avoids stale native files from previous builds. The --no-dev and --minify flags on export strip development-only code and minify the JavaScript bundle, matching what a production binary should contain.

If you need platform-specific steps—CocoaPods for iOS or Gradle tasks for Android—run them inside custom commands. EAS does not automatically install CocoaPods dependencies unless you use the default pipeline. Here is an iOS-only profile that does it explicitly:

{
  "build": {
    "ios-production": {
      "node": "18.17.0",
      "platform": "ios",
      "customBuildCommands": [
        "yarn install --frozen-lockfile",
        "npx expo prebuild --platform ios --clean",
        "cd ios && pod install --repo-update",
        "npx expo export --platform ios --output-dir dist --no-dev --minify"
      ]
    }
  }
}

Note the cd ios && pod install --repo-update. Without that, the iOS build would use an uninstalled Podfile, and the native compilation would fail.

Comparing expo-dev-client vs expo run in CI

When you choose a profile, you also choose whether to build with expo-dev-client (a full native binary that includes Expo modules) or expo run (a managed runtime build). The difference matters for CI because it affects build time, disk usage, and the scope of native module verification.

Aspect expo-dev-client expo run
Native module verification Full: compiles all native code, catches linking errors early Partial: uses Expo's prebuilt runtime, native modules are not compiled
Build time 20–40 minutes (iOS), 10–20 minutes (Android) 5–10 minutes (iOS), 3–8 minutes (Android)
Disk usage on worker High (1–3 GB after build) Low (200–500 MB)
Cache effectiveness Low: native build output is large and changes often High: managed runtime output is small and stable
Use case in CI Pre-merge checks that need native module verification Fast feedback for JavaScript-only changes

If your team regularly adds native modules, or if you have custom native code (TurboModules, Fabric components), use expo-dev-client in CI at least for the merge to main branch. Otherwise, expo run is faster and more cache-friendly. The profile difference is mainly in the customBuildCommandsexpo-dev-client builds run npx expo run:ios or npx expo run:android, while expo run builds run npx expo export and produce a bundle that can be uploaded to a store or a distribution service.

Handling cache and environment variables to avoid drift

Cache is a two-edged sword. It speeds up builds, but a stale cache can reintroduce old dependencies or build artifacts. The key is to tie the cache key to the lockfile so that any dependency change invalidates the cache.

Use cache.key with a checksum of the lockfile:

{
  "cache": {
    "key": "expo-cache-v2-{{ checksum \"yarn.lock\" }}",
    "paths": [
      "~/.npm",
      "~/.cache/yarn",
      "node_modules"
    ]
  }
}

If you use npm, checksum package-lock.json instead. Never use a static key like "cache-v1"—that key never changes, so the cache never invalidates, and you will eventually build against stale node_modules.

Environment variables should be set in the env block of the profile, not injected solely via CI secrets. The reason is reproducibility: if a build depends on CI_SECRET_TOKEN and that variable is only set in the CI provider's UI, a developer who runs eas build --local will get a different build because the variable is missing. Put all variables that affect the build output in the profile's env block, and use CI secrets only for the values (e.g., "MY_API_KEY": "{{ secrets.MY_API_KEY }}"). EAS resolves {{ secrets.* }} references at build time.

Avoid absolute paths in customBuildCommands. The worker's home directory is not guaranteed to be the same across images. Use $HOME or relative paths. For example, write $HOME/.npm instead of /home/user/.npm.

Failure modes: when profiles still diverge

Even with a perfectly pinned profile, builds can diverge. Three common failure modes:

Missing react-native.config.js – Native module linking in React Native 0.73+ uses auto-linking, but some libraries still require manual linking via react-native.config.js. If that file exists locally but is not checked into Git, CI builds will miss the native module. The result is a runtime crash on CI that works locally. The fix: check in the config file, or generate it with npx react-native config inside custom build commands.

Incorrect use of --no-install – The --no-install flag on expo prebuild skips dependency installation entirely. If you pass it without having already installed dependencies, the prebuild step will fail or produce an incomplete output. In custom build commands, always run yarn install --frozen-lockfile before any Expo command that expects dependencies.

Platform-specific dependencies not installed – EAS does not auto-install CocoaPods for iOS or Gradle wrappers for Android unless you use the default pipeline. If your custom build commands skip pod install or gradle wrapper, the native build will use whatever is on the worker, which may be a different version. Always include the platform-specific installation step in custom commands for that platform.

Key takeaways

  • Pin node, yarn, and expo to exact semver strings in every profile. Never rely on "latest".
  • Use customBuildCommands to replicate your local build sequence exactly, including --frozen-lockfile and --no-dev --minify.
  • Tie the cache key to a checksum of your lockfile so dependency changes invalidate the cache.
  • Set all environment variables that affect the build output in the profile's env block, not just in CI secrets.
  • Always install platform-specific dependencies (CocoaPods, Gradle) explicitly in custom commands for the relevant platform.

For more on keeping your development environment consistent across machines, see Reproducible Dev Environments with Dev Containers and a Single Script. If you are working with the New Architecture, the patterns in Adopting React Native New Architecture: Fabric and TurboModules will help you configure native module builds that don't silently break in CI.

Frequently asked questions

How do I make sure my EAS build uses the same Node version as my local machine?
Set the `node` field in `eas.json` under `build.profiles.<profile>.node`. Additionally, maintain a `.nvmrc` file and ensure your CI reads it if the profile doesn't override it. This guarantees that both local and CI use the same Node version.
Why does my EAS build succeed locally but fail on CI?
The most common cause is environment drift: different Node/Yarn versions, missing environment variables, or stale cache from a previous build. Lock all tool versions explicitly in the build profile and clear the cache between runs to force a fresh build.
Can I use the same build profile for iOS and Android?
Yes, but you may need separate profiles for platform-specific settings like `ios.credentialsSource` or `android.credentialsSource`. Use the `extends` key in the profile to share common configuration (e.g., Node version, custom commands) while overriding platform details.
#expo#eas#react-native#ci-cd#reproducible-builds
Share

Keep reading