Safe-defaults environment config for Flutter (prod by default)
My production app was secretly calling http://localhost:3000. On a phone. flutter clean
wipes the cached --dart-define values, so the next build silently fell back to the default
API URL, which was localhost. Connection refused, everywhere, with zero build warnings. The
fix: prod is the default in every build mode; localhost is an explicit opt-in flag.
The failure chain
--dart-define=API_BASE_URL=... gets cached (in Generated.xcconfig on iOS) → flutter clean
deletes the cache → next "play" button build uses your in-code default → if that default is
localhost, your device app dials your laptop.
The playbook
- Make the safe thing the default thing. Default = production, in debug AND release:
class ApiConfig {
static const _localBackend = bool.fromEnvironment('LOCAL_BACKEND');
static const _override = String.fromEnvironment('API_BASE_URL');
static String get baseUrl {
if (_override.isNotEmpty) return _override; // staging/branch deploys
if (_localBackend) return 'http://localhost:3000'; // explicit opt-in only
return 'https://api.example.com'; // DEFAULT: prod, always
}
}
- Localhost is opt-in, never fallback. Run
flutter run --dart-define=LOCAL_BACKEND=1when iterating on backend code. Forgetting the flag now means "hits prod" (annoying, visible) instead of "hits nothing" (confusing, invisible). - Document the resolution order in the README. Mine has a table:
API_BASE_URLwins →LOCAL_BACKEND=1→ prod default. Plus the history of why, so nobody "simplifies" it back. - Treat
flutter cleanas a config-reset event. Anything sourced from dart-defines is gone after it. If a build behaves weirdly right after a clean, check environment resolution first. - Log the resolved base URL once at boot (debug builds): one
debugPrint('API → $baseUrl')would have turned a mystery into a one-second diagnosis. - Never gate environment on build mode alone.
kDebugMode ? localhost : prodis the footgun pattern. Debug builds on real devices are how you dogfood, and they should hit prod data unless told otherwise. - Block localhost in release binaries outright. Cheap assert: if the URL contains
localhostand it's a release build, fail fast at startup instead of failing on every request.
Steal this for your app
- The pattern generalizes: any config with a fallback should fall back to the value that's safe in production (prod URLs, feature OFF, verbose logging OFF). Developer convenience gets the explicit flag.
- Audit your defaults now: grep for
localhost,127.0.0.1,http://in shipped code and ask "what happens if the env layer vanishes?" Because one day (a clean, a new machine, a CI cache miss) it will. - Same rule for web/backend:
.envmissing should mean "production posture," not "developer posture."
Run this on your codebase
Paste this into Claude Code in your repo:
Audit this repo for unsafe environment defaults.
Grep shipped code for localhost, 127.0.0.1, 10.0.2.2, and http:// URLs, and note which are reachable as fallbacks.
Trace how the API base URL resolves: env vars, dart-defines, build flavors, and the in-code default when all are missing.
Flag any default that points at a developer machine, and any config gated on build mode alone like kDebugMode ? localhost : prod.
Check other fallbacks too: feature flags, logging verbosity, and debug tooling should default to their production-safe value.
Propose prod-by-default resolution with localhost as an explicit opt-in flag, plus a startup assert that blocks localhost in release builds.
Report findings as a checklist before changing anything.
The stack-neutral required-env gate
The Flutter URL bug is one version of a larger production failure: local development quietly has a value that the deployed process does not. Stop treating environment variables as loose strings. Keep one manifest of required names and validate it before the server listens.
const required = ['DATABASE_URL', 'SESSION_SECRET', 'PUBLIC_APP_URL'];
const missing = required.filter((name) => !process.env[name]?.trim());
if (missing.length) throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
Do not print values. The name is enough to fix a missing variable without leaking a secret into logs or CI. Separate required secrets, required public configuration, and optional settings with safe defaults.
Add one CI test that launches the app with each required name removed and expects startup to fail immediately. Then compare the manifest with the deployment platform by name only. A server that accepts traffic with half its configuration missing is saving the error for a user.