Most teams reach for a null_resource with a local-exec curl when Terraform has no provider for their internal API. It works until it doesn’t: no drift detection, no clean delete, no import, no plan diff. The right answer is a real provider. Since HashiCorp deprecated the old SDKv2 for new development, the modern path is the terraform-plugin-framework. It is more verbose than SDKv2 but gives you a typed plan model, real null-vs-unknown semantics, and first-class plan modifiers, which is exactly what you need to stop fighting spurious diffs.
This guide builds a provider for a fictional REST API managing widget resources, takes it through acceptance tests, and publishes a signed release to the Terraform Registry. Everything here targets the framework on Terraform 1.5+ and Go 1.22+.
1. Scaffolding the provider and muxing servers
Start from HashiCorp’s terraform-provider-scaffolding-framework template, or wire it by hand. The module path must match the registry namespace you will publish under.
mkdir terraform-provider-kv && cd terraform-provider-kv
go mod init github.com/vinodh/terraform-provider-kv
go get github.com/hashicorp/terraform-plugin-framework@latest
go get github.com/hashicorp/terraform-plugin-go@latest
The binary entrypoint serves the provider over the plugin protocol. If you are migrating an existing SDKv2 provider one resource at a time, you mux the two servers together so both protocol implementations answer on the same binary. A greenfield framework-only provider does not strictly need the mux, but I wire it in from day one because partial migrations are common and the cost is a few lines.
// main.go
package main
import (
"context"
"flag"
"log"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
"github.com/hashicorp/terraform-plugin-go/tfprotov6"
"github.com/hashicorp/terraform-plugin-go/tfprotov6/tf6server"
"github.com/hashicorp/terraform-plugin-mux/tf6muxserver"
"github.com/vinodh/terraform-provider-kv/internal/provider"
)
func main() {
var debug bool
flag.BoolVar(&debug, "debug", false, "set to true to run with delve")
flag.Parse()
ctx := context.Background()
// All providers in the mux must speak protocol v6.
providers := []func() tfprotov6.ProviderServer{
providerserver.NewProtocol6(provider.New("dev")()),
}
muxServer, err := tf6muxserver.NewMuxServer(ctx, providers...)
if err != nil {
log.Fatal(err)
}
var serveOpts []tf6server.ServeOpt
if debug {
serveOpts = append(serveOpts, tf6server.WithManagedDebug())
}
err = tf6server.Serve(
"registry.terraform.io/vinodh/kv",
muxServer.ProviderServer,
serveOpts...,
)
if err != nil {
log.Fatal(err)
}
}
Protocol versions must match across a mux. The framework supports both v5 and v6; if you mux with an SDKv2 server (which is v5), upgrade it to v6 with
tf5to6server.UpgradeServerbefore adding it, or yourNewMuxServercall fails at startup.
The provider type itself implements provider.Provider. The version string is injected at build time via ldflags so the registry and terraform -version report something real.
// 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 kvProvider struct {
version string
}
type kvProviderModel struct {
Endpoint types.String `tfsdk:"endpoint"`
Token types.String `tfsdk:"token"`
}
func New(version string) func() provider.Provider {
return func() provider.Provider {
return &kvProvider{version: version}
}
}
func (p *kvProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
resp.TypeName = "kv"
resp.Version = p.version
}
func (p *kvProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"endpoint": schema.StringAttribute{
Optional: true,
Description: "Base URL of the KV API. May also be set via KV_ENDPOINT.",
},
"token": schema.StringAttribute{
Optional: true,
Sensitive: true,
Description: "Bearer token. May also be set via KV_TOKEN.",
},
},
}
}
2. Configure: wiring the API client once
Configure runs once per provider instance. Resolve config in precedence order (explicit config, then environment), validate, build a client, and hand it to resources and data sources via resp.ResourceData and resp.DataSourceData. Resources retrieve it in their own Configure method.
func (p *kvProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
var cfg kvProviderModel
resp.Diagnostics.Append(req.Config.Get(ctx, &cfg)...)
if resp.Diagnostics.HasError() {
return
}
endpoint := os.Getenv("KV_ENDPOINT")
if !cfg.Endpoint.IsNull() {
endpoint = cfg.Endpoint.ValueString()
}
token := os.Getenv("KV_TOKEN")
if !cfg.Token.IsNull() {
token = cfg.Token.ValueString()
}
if endpoint == "" {
resp.Diagnostics.AddAttributeError(
path.Root("endpoint"),
"Missing API endpoint",
"Set the endpoint argument or KV_ENDPOINT.",
)
}
if resp.Diagnostics.HasError() {
return
}
client := kvclient.New(endpoint, token)
resp.ResourceData = client
resp.DataSourceData = client
}
func (p *kvProvider) Resources(_ context.Context) []func() resource.Resource {
return []func() resource.Resource{NewWidgetResource}
}
func (p *kvProvider) DataSources(_ context.Context) []func() datasource.DataSource {
return []func() datasource.DataSource{NewWidgetDataSource}
}
3. Modeling the schema with plan modifiers
The resource schema is where most production bugs hide. Three rules:
- Attributes the API computes (IDs, timestamps) are
Computed: true. NeverRequired. - Attributes that force replacement use
RequiresReplace(). Attributes the server defaults and never changes useUseStateForUnknown()so Terraform stops showing(known after apply)on every plan. - Sensitive fields get
Sensitive: trueso they are redacted in logs and output.
// internal/provider/widget_resource.go
type widgetResourceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Region types.String `tfsdk:"region"`
Size types.Int64 `tfsdk:"size"`
Tags types.Map `tfsdk:"tags"`
CreatedAt types.String `tfsdk:"created_at"`
}
func (r *widgetResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Version: 1,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"name": schema.StringAttribute{
Required: true,
Validators: []validator.String{
stringvalidator.LengthBetween(3, 63),
},
},
"region": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"size": schema.Int64Attribute{
Optional: true,
Computed: true,
Validators: []validator.Int64{
int64validator.Between(1, 1024),
},
},
"tags": schema.MapAttribute{
Optional: true,
ElementType: types.StringType,
},
"created_at": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
},
}
}
Optional: true, Computed: truetogether means “user may set it, but if they don’t the server picks a value.” This is the correct pattern for server-defaulted fields. WithoutComputed, Terraform treats an omitted optional as an explicit null and may try to send null to your API.
For nested objects, prefer the typed schema.SingleNestedAttribute and schema.ListNestedAttribute over the legacy block syntax. Attributes give you cleaner null handling and work better with the typed model.
4. CRUD against the API client
Each lifecycle method has a strict contract for what it reads and writes. The pattern below is identical for every resource; only the client calls change.
func (r *widgetResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan widgetResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
created, err := r.client.CreateWidget(ctx, kvclient.WidgetInput{
Name: plan.Name.ValueString(),
Region: plan.Region.ValueString(),
Size: plan.Size.ValueInt64(),
})
if err != nil {
resp.Diagnostics.AddError("Create widget failed", err.Error())
return
}
// Write every computed value back so state is complete.
plan.ID = types.StringValue(created.ID)
plan.Size = types.Int64Value(created.Size)
plan.CreatedAt = types.StringValue(created.CreatedAt)
resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}
func (r *widgetResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state widgetResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
w, err := r.client.GetWidget(ctx, state.ID.ValueString())
if errors.Is(err, kvclient.ErrNotFound) {
// Resource is gone. Remove from state so Terraform plans a recreate.
resp.State.RemoveResource(ctx)
return
}
if err != nil {
resp.Diagnostics.AddError("Read widget failed", err.Error())
return
}
state.Name = types.StringValue(w.Name)
state.Region = types.StringValue(w.Region)
state.Size = types.Int64Value(w.Size)
state.CreatedAt = types.StringValue(w.CreatedAt)
resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
}
The single most common provider bug is mishandling a 404 in Read. If the upstream object was deleted out of band, you must call resp.State.RemoveResource(ctx) and return without an error. Returning an error instead leaves Terraform unable to recover, and the user is stuck running state rm by hand.
Update reads both plan and state (state for the ID, plan for the desired values), and Delete is forgiving of an already-absent object.
func (r *widgetResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan, state widgetResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
updated, err := r.client.UpdateWidget(ctx, state.ID.ValueString(), kvclient.WidgetInput{
Name: plan.Name.ValueString(),
Size: plan.Size.ValueInt64(),
})
if err != nil {
resp.Diagnostics.AddError("Update widget failed", err.Error())
return
}
plan.ID = state.ID
plan.Size = types.Int64Value(updated.Size)
plan.CreatedAt = state.CreatedAt
resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}
func (r *widgetResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state widgetResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
err := r.client.DeleteWidget(ctx, state.ID.ValueString())
if err != nil && !errors.Is(err, kvclient.ErrNotFound) {
resp.Diagnostics.AddError("Delete widget failed", err.Error())
}
}
The resource grabs the client in its own Configure, guarding against the nil that occurs during schema validation when no provider data is set yet.
func (r *widgetResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*kvclient.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected provider data type",
fmt.Sprintf("Expected *kvclient.Client, got %T", req.ProviderData))
return
}
r.client = client
}
5. Import, state upgraders, and schema migrations
Import lets users adopt existing infrastructure. The simplest form maps the import ID straight onto the id attribute; the next Read fills in the rest.
func (r *widgetResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
When you change the shape of your schema in an incompatible way (rename an attribute, change a type), bump Version in the schema and add an UpgradeState implementation. Terraform calls the matching upgrader to rewrite old state into the new shape, so existing users do not see a destroy/recreate.
func (r *widgetResource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader {
return map[int64]resource.StateUpgrader{
0: {
// Prior schema where "size" was a string.
PriorSchema: &schema.Schema{
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{Computed: true},
"size": schema.StringAttribute{Optional: true},
// ... remaining v0 attributes ...
},
},
StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
var old widgetModelV0
resp.Diagnostics.Append(req.State.Get(ctx, &old)...)
if resp.Diagnostics.HasError() {
return
}
n, _ := strconv.ParseInt(old.Size.ValueString(), 10, 64)
resp.Diagnostics.Append(resp.State.Set(ctx, widgetResourceModel{
ID: old.ID,
Size: types.Int64Value(n),
})...)
},
},
}
}
6. Data sources, validators, and semantic equality for noisy fields
Data sources implement only Read. They share the client wiring but never mutate anything. Beyond the built-in validators shown earlier, you can attach ConfigValidators at the resource level for cross-attribute rules (for example, “exactly one of A or B”), using helpers from terraform-plugin-framework-validators.
The harder problem is noisy API fields: JSON the server reformats, policy documents it reorders, or values it normalizes. These cause perpetual diffs because the string Terraform stored differs byte-for-byte from what comes back. The framework solves this with semantic equality: implement a custom type whose StringSemanticEquals compares meaning rather than bytes.
// A custom string type that treats semantically-equal JSON as unchanged.
func (v jsonValue) StringSemanticEquals(ctx context.Context, newValuable basetypes.StringValuable) (bool, diag.Diagnostics) {
var diags diag.Diagnostics
newVal, ok := newValuable.(jsonValue)
if !ok {
return false, diags
}
var a, b any
if err := json.Unmarshal([]byte(v.ValueString()), &a); err != nil {
return false, diags
}
if err := json.Unmarshal([]byte(newVal.ValueString()), &b); err != nil {
return false, diags
}
return reflect.DeepEqual(a, b), diags
}
When Terraform sees the prior and new values are semantically equal, it keeps the prior value in state and suppresses the diff, with no DiffSuppressFunc hack and no normalization gymnastics in every CRUD method.
7. Acceptance tests with terraform-plugin-testing
Unit tests cover client logic. The provider contract, that an apply followed by a plan is empty, that delete actually deletes, gets covered by acceptance tests using terraform-plugin-testing. These run real Terraform against a real (or test) API and are gated behind the TF_ACC environment variable so they never run during a plain go test.
func TestAccWidgetResource(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
CheckDestroy: testAccCheckWidgetDestroy,
Steps: []resource.TestStep{
{ // Create and read.
Config: testAccWidgetConfig("alpha", 4),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("kv_widget.test", "name", "alpha"),
resource.TestCheckResourceAttr("kv_widget.test", "size", "4"),
resource.TestCheckResourceAttrSet("kv_widget.test", "id"),
),
},
{ // ImportState round-trip.
ResourceName: "kv_widget.test",
ImportState: true,
ImportStateVerify: true,
},
{ // Update in place.
Config: testAccWidgetConfig("alpha", 8),
Check: resource.TestCheckResourceAttr("kv_widget.test", "size", "8"),
},
},
})
}
CheckDestroy is non-negotiable: after the test case tears down, it asserts the object is actually gone upstream. Without it, a broken Delete passes silently and leaks resources.
func testAccCheckWidgetDestroy(s *terraform.State) error {
c := testAccClient()
for _, rs := range s.RootModule().Resources {
if rs.Type != "kv_widget" {
continue
}
_, err := c.GetWidget(context.Background(), rs.Primary.ID)
if err == nil {
return fmt.Errorf("widget %s still exists", rs.Primary.ID)
}
if !errors.Is(err, kvclient.ErrNotFound) {
return err
}
}
return nil
}
The provider factory hands the framework provider to the test harness over protocol v6:
var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
"kv": providerserver.NewProtocol6WithError(provider.New("test")()),
}
Run them explicitly. The -run filter and timeout matter because acceptance tests create real infrastructure and can be slow.
TF_ACC=1 KV_ENDPOINT=http://localhost:8080 KV_TOKEN=test \
go test ./internal/provider/ -run TestAccWidget -v -timeout 30m
8. Docs and signed releases to the Registry
The Registry renders documentation from Markdown under docs/. Generate it from your schemas and example configs with tfplugindocs so the docs never drift from the code.
go install github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs@latest
tfplugindocs generate --provider-name kv
Publishing requires a GitHub release with cross-compiled binaries, a SHA256SUMS file, and a GPG signature over those sums. GoReleaser is the standard tool, and HashiCorp ships a .goreleaser.yml in the scaffolding template. The critical pieces: a version ldflag, CGO_ENABLED=0, the checksum block, and a signs block that calls gpg --detach-sign.
# .goreleaser.yml (key sections)
builds:
- env: ["CGO_ENABLED=0"]
flags: ["-trimpath"]
ldflags: ["-s -w -X main.version={{.Version}}"]
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
checksum:
name_template: "{{ .ProjectName }}_{{ .Version }}_SHA256SUMS"
algorithm: sha256
signs:
- artifacts: checksum
args: ["--batch", "--local-user", "{{ .Env.GPG_FINGERPRINT }}", "--output", "${signature}", "--detach-sign", "${artifact}"]
Tag with a semver v prefix and let CI run GoReleaser. Then add the provider in the Registry UI, upload the public half of the signing key, and the Registry verifies each release’s signature against it.
git tag -a v0.1.0 -m "v0.1.0"
git push origin v0.1.0
The Registry will reject a release whose
SHA256SUMSsignature does not verify against the uploaded public key. Keep the private key in your CI secret store (GPG_PRIVATE_KEY,GPG_FINGERPRINT) and never in the repo.
9. Debugging: TF_LOG, delve, and the reattach protocol
Three levels of visibility, from cheapest to deepest.
Structured logs first. The framework uses tflog, so your own tflog.Debug(ctx, "creating widget", map[string]any{"name": name}) calls surface under the right log level. Split provider logs from core logs to cut the noise.
export TF_LOG=DEBUG
export TF_LOG_PROVIDER=TRACE # provider-only, more verbose
export TF_LOG_PATH=./tf.log
terraform apply
For a real debugger, run the provider under delve in headless mode. The -debug flag we wired into main.go makes the framework print a TF_REATTACH_PROVIDERS value on startup; exporting it tells Terraform to talk to your already-running, breakpointed process instead of launching its own copy.
# Terminal 1: launch the provider under delve, headless.
dlv debug ./... --headless --listen=:2345 --api-version=2 -- -debug
Connect your editor to :2345, set breakpoints in Create/Read, copy the printed TF_REATTACH_PROVIDERS JSON, and in another terminal:
export TF_REATTACH_PROVIDERS='{"registry.terraform.io/vinodh/kv":{...}}'
terraform apply
Terraform now routes every RPC into your debugger. This is the fastest way to understand why a plan shows an unexpected diff: set a breakpoint, inspect the plan and state models side by side, and watch which attribute the framework marks as unknown.
Verify
Confirm the provider is correct before you ship it. A green acceptance run plus these checks catches the issues that bite users in production.
# 1. Builds clean and embeds a version.
go build -ldflags "-X main.version=0.1.0" -o terraform-provider-kv .
# 2. Unit + acceptance tests pass; CheckDestroy proves clean teardown.
go test ./... -v
TF_ACC=1 go test ./internal/provider/ -run TestAcc -v -timeout 30m
# 3. Docs regenerate with no diff (CI should fail if they do).
tfplugindocs generate --provider-name kv && git diff --exit-code docs/
The behavioral check that matters most: a second plan after apply must be empty. Use a local dev_overrides block in a CLI config file to point Terraform at your locally built binary, then apply and re-plan.
# ~/.terraformrc
provider_installation {
dev_overrides {
"vinodh/kv" = "/abs/path/to/terraform-provider-kv-dir"
}
direct {}
}
terraform apply -auto-approve
terraform plan -detailed-exitcode # exit code 0 == no diff. Anything else is a bug.
If that second plan is not empty, you have a computed attribute you forgot to set in Create, or a normalization mismatch that needs semantic equality. Fix it before the registry release, because every user will hit it on every plan.