Skip to content

Environments

A TerraDart project declares its environments in Dart: an enum of your own, one member per environment, each carrying that environment’s values. runEnvironments builds one Stack per member, and terradart apply --env <name> runs one of them and writes that environment’s define file for the client. Nothing is configured in pubspec.yaml.

Any members, any fields. The Stack takes one member and derives everything from it — the project, the sizes, and where its state lives, so one environment can keep a local state file while another uses a GCS bucket:

lib/env.dart
enum Env {
dev(projectId: 'acme-dev', stateBucket: null),
stg(projectId: 'acme-stg', stateBucket: 'acme-stg-tfstate'),
prod(projectId: 'acme-prod', stateBucket: 'acme-prod-tfstate');
const Env({required this.projectId, required this.stateBucket});
final String projectId;
/// The GCS bucket of the state; `null` keeps a local file.
final String? stateBucket;
}
lib/app_stack.dart
import 'package:my_app/env.dart';
import 'package:terradart_google/provider.dart';
final class AppStack extends Stack {
AppStack({required Env env})
: super(
providers: [GoogleProvider(project: env.projectId)],
backend: switch (env.stateBucket) {
final bucket? => GcsBackend(bucket: bucket, prefix: 'app'),
null => LocalBackend(path: 'state/${env.name}.tfstate'),
},
) {
addOutput('api_url', .literal('https://api.${env.projectId}.example.com'));
addDartDefineOutput();
}
}

runEnvironments takes the members and builds the Stack for the one the command names:

bin/infra.dart
import 'package:my_app/app_stack.dart';
import 'package:my_app/env.dart';
import 'package:terradart_core/terradart_core.dart';
Future<void> main(List<String> args) =>
runEnvironments(args, Env.values, (env) => AppStack(env: env));

terradart synth without --env writes every environment. validate, plan, apply, destroy and outputs run against one, the first of:

  1. --env <name> (-e), a member’s name;
  2. the TERRADART_ENV environment variable — ignored by an entry point that calls runStack;
  3. the defaultEnv the entry point gives runEnvironments;
  4. the only environment, when the enum has one member.

With none of these, the command stops and lists the names. It prints which one it chose and why — env: dev (--env), env: dev (TERRADART_ENV), env: dev (default), env: dev (only environment):

bin/infra_default_env.dart
import 'package:my_app/app_stack.dart';
import 'package:my_app/env.dart';
import 'package:terradart_core/terradart_core.dart';
Future<void> main(List<String> args) => runEnvironments(
args,
Env.values,
(env) => AppStack(env: env),
defaultEnv: Env.dev,
);

When TERRADART_ENV or defaultEnv chose the environment, apply and destroy ask first, and run only on yes:

$ terradart apply
> dart run bin/infra.dart
...
env: dev (default)
Apply environment "dev" (default)? Only "yes" is accepted:

--auto-approve skips the question, as it skips the engine’s. Without an answer to read (no terminal, or input closed), the command stops before init; pass --env or --auto-approve in CI.

Terminal window
terradart validate --env stg
terradart plan --env stg
terradart apply --env stg

The names are the enum’s, not a fixed set: qa, sandbox, euWest work the same. A name that is not a member stops before anything runs, listing the ones that are:

$ terradart plan --env staging
> dart run bin/infra.dart --env staging
infra: unknown environment "staging"; known envs: dev, stg, prod.
terradart: synth failed: bin/infra.dart exited 64.

Each environment gets its own Terraform directory and its own define file, named after it. After apply, terradart writes the define file and prints the line that builds the client with it:

CommandTerraform directoryDefine fileClient build
terradart apply --env devtf-out/dev.terradart/dart_defines.dev.jsonflutter run --dart-define-from-file=.terradart/dart_defines.dev.json
terradart apply --env stgtf-out/stg.terradart/dart_defines.stg.jsonflutter build web --dart-define-from-file=.terradart/dart_defines.stg.json
terradart apply --env prodtf-out/prod.terradart/dart_defines.prod.jsonflutter build ipa --dart-define-from-file=.terradart/dart_defines.prod.json

A client’s build job does not apply anything: terradart outputs --env prod writes the same file from the applied state, and the build compiles it in.

Terminal window
terradart outputs --env prod
flutter build web --dart-define-from-file=.terradart/dart_defines.prod.json

The app reads every value with its type through the generated <Stack>Outputs reader, whichever environment it was built for. Outputs in client apps covers the reader, the values a define file may carry, and why a secret never goes in one.

One directory, one backend, several states

Section titled “One directory, one backend, several states”

When every environment keeps its state in the same kind of backend and only its settings differ, the environments can share one directory and tell their states apart when terradart initializes it. runEnvironments says how, in Dart:

  • Partial backend configuration. The Stack’s backend leaves the settings out (const GcsBackend()); backendConfig names a -backend-config file (relative to the package) or key=value pairs per environment. terradart runs init -reconfigure with them on every command, so the directory never keeps another environment’s backend.

    import 'package:my_app/app_stack.dart';
    import 'package:my_app/env.dart';
    import 'package:terradart_core/terradart_core.dart';
    Future<void> main(List<String> args) => runEnvironments(
    args,
    Env.values,
    (env) => AppStack(env: env),
    dir: (_) => 'tf-out',
    backendConfig: (env) => ['backend/${env.name}.gcs.tfbackend'],
    );
  • Workspaces. workspace names the Terraform workspace terradart selects after init, creating it on the first plan or apply.

    import 'package:my_app/app_stack.dart';
    import 'package:my_app/env.dart';
    import 'package:terradart_core/terradart_core.dart';
    Future<void> main(List<String> args) => runEnvironments(
    args,
    Env.values,
    (env) => AppStack(env: env),
    dir: (_) => 'tf-out',
    workspace: (env) => env.name,
    );

Two environments may share a directory only when one of these tells them apart; runEnvironments throws otherwise. --workspace and --backend-config on the command line override and extend them for one run.

An environment’s backend is Dart too, so moving its state starts with a change to the Stack — giving stg a stateBucket, say, where it kept a local file. The state then follows once:

Terminal window
terradart state migrate --env stg

It synthesizes stg, names the full source and target configuration (the bucket and prefix, not only the backend type), asks, and runs the engine’s init -migrate-state in stg’s directory with its backendConfig. --auto-approve skips the question, and is required when it cannot ask (no terminal, --no-input, CI or an agent). Run it before the next plan or apply of stg: those reconfigure the directory onto the new backend without copying. When environments share that directory, the copy is the state of the environment that last initialized it. If that was another environment, or TerraDart has no record of which one, it stops and tells you to run terradart plan --env stg first — it does not copy, and --auto-approve does not skip the stop. The other environments are not touched.

terradart migrate --merge-envs writes an Env enum whose members carry the directory each environment came from (path), and a bin/infra.dart that calls runEnvironments with dir: (env) => 'tf-out/${env.path}'. terradart plan --env prodEu plans the matching environment. A single-module migration calls runStack instead, so terradart plan with no --env is enough. See Migrating from HCL.