New
Apps 1,568Makers 1,566This week 1,567
Promote your app−30%
TerraDart screenshot 1
0views in
the last 30 days

TerraDart

🎯 Type-safe IaC for Dart. Contribute to nozomi-koborinai/terradart development by creating an...

Built with Cursor

by nozomi-koborinai · Unclaimed · from JPJapan · added Oct 29, 2026
no ratings0 comments

TerraDart — Type-safe IaC for Dart

TerraDart

Type-safe IaC for Dart.

Write your infrastructure and your app in one typed Dart codebase. TerraDart synthesizes Terraform JSON for Google Cloud, AWS, Cloudflare, Appwrite and Azure, and hands the values your app needs — topic names, IDs, URLs — to it as typed Dart instead of copied strings. The terradart command plans and applies it with OpenTofu, and you never install Terraform.

Alpha — no SemVer until v1.0.0, but breaking changes land only on minor bumps. Pin ^0.35.0, read MIGRATING.md before minor bumps, and see status on terradart.dev.

CI Dart SDK License: Apache-2.0 Docs


Quickstart

# pubspec.yaml
name: my_app
environment:
  sdk: ^3.10.0
dependencies:
  terradart_core: ^0.35.0
  terradart_google: ^0.35.0  # or terradart_aws / terradart_azure / terradart_cloudflare / terradart_appwrite

A Stack is one Terraform root module, written as a Dart class. This one runs an API on Cloud Run that publishes to a Pub/Sub topic, and tells the app which topic that is:

// docs:pitch:start
// lib/orders_stack.dart
import 'package:terradart_google/cloud_run.dart';
import 'package:terradart_google/iam.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, region: 'asia-northeast1')],
        appExports: AppExports('lib/generated/orders_stack.app.dart'),
      ) {
    final orders = add(GooglePubsubTopic('orders', name: .literal('orders')));
    final apiSa = add(GoogleServiceAccount('api', accountId: .literal('orders-api')));
    add(GooglePubsubTopicIamMember(
      'api_publishes_orders',
      topic: orders.ref, // only a GooglePubsubTopic fits here
      role: .literal('roles/pubsub.publisher'),
      member: apiSa.principal,
    ));

    // Typed in the app: a constant now, an output after apply.
    addConstant('ordersTopic', .ref(orders.name));
    addOutput('orders_topic_id', orders.id);

    final api = add(GoogleCloudRunV2Service(
      'api',
      name: .literal('orders-api'),
      location: .literal('asia-northeast1'),
      ingress: .all, // an enum, not a string
      template: CloudRunV2ServiceTemplate(
        serviceAccount: apiSa.ref,
        containers: [
          .new(
            image: .literal('us-docker.pkg.dev/my-project/app/orders-api'),
            env: [
              for (final (:name, :value) in outputEnvironment())
                .new(name: .literal(name), source: .value(value)),
            ],
          ),
        ],
      ),
    ));

    // Pub/Sub pushes as its own service account, which may invoke the API.
    final pushSa = add(GoogleServiceAccount('push', accountId: .literal('orders-push')));
    add(GoogleCloudRunV2ServiceIamMember(
      'push_invokes_api',
      service: api.ref, // emits the service's name, location and project
      role: .literal('roles/run.invoker'),
      member: pushSa.principal,
    ));
    add(GooglePubsubSubscription(
      'orders_push',
      name: .literal('orders-push'),
      topic: orders.ref,
      // A sealed choice: push, BigQuery or Cloud Storage — exactly one.
      delivery: .pushConfig(.new(
        pushEndpoint: api.uri,
        oidcToken: .new(serviceAccountEmail: pushSa.ref),
      )),
    ));
  }
}
// docs:pitch:end

The app imports the file synth generates. Constants are plain Dart; outputs are read from the environment the Stack gave the service:

// lib/orders_api.dart
import 'dart:io';

import 'generated/orders_stack.app.dart';

/// The topic this service publishes to, passed in as ORDERS_TOPIC_ID.
String ordersTopicId() =>
    OrdersStackOutputs.fromEnvironment(Platform.environment).ordersTopicId;

/// Whether a pushed message came from the orders topic.
bool fromOrders(String topicName) => topicName == OrdersStackConstants.ordersTopic;
// 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: 'my-project'));
dart pub get
dart pub global activate terradart_cli
terradart apply

terradart apply runs bin/infra.dart, which writes tf-out/main.tf.json and lib/generated/orders_stack.app.dart, then init and apply in tf-out/ with the tofu or terraform on your PATH — or a checksum-verified OpenTofu it downloads when there is neither. terradart plan, destroy and outputs work the same way; environments are a Dart enum bin/infra.dart hands to runEnvironments (terradart apply --env prod), and a Stack with addDartDefineOutput() gets the define file a Flutter client builds with. See The terradart command, Environments and Outputs in client apps.

Already have tofu or terraform installed, or a pipeline that runs one? terradart uses the engine on your PATH (--engine picks one), and tf-out/ is standard Terraform JSON any of them can apply. The default path is still the terradart command, which needs neither.

What the compiler now checks for you:

  • References are typed. An argument that names another resource takes that resource's ref (topic: orders.ref, serviceAccount: apiSa.ref) and picks the attribute it emits; passing a bucket where a topic belongs does not compile. Every attribute also has a plain getter (orders.name, apiSa.email) that is itself a TfArg, so it passes straight into any argument of its type.
  • Fixed value sets are enums and exclusive blocks are sealed types, written as Dart 3.10 dot shorthands: .all, .pushConfig(...), .value(...). A typo or a second delivery mode is a compile error, not a failed plan.
  • The app and the infra share one source of truth. Rename the topic in the Stack and OrdersStackConstants.ordersTopic follows on the next synth; remove the output and ordersTopicId stops compiling. outputEnvironment() passes every output to the service, so no variable name is written twice. A Flutter or web client gets the same variables at build time: addDartDefineOutput() declares the file --dart-define-from-file reads (terradart apply and terradart outputs write it to .terradart/dart_defines.json), and const OrdersStackOutputs.fromDartDefine() reads it — see Outputs in client apps.
  • It is plain Dart. Loops, conditionals and your own classes work as they always do. Synth is your own bin/infra.dart running; terradart only runs it and then the engine.

Runnable versions: examples/pubsub_quickstart, examples/flutter_client_quickstart (a Flutter app reading typed outputs after terradart apply --env dev), and the single-project-app cookbook recipe (Cloud Run + Cloud SQL + the app). Full walkthrough: Getting started.

Writing arguments

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 is Write Example
known when you synth .literal(...) functionName: .literal('hello')
another resource of this Stack (a RefTo<R> input) its ref role: role.ref
one attribute of another block its getter assumeRolePolicy: trust.json
a resource outside this Stack (a RefTo<R> input) .literal(id) zoneId: .literal('023e105f4ecef8ad9ca31a8372d0c353')
one of a fixed set (an enum) the member runtime: .providedAl2023
one of several exclusive arguments (a sealed type) the variant code: .filename(.literal('build/fn.zip'))
a nested block its helper class; .new(...) inside another block or a variant environment: LambdaFunctionEnvironment(...)
a Terraform variable the handle variable<T>() returns memorySize: 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 literal value: .value(dbPassword)
a reference inside a literal list or map the 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.


Providers

Each package wraps an official Terraform provider: one generated factory per resource and data source, with the provider's own docs, enums and exclusive groups.

Package Terraform provider Catalog Pub
terradart_google hashicorp/google 1380 curated resource factories + 472 data sources (1852 catalog entries) pub
terradart_google_beta hashicorp/google-beta the beta-only types (112 resource factories); everything also in GA stays in terradart_google pub
terradart_aws hashicorp/aws every resource and data source at the pinned provider version pub
terradart_azure hashicorp/azurerm every resource and data source at the pinned provider version pub
terradart_cloudflare cloudflare/cloudflare every resource and data source at the pinned provider version pub
terradart_appwrite appwrite/appwrite every resource and data source at the pinned provider version pub
terradart_time hashicorp/time TimeSleep, the propagation wait for a stack on any provider pub

All of them build on terradart_core (pub): Stack, Resource, Data, TfArg, RefTo and synth. One Stack can mix providers — this one puts a Cloudflare zone in front of the Cloud Run service:

// lib/edge_stack.dart
import 'package:terradart_cloudflare/dns.dart';
import 'package:terradart_cloudflare/provider.dart';
import 'package:terradart_cloudflare/zone.dart';

