linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
Intro
Lessons
Footer
linuxlab-TutorialsPricingAboutPrivacy & cookies
Copyright © 2026 LinuxLab. All rights reserved.
linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
  • Introduction
  • Lessons
  • How it works
  • Knowledge base
  • Cheat sheet
  • Capstone
  • Interview prep
home/terraform/kb/Advanced/tf-provider-development

kb/advanced ── Advanced ── advanced

Writing a Terraform Provider with Plugin Framework

Build a custom provider in Go using terraform-plugin-framework. The model: Provider -> Resource (CRUD) or DataSource (R). Each resource needs a Schema plus Create/Read/Update/Delete methods. Terraform Core talks to the provider over gRPC. Deploy with `go install` and dev_overrides, or publish to the Terraform Registry. Write a custom provider when no existing provider covers your API.

view as markdownaka: terraform-custom-provider, terraform-plugin-framework, hashicorp-plugin-sdk

When to write a custom provider

Ask yourself whether you actually need one before starting.

Write one when:

  • You have a private API (an internal SaaS, a company-specific platform).
  • The existing community provider is abandoned.
  • You need custom diff logic that no existing provider exposes.

Skip it when:

  • The REST API is simple and used in a single project. data "http" or terraform_data with local-exec will do the job.
  • A null_resource with a CLI call is good enough for now.

A custom provider is a long-term commitment: versioning, documentation, community issues. Think twice.

Plugin Framework vs Plugin SDK v2

Two paths:

Plugin FrameworkPlugin SDK v2
Year2022+2014+
StatusActive developmentMaintenance only
Type-safetyBetterWeaker
SchemaDeclarativeMap-based
RecommendedYes, for new providersLegacy migration only

Use Plugin Framework for any new provider. SDK v2 is old, but many large providers (including hashicorp/aws) still run on it.

Minimal provider

main.go

go
package main
import (
    "context"
    "log"
    "github.com/hashicorp/terraform-plugin-framework/providerserver"
    "github.com/example/terraform-provider-mysite/internal/provider"
)
func main() {
    err := providerserver.Serve(context.Background(),
        provider.New("dev"),
        providerserver.ServeOpts{Address: "registry.terraform.io/example/mysite"},
    )
    if err != nil {
        log.Fatal(err.Error())
    }
}

internal/provider/provider.go

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"
)
type MySiteProvider struct {
    version string
}
func New(version string) func() provider.Provider {
    return func() provider.Provider {
        return &MySiteProvider{version: version}
    }
}
func (p *MySiteProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
    resp.TypeName = "mysite"
    resp.Version = p.version
}
func (p *MySiteProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
    resp.Schema = schema.Schema{
        Attributes: map[string]schema.Attribute{
            "api_token": schema.StringAttribute{
                Required:  true,
                Sensitive: true,
            },
            "endpoint": schema.StringAttribute{
                Optional: true,
            },
        },
    }
}
func (p *MySiteProvider) Configure(_ context.Context, _ provider.ConfigureRequest, _ *provider.ConfigureResponse) {
    // parse config, initialize HTTP client
}
func (p *MySiteProvider) Resources(_ context.Context) []func() resource.Resource {
    return []func() resource.Resource{
        NewPostResource,
    }
}
func (p *MySiteProvider) DataSources(_ context.Context) []func() datasource.DataSource {
    return nil
}

internal/provider/post_resource.go

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 PostResource struct {
    // HTTP client for the API
}
func NewPostResource() resource.Resource {
    return &PostResource{}
}
type PostResourceModel struct {
    ID    types.String `tfsdk:"id"`
    Title types.String `tfsdk:"title"`
    Body  types.String `tfsdk:"body"`
}
func (r *PostResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
    resp.TypeName = req.ProviderTypeName + "_post"
}
func (r *PostResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
    resp.Schema = schema.Schema{
        Attributes: map[string]schema.Attribute{
            "id":    schema.StringAttribute{Computed: true},
            "title": schema.StringAttribute{Required: true},
            "body":  schema.StringAttribute{Required: true},
        },
    }
}
func (r *PostResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
    var plan PostResourceModel
    resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
    if resp.Diagnostics.HasError() {
        return
    }
    // POST /posts
    // plan.ID = API response ID
    plan.ID = types.StringValue("mocked-id-123")
    resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}
func (r *PostResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    // GET /posts/{id}, refresh state
}
func (r *PostResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
    // PUT /posts/{id}
}
func (r *PostResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
    // DELETE /posts/{id}
}

Local development with dev_overrides

You do not want to push to the Registry on every iteration. Put this in ~/.terraformrc:

hcl
provider_installation {
  dev_overrides {
    "example/mysite" = "/home/student/go/bin"
  }
  direct {}
}
bash
go install .
cd /path/to/test-config
terraform plan  # picks up the binary from ~/go/bin

Terraform checks the dev_override, finds the local binary, and uses it instead of downloading from the Registry. The inner loop becomes: compile, plan, repeat.

Tests

Terraform provides terraform-plugin-testing:

go
package provider_test
import (
    "testing"
    "github.com/hashicorp/terraform-plugin-testing/helper/resource"
)
func TestAccPostResource_basic(t *testing.T) {
    resource.Test(t, resource.TestCase{
        PreCheck:                 func() { testAccPreCheck(t) },
        ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
        Steps: []resource.TestStep{
            {
                Config: testAccPostResourceConfig("title-1", "body-1"),
                Check: resource.ComposeAggregateTestCheckFunc(
                    resource.TestCheckResourceAttr("mysite_post.test", "title", "title-1"),
                ),
            },
        },
    })
}

Run with:

bash
TF_ACC=1 go test ./... -v

TF_ACC=1 is required. Without it, acceptance tests are skipped silently.

Publishing to the Registry

Terraform Registry indexes any repository named terraform-provider-X under your org or user account. Requirements:

  • A GitHub release with correctly signed ZIPs (Goreleaser).
  • A docs/ directory generated with tfplugindocs.
  • A LICENSE file (MPL-2.0 or MIT).

The full setup is its own topic. HashiCorp publishes a detailed walkthrough in their documentation.

Pitfalls

  • Schema changes are breaking. Removing a required field means every user needs an upgrade path (moved or removed). Design the schema carefully from the start.

  • Read matters. Skip it and drift detection breaks: Terraform cannot see resources that have disappeared from state. This is the most common bug in custom providers.

  • Idempotency is your problem. Create called twice must not produce a duplicate. If the API is not idempotent, add a Read-before-Create fallback.

  • Sensitive fields. Any attribute that holds a secret needs Sensitive: true in the schema. See tf-sensitive.

  • gRPC protocol versions. Plugin Framework uses protocol 6. Older providers use protocol 5. Mixed-mode is possible, but protocol 5 is heading for deprecation.

  • Versioning. Semver is mandatory. Breaking changes require a major bump. Any schema change without backward compatibility is a major bump.

  • Documentation needs good source material. tfplugindocs generates markdown from your Schema descriptions. Write useful descriptions or the generated docs will be worthless.

  • Acceptance tests need a real API. They actually call terraform plan and apply. Set up a mock server or a sandbox environment for CI.

§ команды

bash
go install .

Compile the provider into $GOPATH/bin. Uses dev_overrides from .terraformrc.

bash
TF_ACC=1 go test ./... -v

Run acceptance tests. They execute real terraform plan and apply calls.

bash
tfplugindocs generate

Generate the docs/ directory for the Registry from Schema descriptions.

bash
goreleaser release --clean

Build release ZIPs for all platforms and sign them with GPG. Required for Registry publishing.

Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies