Skip to content

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:

go build -o terraform-provider-example .

~/.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:

terraform plan
terraform apply

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/testing from 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)

References