terrably

Getting started

Scaffold, implement, build, and test your first Terraform provider in TypeScript in under 10 minutes.

Prerequisites

terrably supports two runtimes.

RuntimeVersionNotes
Node.js≥ 25.5.0Uses node --build-sea; requires a native runner per platform for CI
Bun≥ 0.6.0Uses bun build --compile; supports cross-compilation in a single CI job, and usually produces a smaller output binary

Scaffold a new project

npx terrably new mycloud
cd terraform-provider-mycloud
npm install

The scaffold creates a fully-wired starter project –

main.ts
provider.ts
tsconfig.json
package.json

Implement a resource

Every resource is a class implementing the Resource interface. The method name becomes the Terraform type: getName() returns "server" → Terraform type is "mycloud_server".

src/resources/server.ts
import { types, Attribute, Schema } from "terrably";
import type { Resource, CreateContext, ReadContext, UpdateContext, DeleteContext, Provider, State } from "terrably";

export class MyCloudServer implements Resource {
  constructor(private readonly provider: MyCloudProvider) {}

  getName() { return "server"; }

  getSchema(): Schema {
    return new Schema([
      new Attribute("id",         types.string(), { computed: true }),
      new Attribute("name",       types.string(), { required: true }),
      new Attribute("region",     types.string(), { required: true, requiresReplace: true }),
      new Attribute("ip_address", types.string(), { computed: true }),
    ]);
  }

  async create(ctx: CreateContext, planned: State): Promise<State> {
    const resp = await fetch(`${this.provider.apiBase}/servers`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ name: planned["name"], region: planned["region"] }),
    });
    if (!resp.ok) { ctx.diagnostics.addError("create failed", await resp.text()); return planned; }
    return resp.json();
  }

  async read(_ctx: ReadContext, current: State): Promise<State | null> {
    const resp = await fetch(`${this.provider.apiBase}/servers/${current["id"]}`);
    if (resp.status === 404) return null;
    return resp.json();
  }

  async update(ctx: UpdateContext, prior: State, planned: State): Promise<State> {
    const resp = await fetch(`${this.provider.apiBase}/servers/${prior["id"]}`, {
      method: "PUT",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ name: planned["name"] }),
    });
    if (!resp.ok) { ctx.diagnostics.addError("update failed", await resp.text()); return prior; }
    return resp.json();
  }

  async delete(_ctx: DeleteContext, current: State): Promise<void> {
    await fetch(`${this.provider.apiBase}/servers/${current["id"]}`, { method: "DELETE" });
  }
}

Implement the provider

src/provider.ts
import { types, Attribute, Schema, Diagnostics } from "terrably";
import type { Provider, Resource, DataSource, ResourceClass, DataSourceClass, State } from "terrably";
import { MyCloudServer } from "./resources/server.js";

export class MyCloudProvider implements Provider {
  apiBase = "https://api.mycloud.example";

  getFullName()    { return "registry.terraform.io/myorg/mycloud"; }
  getModelPrefix() { return "mycloud"; }

  getProviderSchema(_diags: Diagnostics): Schema {
    return new Schema([
      new Attribute("api_url", types.string(), { optional: true }),
      new Attribute("token",   types.string(), { optional: true, sensitive: true }),
    ]);
  }

  validateConfig(_diags: Diagnostics, _config: State): void {}

  configure(_diags: Diagnostics, config: State): void {
    if (typeof config["api_url"] === "string") this.apiBase = config["api_url"];
  }

  getResources():   ResourceClass[]   { return [MyCloudServer]; }
  getDataSources(): DataSourceClass[] { return []; }
  newResource(cls:   ResourceClass):   Resource   { return new cls(this); }
  newDataSource(cls: DataSourceClass): DataSource { return new cls(this); }
}

Wire up the entry point

src/main.ts
import { serve } from "terrably";
import { MyCloudProvider } from "./provider.js";

serve(new MyCloudProvider());

Build and verify

pnpm run build
# → bin/terraform-provider-mycloud  (~130 MB, Node.js runtime embedded)

terrably build runs tsc --noEmit falseesbuildnode --build-sea in one step.

bun node_modules/.bin/../terrably/dist/src/cli/index.js build
# → bin/terraform-provider-mycloud  (~90 MB, Bun runtime embedded)

When invoked via Bun, terrably build runs tsc --noEmit (typecheck only) → bun build --compile in one step. Cross-compile for a specific platform with --target — pass any target string from Bun's supported targets list

# Build for the current platform
bun node_modules/terrably/dist/src/cli/index.js build

# Cross-compile for specific targets
bun node_modules/terrably/dist/src/cli/index.js build --target bun-linux-x64
bun node_modules/terrably/dist/src/cli/index.js build --target bun-darwin-arm64
bun node_modules/terrably/dist/src/cli/index.js build --target bun-windows-x64

Verify the binary starts correctly –

npx terrably check
# Runs GetProviderSchema → ValidateProviderConfig → ConfigureProvider
# and reports any schema mismatches.

Test with dev_overrides

Tell Terraform to use your local binary instead of downloading from the registry –

~/.terraformrc
provider_installation {
  dev_overrides {
    "myorg/mycloud" = "/absolute/path/to/terraform-provider-mycloud/bin"
  }
  direct {}
}

Write a minimal config and run –

main.tf
terraform {
  required_providers {
    mycloud = { source = "myorg/mycloud" }
  }
}

provider "mycloud" {
  api_url = "http://127.0.0.1:8765"
}

resource "mycloud_server" "example" {
  name   = "hello"
  region = "us-east-1"
}
terraform plan   # no terraform init needed with dev_overrides
terraform apply -auto-approve

No terraform init is needed with dev_overrides. Terraform skips the registry lookup entirely.

Next steps

Last updated on

On this page