Go for DevOps: Writing Terraform Providers

go devops

Terraform providers let you manage any API as infrastructure. When the provider you need doesn’t exist, you can write your own in Go. Here’s how.

Why Write a Provider

Provider Architecture

Terraform Core ←→ Provider (Go binary) ←→ API

Provider responsibilities:
├── Resource CRUD operations
├── Data source reads
├── Schema definition
└── State management

Setup

Dependencies

# Initialize module
go mod init terraform-provider-myservice

# Add dependencies
go get github.com/hashicorp/terraform-plugin-framework

Project Structure

terraform-provider-myservice/
├── main.go
├── internal/
│   └── provider/
│       ├── provider.go
│       ├── resource_item.go
│       └── data_source_item.go
├── go.mod
└── go.sum

The Provider

// internal/provider/provider.go
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"
)

type MyServiceProvider struct {
    version string
}

type MyServiceProviderModel struct {
    Endpoint types.String `tfsdk:"endpoint"`
    ApiKey   types.String `tfsdk:"api_key"`
}

func (p *MyServiceProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) {
    resp.TypeName = "myservice"
    resp.Version = p.version
}

func (p *MyServiceProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) {
    resp.Schema = schema.Schema{
        Attributes: map[string]schema.Attribute{
            "endpoint": schema.StringAttribute{
                Required:    true,
                Description: "The API endpoint",
            },
            "api_key": schema.StringAttribute{
                Required:    true,
                Sensitive:   true,
                Description: "API key for authentication",
            },
        },
    }
}

func (p *MyServiceProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
    var config MyServiceProviderModel
    resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
    
    if resp.Diagnostics.HasError() {
        return
    }
    
    // Create API client
    client := NewAPIClient(config.Endpoint.ValueString(), config.ApiKey.ValueString())
    resp.DataSourceData = client
    resp.ResourceData = client
}

func (p *MyServiceProvider) Resources(ctx context.Context) []func() resource.Resource {
    return []func() resource.Resource{
        NewItemResource,
    }
}

func (p *MyServiceProvider) DataSources(ctx context.Context) []func() datasource.DataSource {
    return []func() datasource.DataSource{
        NewItemDataSource,
    }
}

func New(version string) func() provider.Provider {
    return func() provider.Provider {
        return &MyServiceProvider{version: version}
    }
}

A Resource

// internal/provider/resource_item.go
package provider

import (
    "context"
    "github.com/hashicorp/terraform-plugin-framework/resource"
    "github.com/hashicorp/terraform-plugin-framework/resource/schema"
    "github.com/hashicorp/terraform-plugin-framework/types"
)

type ItemResource struct {
    client *APIClient
}

type ItemResourceModel struct {
    ID          types.String `tfsdk:"id"`
    Name        types.String `tfsdk:"name"`
    Description types.String `tfsdk:"description"`
}

func NewItemResource() resource.Resource {
    return &ItemResource{}
}

func (r *ItemResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
    resp.TypeName = req.ProviderTypeName + "_item"
}

func (r *ItemResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
    resp.Schema = schema.Schema{
        Attributes: map[string]schema.Attribute{
            "id": schema.StringAttribute{
                Computed: true,
            },
            "name": schema.StringAttribute{
                Required: true,
            },
            "description": schema.StringAttribute{
                Optional: true,
            },
        },
    }
}

func (r *ItemResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
    if req.ProviderData == nil {
        return
    }
    r.client = req.ProviderData.(*APIClient)
}

func (r *ItemResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
    var plan ItemResourceModel
    resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
    
    if resp.Diagnostics.HasError() {
        return
    }
    
    // Call API
    item, err := r.client.CreateItem(plan.Name.ValueString(), plan.Description.ValueString())
    if err != nil {
        resp.Diagnostics.AddError("Create failed", err.Error())
        return
    }
    
    // Set state
    plan.ID = types.StringValue(item.ID)
    resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}

func (r *ItemResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state ItemResourceModel
    resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
    
    item, err := r.client.GetItem(state.ID.ValueString())
    if err != nil {
        resp.Diagnostics.AddError("Read failed", err.Error())
        return
    }
    
    state.Name = types.StringValue(item.Name)
    state.Description = types.StringValue(item.Description)
    resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
}

func (r *ItemResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
    var plan ItemResourceModel
    resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
    
    _, err := r.client.UpdateItem(plan.ID.ValueString(), plan.Name.ValueString(), plan.Description.ValueString())
    if err != nil {
        resp.Diagnostics.AddError("Update failed", err.Error())
        return
    }
    
    resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}

func (r *ItemResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
    var state ItemResourceModel
    resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
    
    err := r.client.DeleteItem(state.ID.ValueString())
    if err != nil {
        resp.Diagnostics.AddError("Delete failed", err.Error())
        return
    }
}

Main Entry Point

// main.go
package main

import (
    "context"
    "flag"
    "log"
    
    "github.com/hashicorp/terraform-plugin-framework/providerserver"
    "terraform-provider-myservice/internal/provider"
)

var version = "dev"

func main() {
    var debug bool
    flag.BoolVar(&debug, "debug", false, "set to true to enable debug mode")
    flag.Parse()
    
    opts := providerserver.ServeOpts{
        Address: "registry.terraform.io/myorg/myservice",
        Debug:   debug,
    }
    
    err := providerserver.Serve(context.Background(), provider.New(version), opts)
    if err != nil {
        log.Fatal(err)
    }
}

Building and Installing

# Build
go build -o terraform-provider-myservice

# Install locally (macOS)
mkdir -p ~/.terraform.d/plugins/registry.terraform.io/myorg/myservice/1.0.0/darwin_amd64
mv terraform-provider-myservice ~/.terraform.d/plugins/registry.terraform.io/myorg/myservice/1.0.0/darwin_amd64/

Using the Provider

# main.tf
terraform {
  required_providers {
    myservice = {
      source  = "myorg/myservice"
      version = "~> 1.0"
    }
  }
}

provider "myservice" {
  endpoint = "https://api.myservice.com"
  api_key  = var.myservice_api_key
}

resource "myservice_item" "example" {
  name        = "my-item"
  description = "An example item"
}

Testing

// internal/provider/resource_item_test.go
package provider

import (
    "testing"
    "github.com/hashicorp/terraform-plugin-testing/helper/resource"
)

func TestAccItemResource(t *testing.T) {
    resource.Test(t, resource.TestCase{
        ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
        Steps: []resource.TestStep{
            {
                Config: `
                    provider "myservice" {
                        endpoint = "https://test.local"
                        api_key  = "test"
                    }
                    
                    resource "myservice_item" "test" {
                        name = "test-item"
                    }
                `,
                Check: resource.ComposeAggregateTestCheckFunc(
                    resource.TestCheckResourceAttr("myservice_item.test", "name", "test-item"),
                    resource.TestCheckResourceAttrSet("myservice_item.test", "id"),
                ),
            },
        },
    })
}

Publishing

# Build for multiple platforms
goreleaser release --rm-dist

# Register with Terraform Registry
# 1. Create GitHub release with binaries
# 2. Sign with GPG key
# 3. Add to registry via GitHub OAuth

Best Practices

  1. Idempotent operations: Create should be safe to retry
  2. Handle drift: Read should update state from reality
  3. Sensitive data: Mark sensitive attributes
  4. Timeouts: Use context for cancellation
  5. Logging: Use tflog for debugging

Final Thoughts

Writing Terraform providers is straightforward with the plugin framework. If you have an internal service or custom API, a provider brings it into your IaC workflow.

The Go tooling ecosystem makes building, testing, and distributing providers reliable.


Everything as code, including your custom APIs.

All posts