Skip to content

Outputs in client apps

A client app needs values that exist only after apply: the URL of the API it calls, the bucket it uploads to. TerraDart hands them over without a copied string and without tying the app’s release to the apply. The Stack declares one more output, the define file; terradart apply or terradart outputs writes it from the applied state, the client’s build compiles it in with --dart-define-from-file, and reads each value with its type through the generated <Stack>Outputs reader.

It works the same on every provider package — Google Cloud, AWS, Cloudflare, Appwrite — because it lives on Stack, not on a provider.

addDartDefineOutput() adds a Terraform output, dart_defines, whose value is a JSON object with one string per non-sensitive output: the variable named after the output in SCREAMING_SNAKE_CASE, holding a String output as it is and any other type as JSON. These are the same variables outputEnvironment() passes to a server, so a client and a service read one set of names.

lib/app_stack.dart
import 'package:terradart_google/cloud_run.dart';
import 'package:terradart_google/provider.dart';
import 'package:terradart_google/storage.dart';
final class AppStack extends Stack {
AppStack({required String projectId})
: super(
providers: [GoogleProvider(project: projectId)],
appExports: AppExports('lib/generated/app_stack.app.dart'),
) {
final api = add(
GoogleCloudRunV2Service(
'api',
name: .literal('app-api'),
location: .literal('asia-northeast1'),
template: CloudRunV2ServiceTemplate(
containers: [
.new(image: .literal('us-docker.pkg.dev/cloudrun/container/hello')),
],
),
),
);
final uploads = add(
GoogleStorageBucket(
'uploads',
name: .literal('$projectId-uploads'),
location: .literal('ASIA-NORTHEAST1'),
),
);
addOutput('api_url', api.uri, description: 'Base URL of the API.');
addOutput('api_urls', api.urls);
addOutput('uploads_bucket', uploads.name);
addDartDefineOutput();
}
}
bin/infra.dart
import 'package:my_app/app_stack.dart';
import 'package:terradart_core/terradart_core.dart';
Future<void> main(List<String> args) =>
runStack(args, () => AppStack(projectId: 'my-project'));

terradart synth writes it into tf-out/main.tf.json after the three outputs it carries:

{
"output": {
"dart_defines": {
"value": {
"API_URL": "${google_cloud_run_v2_service.api.uri}",
"API_URLS": "${jsonencode(google_cloud_run_v2_service.api.urls)}",
"UPLOADS_BUCKET": "${google_storage_bucket.uploads.name}"
}
}
}
}

The call can come before the outputs it carries: the define file is resolved at synth, from every output the Stack registered by then.

lib/generated/app_stack.app.dart holds AppStackOutputs, one getter per non-sensitive output, typed like the output’s value. Its fromDartDefine constructor reads the values compiled into the app, so the reader is a constant:

lib/api_client.dart
import 'generated/app_stack.app.dart';
const outputs = AppStackOutputs.fromDartDefine();
Uri endpoint(String path) => Uri.parse(outputs.apiUrl).resolve(path);
List<Uri> mirrors() => [for (final url in outputs.apiUrls) Uri.parse(url)];
String uploadsBucket() => outputs.uploadsBucket;

Each getter reads when it is called. A getter whose define is not set throws a StateError that names the variable and the output (Dart define API_URL (Terraform output "api_url") is not set.), and so does one whose value is not JSON or not of the getter’s type — so a client built without the file fails on the first value it reads, saying which one.

The client’s pipeline reads the applied state; it never plans or applies. terradart apply writes the define file to .terradart/dart_defines.json after the apply, and terradart outputs writes it from the applied state alone, for a build job that may read the state but not change it. A Flutter build reads it as it is:

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

The app ships when it is ready, against whatever was applied last; a new apply reaches it on its next build.

The Flutter client quickstart is that build, end to end: terradart apply --env dev writes .terradart/dart_defines.dev.json, and flutter run --dart-define-from-file=.terradart/dart_defines.dev.json compiles it into the app, which reads FlutterClientStackOutputs.fromDartDefine().

With environments, each environment’s apply writes its own file, .terradart/dart_defines.<env>.json, and each client build names the one it is for:

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

The generated reader is the same for every environment; only the values compiled in differ.

terradart outputs --env prod runs init with that environment’s backend, then the engine’s output -json dart_defines, and writes the file. Running the raw engine command yourself also works — tofu output -json dart_defines > dart_defines.json (or terraform output ...) in the environment’s Terraform directory, after init — and prints the same JSON object.

