Building a Terraform Provider with Go¶
What Is a Terraform Provider?¶
A Terraform provider is a plugin that lets Terraform manage resources on some API or service. It translates Terraform config (HCL) into CRUD operations against that API. You write it in Go using Hashicorp's Plugin Framework (the modern approach, replacing the older SDKv2).
Prerequisites¶
- Go 1.21+
- Terraform CLI installed
- Basic familiarity with Go structs, interfaces, and context
The Core Architecture¶
A provider consists of these building blocks:
Provider
├── providerData (config: API URL, credentials, etc.)
├── Resources (things that have lifecycle: create/read/update/delete)
└── DataSources (read-only lookups)
Each Resource implements a Go interface with Create, Read, Update, Delete (and Schema for its attributes).
Step 1 — Project Setup¶
mkdir terraform-provider-example
cd terraform-provider-example
go mod init terraform-provider-example
go get github.com/hashicorp/terraform-plugin-framework
Your directory structure will look like:
terraform-provider-example/
├── main.go
├── internal/
│ └── provider/
│ ├── provider.go
│ └── resource_order.go
Step 2 — main.go (the entrypoint)¶
package main
import (
"context"
"log"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
"terraform-provider-example/internal/provider"
)
func main() {
err := providerserver.Serve(context.Background(), provider.New, providerserver.ServeOpts{
Address: "registry.terraform.io/example/example",
})
if err != nil {
log.Fatal(err)
}
}
The Address is what goes in the Terraform required_providers block.
Step 3 — provider.go (the provider itself)¶
package provider
import (
"context"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/provider"
"github.com/hashicorp/terraform-plugin-framework/provider/schema"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var _ provider.Provider = &ExampleProvider{}
type ExampleProvider struct{}
// providerData is passed down to resources/datasources after Configure().
type providerData struct {
APIKey string
BaseURL string
}
func New() provider.Provider {
return &ExampleProvider{}
}
func (p *ExampleProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
resp.TypeName = "example"
}
// Schema defines what goes in the provider {} block in HCL.
func (p *ExampleProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"api_key": schema.StringAttribute{
Required: true,
Sensitive: true,
},
"base_url": schema.StringAttribute{
Optional: true,
},
},
}
}
// Configure reads provider-level config and stores it for resources to use.
func (p *ExampleProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
var config struct {
APIKey types.String `tfsdk:"api_key"`
BaseURL types.String `tfsdk:"base_url"`
}
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
pd := &providerData{
APIKey: config.APIKey.ValueString(),
BaseURL: config.BaseURL.ValueString(),
}
resp.ResourceData = pd
resp.DataSourceData = pd
}
func (p *ExampleProvider) Resources(_ context.Context) []func() resource.Resource {
return []func() resource.Resource{
NewOrderResource,
}
}
func (p *ExampleProvider) DataSources(_ context.Context) []func() datasource.DataSource {
return nil
}
Step 4 — resource_order.go (a resource)¶
package provider
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var _ resource.Resource = &OrderResource{}
type OrderResource struct {
pd *providerData
}
// orderModel mirrors the Terraform schema as a Go struct.
type orderModel struct {
ID types.String `tfsdk:"id"`
Item types.String `tfsdk:"item"`
Quantity types.Int64 `tfsdk:"quantity"`
}
func NewOrderResource() resource.Resource {
return &OrderResource{}
}
func (r *OrderResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_order" // => "example_order"
}
func (r *OrderResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"item": schema.StringAttribute{
Required: true,
},
"quantity": schema.Int64Attribute{
Required: true,
},
},
}
}
// Configure receives providerData from the provider's Configure().
func (r *OrderResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
pd, ok := req.ProviderData.(*providerData)
if !ok {
resp.Diagnostics.AddError("Unexpected provider data type", fmt.Sprintf("got %T", req.ProviderData))
return
}
r.pd = pd
}
func (r *OrderResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan orderModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
// --- Call your API here using r.pd.APIKey / r.pd.BaseURL ---
plan.ID = types.StringValue("order-abc-123")
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *OrderResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state orderModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
// --- Fetch from API, update state if resource was modified externally ---
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *OrderResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan orderModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
// --- Call update API ---
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *OrderResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state orderModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
// --- Call delete API using state.ID ---
// Terraform removes from state automatically after this returns.
}
Step 5 — Testing it locally¶
Build and drop the binary into the Terraform plugin cache:
~/.terraformrc — point Terraform to your local binary:
provider_installation {
dev_overrides {
"registry.terraform.io/example/example" = "/path/to/your/binary/dir"
}
direct {}
}
Sample main.tf:
terraform {
required_providers {
example = {
source = "registry.terraform.io/example/example"
}
}
}
provider "example" {
api_key = "test-key"
}
resource "example_order" "my_order" {
item = "widget"
quantity = 5
}
Then:
Key Concepts¶
| Concept | What it means |
|---|---|
tfsdk struct tags |
Map Go struct fields to Terraform schema attributes |
types.String vs string |
Terraform types handle null/unknown; always use them in models |
Diagnostics |
Terraform's error/warning system — never panic, always append errors |
Computed |
Attribute set by the provider (e.g. ID from API), not by user |
PlanModifier |
Controls how Terraform diffs; UseStateForUnknown prevents unnecessary recreation |
resp.State.Set |
Persists resource state after CRUD operations |
What to Build Next¶
- Acceptance tests using
resource/testingfrom the framework - Import support by implementing
resource.ResourceWithImportState - Data sources — same pattern as resources but only implement
Read - Publishing to the Terraform Registry (requires signing your binary with GPG)