Skip to content

Getting started

This guide matches the README quickstart for the 0.35.x line. TerraDart is alpha — breaking changes land only on minor bumps; see Status & versioning for the change policy and the path to beta.

Each of these is one path you can paste, from installing the terradart command to an apply. The rest of this page builds the same pieces by hand, one at a time.

  • Dart SDK ≥ 3.10
  • Nothing else to install for plan and apply: the terradart command downloads a checksum-verified OpenTofu on first use, or runs the tofu or terraform already on your PATH.
  • Appwrite only: Terraform on your PATH. The appwrite/appwrite provider is published to the Terraform registry only, so OpenTofu cannot install it, and an Appwrite Stack runs on Terraform (--engine terraform, or terradart: engine: terraform in pubspec.yaml).
  • Credentials for your provider at apply time — Google Cloud Application Default Credentials (gcloud auth application-default login), the AWS SDK credential chain, CLOUDFLARE_API_TOKEN, or APPWRITE_API_KEY. Synth needs none, and none are written into the output.

Install the terradart command once, and let it write the project:

Terminal window
dart pub global activate terradart_cli
terradart init --provider google --env dev,prd --gcp-project dev=acme-dev,prd=acme-prd --backend local
cd infra
terradart plan --env dev

terradart init writes infra/ (or the directory you name), runs dart pub get in it, and prints what to run next. The project plans as it is:

FileHolds
pubspec.yamlterradart_core and the provider packages, at the command’s own version; terradart: engine: terraform with Appwrite
lib/env.dartthe Env enum: each environment and its values — project or account ID, region, state bucket
lib/stack.darta Stack with the provider, the backend, one resource and one output
bin/infra.dartthe entry point, which hands the environments to runEnvironments
.gitignoretf-out/ and .terradart/
README.md, AGENTS.mdthe next steps, and the commands and Agent Skill a coding agent works with

A value you did not give is a placeholder marked TODO in lib/env.dart.

In a terminal, terradart init asks for what the flags leave out — the providers, the environment names, each environment’s project, region or account ID, whether a state bucket exists — and ends by printing the terradart init ... command that gives the same answers as flags. Asked whether a bucket for the state already exists, no keeps the state in a local file (move it later with terradart state migrate); yes takes its name and the backend of your provider. Without a terminal it never asks and never chooses for you: --provider, --env and --backend (or --state-bucket) are required, and a run without them prints the command to edit; --defaults accepts dev,prd and local state. --dry-run lists the files without writing them, and an existing file is never overwritten without --force.

FlagTakes
--providergoogle, aws, cloudflare, appwrite, comma-separated
--envany lowerCamelCase names, comma-separated
--gcp-project, --aws-region, --aws-account, --cloudflare-account, --appwrite-endpoint, --appwrite-project<env>=<value> pairs, or one value for every environment
--state-bucketa bucket that already exists, one name or <env>=<name> pairs: GCS for google, S3 for aws, R2 for cloudflare
--backendlocal (a file under tf-out/), gcs, s3 or r2
--defaults--env dev,prd --backend local
--[no-]flutterwiring to the Flutter app in the current directory

Run inside a Flutter app, it also generates the reader the app imports into the app’s lib/generated/, and declares the define file the app builds with:

Terminal window
terradart init
cd infra && terradart apply --env dev && cd ..
flutter run --dart-define-from-file=infra/.terradart/dart_defines.dev.json

A directory that already holds *.tf files, at any depth, gets no scaffold: terradart init names where it found them and points at terradart migrate instead, and in a terminal offers to run its report.

The rest of this guide builds the same pieces by hand, one at a time.

terradart_core plus the package for each provider your Stack uses (all on pub.dev). Every provider barrel re-exports terradart_core, so the Stack itself imports only the barrels:

pubspec.yaml
name: my_app
environment:
sdk: ^3.10.0
dependencies:
terradart_core: ^0.35.0
terradart_google: ^0.35.0
# terradart_google_beta: ^0.35.0 # beta-only types (Firebase, ...)
Terminal window
dart pub get
dart pub global activate terradart_cli

The second line installs the terradart command, which synthesizes the Stack and runs plan or apply for you. To pin it per project instead, dart pub add --dev terradart_cli and run it as dart run terradart_cli:terradart.

Each provider package wraps the whole catalog of its Terraform provider (Google’s beta-only types live in terradart_google_beta), so any resource the provider documents has a factory. One Stack can mix packages.

A Stack is one Terraform root module. add registers a resource; addConstant and addOutput declare what your app reads back — a constant at synth time, an output after apply — and appExports names the Dart file they are generated into:

lib/orders_stack.dart
import 'package:terradart_google/provider.dart';
import 'package:terradart_google/pubsub.dart';
final class OrdersStack extends Stack {
OrdersStack({required String projectId})
: super(
providers: [GoogleProvider(project: projectId)],
appExports: AppExports('lib/generated/orders_stack.app.dart'),
) {
final topic = add(GooglePubsubTopic(
'orders',
name: .literal('orders-prod'),
));
addConstant('ordersTopicName', .ref(topic.name));
addOutput('orders_topic_id', topic.id);
}
}

.literal(...) is a value known now; ... is one Terraform resolves during apply. An argument that names another resource takes that resource’s ref (zoneId: zone.ref) and does not compile with the wrong kind. Both are Dart 3.10 dot shorthands, like the enum in .cname — every argument is typed, so the type name can be left out. A block inside another block or inside a sealed choice is built the same way, with .new(...): delivery: .pushConfig(.new(pushEndpoint: ...)). Keep the class name on a resource’s own arguments (template: CloudRunV2ServiceTemplate(...)), where it tells a reader which block starts.