dart run and dart compile take one define per flag, so pass each value of the define file as it is — every value in it is already the string the reader expects:

Terminal window
terradart outputs
dart run -DAPI_URL="$(jq -r .API_URL .terradart/dart_defines.json)" -DAPI_URLS="$(jq -r .API_URLS .terradart/dart_defines.json)" bin/client.dart
dart compile js -DAPI_URL="$(jq -r .API_URL .terradart/dart_defines.json)" -o web/main.dart.js web/main.dart

The AWS Lambda quickstart (bin/client.dart) calls its function URL this way.

The two halves come at different times:

  1. Synth, before any apply: terradart synth (or dart run bin/infra.dart) writes the reader class, AppStackOutputs. Its getters and their types are known, so the client compiles against it, but it holds no values.
  2. Apply, then the client’s build: the values exist only once the Stack is applied. terradart apply (or terradart outputs in the client’s build) writes them to .terradart/dart_defines.json (.terradart/dart_defines.<env>.json with --env), and flutter build web --dart-define-from-file=.terradart/dart_defines.json (or apk, ios, …) compiles them in. A build without them fails at the first read, with a StateError that names the variable and the command that writes it.

terradart outputs reads the state from the Stack’s backend: a local state file, or a remote bucket such as a GcsBackend or S3Backend. The machine or CI job that builds the client runs init against that backend — terradart outputs does — and needs read access to the state; it never needs permission to apply.

Give each client only what it reads. only picks outputs by name, and name names the output:

final uploads = add(
GoogleStorageBucket(
'uploads',
name: .literal('my-project-uploads'),
location: .literal('ASIA-NORTHEAST1'),
),
);
final reports = add(
GoogleStorageBucket(
'reports',
name: .literal('my-project-reports'),
location: .literal('ASIA-NORTHEAST1'),
),
);
addOutput('uploads_bucket', uploads.name);
addOutput('uploads_url', uploads.url);
addOutput('reports_bucket', reports.name);
addDartDefineOutput(
name: 'mobile_defines',
only: ['uploads_bucket', 'uploads_url'],
);
addDartDefineOutput(name: 'reports_defines', only: ['reports_bucket']);

terradart outputs --define-output mobile_defines writes the mobile app’s file, .terradart/mobile_defines.json, and --define-output reports_defines the reporting tool’s. Both build from the same generated reader; a getter of an output their file does not carry throws when called.

Everything compiled into a client can be read by anyone who has the app, so the define file never carries a sensitive output: it is left out of the default set, and synth reports an InvalidDartDefineOutput issue when only names one. Synth reports the same issue when only names an output that is not registered, when two outputs share a variable, and when the file would carry no output at all.

Build a client from the define file only: it is the one view of the outputs that leaves the sensitive ones out. The full output set of the state holds them in plain text.

The rule is wider than Terraform: a dart-define is compiled into the app binary or its JavaScript, where anyone can extract it, so never pass a secret to a client that way, wherever it comes from — Secret Manager, a GitHub Actions secret, a sensitive output. A client gets only public values, such as an API URL or a Firebase web config, and work that needs a secret runs on a server:

  • A server reads secrets at runtime from its secret store: a Cloud Run service through a secret environment reference (source: .valueSource(.new(secretKeyRef: ...)) on CloudRunV2ServiceEnv), a Lambda function from AWS Secrets Manager with the SDK, under its execution role.
  • CI secrets are credentials for the pipeline — reading the state, deploying — not values for the app.

The same reader serves the other places an app runs:

Where the app runsHow it gets the valuesReader
A Flutter, web or CLI client--dart-define-from-file with the define file, or -D per variableAppStackOutputs.fromDartDefine()
A Cloud Run service or a functionits environment, set by the Stack with outputEnvironment()AppStackOutputs.fromEnvironment(Platform.environment)
A script or test after applythe define file, read at run timeAppStackOutputs.fromEnvironment(defines)

A script reads the define file terradart outputs wrote as the environment it stands for:

lib/applied_outputs.dart
import 'dart:convert';
import 'dart:io';
import 'generated/app_stack.app.dart';
AppStackOutputs appliedOutputs() {
final defines = File('.terradart/dart_defines.json').readAsStringSync();
final values = jsonDecode(defines) as Map<String, Object?>;
return AppStackOutputs.fromEnvironment(values.cast<String, String>());
}

See How TerraDart works — the app boundary for addOutput, outputEnvironment() and the constants a Stack knows at synth, which need no define at all.