I'm building an Expo/React Native app. Last week I installed my own production build from the Play Store, opened the paywall, and got "Purchases aren't available here."
Odd, because purchases worked fine in my dev build. So did cloud sync. So did everything.
Root cause: .env is in .gitignore, and EAS Build uses .gitignore to decide what to upload. My .env never reached the build server. Every EXPO_PUBLIC_* variable resolved to undefined in the cloud.
That meant three production builds shipped with:
no Supabase URL or key (auth and sync silently dead)
no RevenueCat key (paywall dead)
no Sentry DSN (crash reporting dead, so nothing reported the other two)
Nothing errored. The app launched, logged data locally, looked completely normal. Features just quietly did nothing.
Local builds masked it perfectly, because they read .env straight off disk.
What made it take a day longer than it should have: I found this line in the cloud build log and read it as confirmation that .env was loaded:
The NODE_ENV environment variable is required but was not specified. Using only .env.local and .env
It isn't. It only states which files Expo would consider. It says nothing about whether they exist.
The actual tell was the absence of something. Local builds print:
env: load .env env: export EXPO_PUBLIC_SUPABASE_URL EXPO_PUBLIC_...
My cloud logs had zero occurrences of env: load, and zero occurrences of the string EXPO_PUBLIC anywhere in the entire build log. That was sitting there the whole time.
The fix. Put every public var in EAS's own environment store:
npx eas env:create --name EXPO_PUBLIC_FOO --value bar \ --environment production --visibility plaintext npx eas env:list --environment production # what builds actually use
A correct build then prints, near the top:
Environment variables ... loaded from the "production" environment on EAS: EXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_...
If that line lists nothing, your build is broken.
How to actually verify rather than trust it. Unzip the AAB and grep the JS bundle, always with a control string you know is present:
unzip -q app.aab -d x B=x/base/assets/index.android.bundle grep -a -c "Some UI String You Wrote" $B # control grep -a -c "your_api_key" $B # the thing under test
One caveat that cost me another hour: Hermes stores any string containing a non-ASCII character (em dashes, curly quotes) as UTF-16, where ASCII grep will never find it. If your UI copy has em dashes in it — mine does, everywhere — search the raw bytes both ways:
data = open(bundle,'rb').read() data.count(b"plain ascii string") data.count("string with — dash".encode("utf-16-le"))
The generalizable lesson: any config that lives only in a gitignored file is invisible to your CI. That's the entire bug. It's obvious in hindsight and completely silent in practice, and I'd bet a lot of Expo projects have it right now without knowing, because local builds never complain.