Pick the form by where the value comes from. The argument’s type tells you which forms it takes, and a dot shorthand (.literal, .new, .providedAl2023) names the constructor of that type.

The value isWriteExample
known when you synth.literal(...)functionName: .literal('hello')
another resource of this Stack (a RefTo<R> input)its refrole: role.ref
one attribute of another blockits getterassumeRolePolicy: trust.json
a resource outside this Stack (a RefTo<R> input).literal(id)zoneId: .literal('023e105f4ecef8ad9ca31a8372d0c353')
one of a fixed set (an enum)the memberruntime: .providedAl2023
one of several exclusive arguments (a sealed type)the variantcode: .filename(.literal('build/fn.zip'))
a nested blockits helper class; .new(...) inside another block or a variantenvironment: LambdaFunctionEnvironment(...)
a Terraform variablethe handle variable<T>() returnsmemorySize: memory
a variable in an enum or RefTo<R> input.arg(handle)runtime: .arg(runtimeName)
a secret (a Sensitive<T> input)a sensitive variable, never a literalvalue: .value(dbPassword)
a reference inside a literal list or mapthe getter’s .interpolation{'ROLE_ARN': role.arn.interpolation}
anything else Terraform evaluates.expression(...).expression(r'${file("trust.json")}')
final memory = variable<num>('memory_mb');
final runtimeName = variable<String>('runtime');
final dbPassword = variable<String>('db_password', sensitive: true);
final trust = add(
DataAwsIamPolicyDocument(
'trust',
statement: [
DataIamPolicyDocumentStatement(
actions: .literal(['sts:AssumeRole']),
principals: [
.new(
type: .literal('Service'),
identifiers: .literal(['lambda.amazonaws.com']),
),
],
),
],
),
);
final role = add(AwsIamRole('hello', assumeRolePolicy: trust.json));
add(
AwsLambdaFunction(
'hello',
functionName: .literal('hello'),
role: role.ref,
runtime: .arg(runtimeName),
code: .filename(.literal('build/fn.zip')),
memorySize: memory,
environment: LambdaFunctionEnvironment(
variables: .literal({'ROLE_ARN': role.arn.interpolation}),
),
),
);
add(
AwsSsmParameter(
'db_password',
name: .literal('/hello/db_password'),
type: .securestring,
value: .value(dbPassword),
),
);

More on each row: Writing arguments.

Synthesis is plain Dart: bin/infra.dart, the project’s entry point, constructs the Stack and hands it to runStack, which writes it. The rest of this guide follows the Google Cloud Stack; for the others, construct QueueStack(), EdgeStack(accountId: ...) or UploadsStack() the same way.

bin/infra.dart
import 'package:my_app/orders_stack.dart';
import 'package:terradart_core/terradart_core.dart';
Future<void> main(List<String> args) =>
runStack(args, () => OrdersStack(projectId: 'YOUR-PROJECT-ID'));
Terminal window
terradart synth

This runs bin/infra.dart, which writes tf-out/main.tf.json (with the orders_topic_id output) and lib/generated/orders_stack.app.dart (with the ordersTopicName constant and the OrdersStackOutputs reader). dart run bin/infra.dart does the same without the command.

Terminal window
terradart plan
terradart apply

Each command synthesizes first, then runs init and plan or apply in tf-out/ with the tofu or terraform on your PATH, or a checksum-verified OpenTofu it downloads when there is neither. A Stack that uses Appwrite runs on the terraform on your PATH, and stops before init when there is none or --engine tofu asks for OpenTofu. For several environments, declare them as a Dart enum and hand it to runEnvironments in bin/infra.dart; terradart apply --env prod then runs one of them. See Environments and The terradart command.

On a new Google Cloud project every API is off, so the first apply fails with a 403 until the Stack turns on the ones it uses with enableApis([.pubsub]) — see Enabling APIs. The project terradart init writes does it already.

The output is ordinary Terraform JSON, so your existing remote state backend and modules stay unchanged — TerraDart only replaces HCL/JSON authoring.

App code imports the generated file instead of repeating strings:

lib/subscriber.dart
import 'dart:io';
import 'generated/orders_stack.app.dart';
bool acceptsTopic(String eventTopic) =>
eventTopic == OrdersStackConstants.ordersTopicName;
/// Known only after apply, so it comes from the generated outputs reader.
String ordersTopicId() =>
OrdersStackOutputs.fromEnvironment(Platform.environment).ordersTopicId;

A deployed service reads outputs from its environment (ORDERS_TOPIC_ID) — the Stack passes them with outputEnvironment() — and a script can read the define file terradart outputs writes. A Flutter or web client is built with them instead. Add addDartDefineOutput() to the Stack, and terradart apply writes the define file the build compiles in:

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

With environments, each one gets its own file: terradart apply --env prod writes .terradart/dart_defines.prod.json. The client reads each value with its type through the same OrdersStackOutputs reader; see Outputs in client apps.

Rename orders-prod in the Stack and the subscriber follows on the next synth — there is no second copy of the string to update. Rename or remove the constant and dart analyze fails. See How TerraDart works — the app boundary and the runnable pubsub quickstart (lib/subscriber_stub.dart).