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"orterraform_datawith local-exec will do the job. - A
null_resourcewith 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 Framework | Plugin SDK v2 | |
|---|---|---|
| Year | 2022+ | 2014+ |
| Status | Active development | Maintenance only |
| Type-safety | Better | Weaker |
| Schema | Declarative | Map-based |
| Recommended | Yes, for new providers | Legacy 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
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
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
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:
provider_installation { dev_overrides {"example/mysite" = "/home/student/go/bin"
}
direct {}}
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:
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:
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 withtfplugindocs. - A
LICENSEfile (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 (
movedorremoved). Design the schema carefully from the start. -
Readmatters. 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.
Createcalled twice must not produce a duplicate. If the API is not idempotent, add aRead-before-Createfallback. -
Sensitive fields. Any attribute that holds a secret needs
Sensitive: truein 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.
tfplugindocsgenerates 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 planandapply. Set up a mock server or a sandbox environment for CI.