Launch offer: the first 1,000 users get Settl free for a year*Claim your spot
settlbuilding in public

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

  1. 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
  }
}
  1. Localhost is opt-in, never fallback. Run flutter run --dart-define=LOCAL_BACKEND=1 when iterating on backend code. Forgetting the flag now means "hits prod" (annoying, visible) instead of "hits nothing" (confusing, invisible).
  2. Document the resolution order in the README. Mine has a table: API_BASE_URL wins → LOCAL_BACKEND=1 → prod default. Plus the history of why, so nobody "simplifies" it back.
  3. Treat flutter clean as 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.
  4. Log the resolved base URL once at boot (debug builds): one debugPrint('API → $baseUrl') would have turned a mystery into a one-second diagnosis.
  5. Never gate environment on build mode alone. kDebugMode ? localhost : prod is the footgun pattern. Debug builds on real devices are how you dogfood, and they should hit prod data unless told otherwise.
  6. Block localhost in release binaries outright. Cheap assert: if the URL contains localhost and it's a release build, fail fast at startup instead of failing on every request.

Steal this for your app

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.

Download the runnable pack

Get the next one in your inbox