Environment Variables and Secrets Management

Development and Production servers are the standard deployment environments for running, testing and deploying high-availability applications. Each environment is configured with respective environment variables, allowing for structured release management, phased deployment and rollbacks in case of problems.

However, all environment variables are not the same, and can be classified in three buckets. Such a setup allows for a cleaner separation of concerns between environments, without accidentally leaking secrets. Let’s walkthrough the different buckets in Dart (Flutter).

Shared variables

Values don’t change across environments. They are defined in a shared module for easy access and updates. For example, API_ENDPOINT_LOGIN = ‘/auth/login’.

// package: constants/constants.dart
library constants;

/// Login API Endpoint
const String apiEndpointAuthLogin = ‘/api/login';

...
// file: api_client.dart

import 'package:constants/constants.dart' as constants;

...

final loginRequest = Uri.https(
    <API_HOST>,
    constants.apiEndpointAuthLogin,
);

...

Per-environment variables

Values differ between environments. They are public variables and can be checked in the repo. For example, API_HOST = ’localhost’ for development environment and API_HOST = ‘CUSTOM API SERVER’ for production environment.

// package: environments/environment.dart
library environments;

class Environment {
  factory Environment() {
    return _singleton;
  }

  Environment._internal();

  static final Environment _singleton = Environment._internal();

  /// DEV environment string.
  static const String dev = 'DEV';

  /// PROD environment string.
  static const String prod = 'PROD';

  /// Environment config.
  late BaseConfig config;

  /// Init environment config.
  void initConfig(String environment) {
    config = _getConfig(environment);
  }

  BaseConfig _getConfig(String environment) {
    switch (environment) {
      case Environment.prod:
        return ProdConfig();
      default:
        return DevConfig();
    }
  }
}

abstract class BaseConfig {
  String get apiHost;
}

/// Environment config for DEV.
class DevConfig implements BaseConfig {
  @override
  String get apiHost => 'localhost';
}

/// Environment config for PROD.
class ProdConfig implements BaseConfig {
  @override
  String get apiHost => ‘CUSTOM API SERVER’;
}
// file: main.dart

import 'package:environments/environment.dart';

...

Future<void> main() {

// Init environment config.
  const environment = String.fromEnvironment(
    'ENVIRONMENT',
    defaultValue: Environment.dev,
  );
  Environment().initConfig(environment);

  ...
}
// file: api_client.dart

import 'package:constants/constants.dart' as constants;
import 'package:environments/environment.dart';

...

final loginRequest = Uri.https(
    Environment().config.apiHost,
    constants.apiEndpointAuthLogin,
);

...
// Runtime

$ flutter run --dart-define=ENVIRONMENT=DEV
$ flutter run --dart-define=ENVIRONMENT=PROD

Per-environment secrets

Values differ between environments. However, these variables should NOT be checked in the repo. For example, API_KEY = ‘DEV_API_KEY’ for development environment and API_KEY = ‘PROD_API_KEY’ for production environment.

// file: main.dart

import 'package:flutter_dotenv/flutter_dotenv.dart';
...

Future<void> main() {
  ...

  // Load environment secrets.
  dotenv.load(
    fileName: 'assets/.$environment.env',
  );
  
  ...
}
// file: api_client.dart

import 'package:constants/constants.dart' as constants;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;

...

final loginResponse = await http.Client.get(
    loginRequest,
    headers: {
      constants.headerAPIKey: dotenv.get('API_KEY'),
    },
);
  
...
// file: assets/.DEV.env

API_KEY=DEV_API_KEY
// file: assets/.PROD.env

API_KEY=PROD_API_KEY
// file: .gitignore
...

*.env
...