final class EdgeStack extends Stack {
  EdgeStack({required String accountId}) : super(providers: [const CloudflareProvider()]) {
    final zone = add(CloudflareZone(
      'main',
      name: .literal('example.com'),
      account: ZoneAccount(id: .literal(accountId)),
    ));
    add(CloudflareDnsRecord(
      'api',
      zoneId: zone.ref,
      name: .literal('api.example.com'),
      type: .cname,
      ttl: .literal(1),
      content: .content(.literal('ghs.googlehosted.com')),
      proxied: .literal(true),
    ));
  }
}

Credentials never enter the synthesized JSON: each provider authenticates at apply time through its usual environment variables or credential chain. Per-provider guides: Google Cloud, AWS (Lambda, HTTP API + DynamoDB, ECS Express Mode, S3 + CloudFront), Cloudflare, Appwrite and Azure.


Already on Terraform?

terradart migrate turns an existing Terraform source tree into a TerraDart package: one Stack per module directory, a tf-out/ tree mirroring the source, and a leftover sidecar beside each main.tf.json holding, verbatim and with a reason, every block it cannot translate yet. Resource addresses are preserved, so terradart plan against your existing state reports No changes — move one resource at a time, no big-bang rewrite. It reads .tf / .tf.json only: no Terraform run, no state access.

dart pub global activate terradart_cli
terradart migrate --dir infra --out infra_dart
cd infra_dart && dart pub get && terradart plan

terradart migrate --report sizes a tree without writing anything. --merge-envs folds sibling environments into one Stack, and terradart plan --env <name> runs one of them. Guide: Migrating from HCL.


Tools

Package What it is Pub
terradart_cli The terradart command: synth, plan, apply, destroy, outputs, migrate and the client define file, with OpenTofu or Terraform. pub
terradart_migrate The HCL → Dart migrator library (terradart migrate). pub
terradart_hcl A pure Dart HCL / *.tf.json parser and Terraform module model — the migrator's input side. pub
terradart_codegen The maintainer generation CLI (terradart-codegen wrap) that produces the provider packages. pub

Coding agents. The factories are generated Dart committed to the provider packages, so an agent can read the exact constructor, its doc comment and a CI-validated example instead of guessing. The TerraDart Agent Skill tells it where to look (each package's lib/src/_catalog.g.dart, examples/, /llms.txt):

terradart skill install   # .agents/skills/ and .claude/skills/, the skill of this CLI's release

terradart skill update keeps it in step with the CLI, and terradart skill status --check fails CI when it falls behind. Without the CLI: npx skills add nozomi-koborinai/terradart#v0.35.0 --skill terradart, pinned to the release tag.

Docs: Let an AI agent do it.


Non-goals

  • Not a Terraform replacement. TerraDart synthesizes Terraform JSON, and OpenTofu or Terraform plans and applies it as before. State stays where you already keep it.
  • Not a multi-cloud abstraction layer. Curated wrappers faithfully mirror provider schemas rather than imposing cross-cloud abstractions.
  • Not a constructs framework. Composite abstractions are out of scope for the pre-1.0 cycle.
  • Not a module system. addModule(ModuleCall(...)) calls an existing Terraform module by its source, and HCL files beside the generated *.tf.json feed the same apply; TerraDart does not turn modules into Dart.

How TerraDart compares with HCL, CDKTF and Pulumi: Why TerraDart.


Status

Alpha, pre-1.0 (0.35.x). No SemVer until v1.0.0, but breaking changes land only on minor bumps, always documented in MIGRATING.md; pin ^0.35.0 and take patches freely. Beta needs external validation — see the path to beta.

Contributing

See CONTRIBUTING.md. For security issues, use the GitHub private security advisory flow.

Trademarks

"Terraform" is a registered trademark of HashiCorp, Inc.

Dart™ and the related logo are trademarks of Google LLC. We are not endorsed by or affiliated with Google LLC.

TerraDart is an independent open-source project and is not affiliated with, endorsed by, or sponsored by HashiCorp or Google.

Sponso

Description taken from the repository README.

Ratings

–
0 ratings
5 0
4 0
3 0
2 0
1 0

Rate this app

Sign in to rate

Comments 0

Sign in to comment

No comments yet.