Getting started
Scaffold, implement, build, and test your first Terraform provider in TypeScript in under 10 minutes.
Prerequisites
terrably supports two runtimes.
| Runtime | Version | Notes |
|---|---|---|
| Node.js | ≥ 25.5.0 | Uses node --build-sea; requires a native runner per platform for CI |
| Bun | ≥ 0.6.0 | Uses 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 installThe scaffold creates a fully-wired starter project –
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".
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
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
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 false → esbuild → node --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-x64Verify 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 –
provider_installation {
dev_overrides {
"myorg/mycloud" = "/absolute/path/to/terraform-provider-mycloud/bin"
}
direct {}
}Write a minimal config and run –
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-approveNo terraform init is needed with dev_overrides. Terraform skips the registry lookup entirely.
Next steps
- Core concepts — deep dive into types, attributes, and schema
- Development workflow — dev mode, debugger, unit tests
- Distribution — multi-platform CI and Terraform Registry publishing
Last updated on