Terraform Provider Development: Building Custom Providers for Internal Services

When Built-in Providers Aren't Enough

Terraform's provider ecosystem covers most cloud services and popular SaaS products. But every organization eventually builds something internal — a deployment platform, a feature flag system, a custom DNS service, a certificate authority — that doesn't have a Terraform provider. At that point, you either manage it manually or build a provider yourself.

I've built three custom providers. The first was terrible, the second was acceptable, and the third was actually good. Here's what I learned across all three, condensed into something that might save you the iteration cycle I went through.

Provider Architecture Fundamentals

A Terraform provider is a Go binary that communicates with Terraform core through gRPC using the Terraform Plugin Framework (or the older Plugin SDK v2). The provider defines resources and data sources, each with a schema and CRUD operations. When someone runs terraform apply, Terraform calls the appropriate CRUD function for each resource that needs to change.

The Plugin Framework is the newer approach and what you should use for new providers. The Plugin SDK v2 is still supported but no longer receives new features. If you see tutorials using schema.Resource and schema.Schema, that's the SDK v2 pattern. The Framework uses resource.Resource interface implementations instead.

// Provider definition using Plugin Framework
package provider

import (
    "context"
    "github.com/hashicorp/terraform-plugin-framework/datasource"
    "github.com/hashicorp/terraform-plugin-framework/provider"
    "github.com/hashicorp/terraform-plugin-framework/resource"
)

type InternalPlatformProvider struct {
    version string
}

func (p *InternalPlatformProvider) Metadata(_ context.Context,
    _ provider.MetadataRequest, resp *provider.MetadataResponse) {
    resp.TypeName = "internal"
    resp.Version = p.version
}

func (p *InternalPlatformProvider) Schema(_ context.Context,
    _ provider.SchemaRequest, resp *provider.SchemaResponse) {
    resp.Schema = schema.Schema{
        Attributes: map[string]schema.Attribute{
            "api_endpoint": schema.StringAttribute{
                Required:    true,
                Description: "Base URL of the internal platform API",
            },
            "api_token": schema.StringAttribute{
                Required:    true,
                Sensitive:   true,
                Description: "Authentication token",
            },
        },
    }
}

Resource Implementation Pattern

Each resource needs five operations: Create, Read, Update, Delete, and ImportState. The temptation is to implement Create first and worry about the others later. Don't. Implement Read first. Read is the foundation — Create and Update both call Read at the end to refresh state, and a broken Read means Terraform can't reconcile planned changes with actual state.

type FeatureFlagResource struct {
    client *platformclient.Client
}

func (r *FeatureFlagResource) Read(ctx context.Context,
    req resource.ReadRequest, resp *resource.ReadResponse) {
    var state FeatureFlagModel
    diags := req.State.Get(ctx, &state)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }

    flag, err := r.client.GetFeatureFlag(ctx, state.ID.ValueString())
    if err != nil {
        if platformclient.IsNotFound(err) {
            resp.State.RemoveResource(ctx)
            return
        }
        resp.Diagnostics.AddError("Read failed",
            fmt.Sprintf("Could not read feature flag %s: %s",
                state.ID.ValueString(), err))
        return
    }

    state.Name = types.StringValue(flag.Name)
    state.Enabled = types.BoolValue(flag.Enabled)
    state.Description = types.StringValue(flag.Description)
    state.UpdatedAt = types.StringValue(flag.UpdatedAt.Format(time.RFC3339))

    diags = resp.State.Set(ctx, &state)
    resp.Diagnostics.Append(diags...)
}

Notice the IsNotFound check. If the resource was deleted outside of Terraform (someone used the API directly), Read should remove it from state rather than returning an error. This tells Terraform "this resource no longer exists" and the next plan will show it needs to be recreated. Returning an error instead would leave Terraform stuck — unable to plan because it can't read the resource, unable to destroy because there's nothing to destroy.

Handling Eventual Consistency

If your internal API is eventually consistent — and many microservice-backed APIs are — Create might return success before the resource is fully available. The subsequent Read call (which Create should always trigger) might not find the resource yet.

The pragmatic solution is a retry loop in Read with a short timeout:

func (r *FeatureFlagResource) Create(ctx context.Context,
    req resource.CreateRequest, resp *resource.CreateResponse) {
    // ... create logic ...

    created, err := r.client.CreateFeatureFlag(ctx, createReq)
    if err != nil {
        resp.Diagnostics.AddError("Create failed", err.Error())
        return
    }

    state.ID = types.StringValue(created.ID)

    // Wait for eventual consistency before reading back
    err = retry.Do(func() error {
        _, readErr := r.client.GetFeatureFlag(ctx, created.ID)
        return readErr
    }, retry.Attempts(5), retry.Delay(500*time.Millisecond))

    if err != nil {
        resp.Diagnostics.AddWarning("Consistency delay",
            "Resource created but read-back timed out")
    }

    // Read the final state
    r.readIntoState(ctx, created.ID, &state)
    resp.State.Set(ctx, &state)
}

Testing Your Provider

Terraform's acceptance test framework runs real Terraform operations against your provider. Each test creates resources, verifies their attributes, modifies them, and destroys them. The framework handles the Terraform lifecycle — you just define the test configurations and assertions.

func TestAccFeatureFlag_basic(t *testing.T) {
    resource.Test(t, resource.TestCase{
        ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
        Steps: []resource.TestStep{
            {
                Config: `
                    resource "internal_feature_flag" "test" {
                        name        = "test-flag-basic"
                        enabled     = true
                        description = "Test flag for acceptance tests"
                    }`,
                Check: resource.ComposeAggregateTestCheckFunc(
                    resource.TestCheckResourceAttr(
                        "internal_feature_flag.test", "name", "test-flag-basic"),
                    resource.TestCheckResourceAttr(
                        "internal_feature_flag.test", "enabled", "true"),
                ),
            },
            {
                Config: `
                    resource "internal_feature_flag" "test" {
                        name        = "test-flag-basic"
                        enabled     = false
                        description = "Updated description"
                    }`,
                Check: resource.TestCheckResourceAttr(
                    "internal_feature_flag.test", "enabled", "false"),
            },
        },
    })
}

Run acceptance tests against a test instance of your internal service, not production. Our CI pipeline spins up a Docker Compose stack with the service under test, runs the acceptance tests, and tears it down. The tests take about 3 minutes and catch regression in the provider's interaction with the API.

Distribution and Versioning

For internal providers, you don't need to publish to the public Terraform registry. Terraform supports private registries (Terraform Cloud, Artifactory, a simple HTTP server) and local filesystem installations. The simplest option for getting started is a filesystem mirror:

# ~/.terraformrc
provider_installation {
  filesystem_mirror {
    path    = "/opt/terraform-providers"
    include = ["registry.internal.company.com/*/*"]
  }
  direct {
    exclude = ["registry.internal.company.com/*/*"]
  }
}

Place your compiled provider binary at the expected path (/opt/terraform-providers/registry.internal.company.com/company/internal/1.2.0/linux_amd64/terraform-provider-internal_v1.2.0) and Terraform finds it without network access. For distribution across a team, publish the binary to your artifact repository and point everyone's .terraformrc at a shared network path or S3 bucket synced locally.

Version your provider with the same discipline you'd apply to any shared library. Breaking changes (resource schema changes, removed attributes, changed behavior) get a major version bump. New resources and attributes get a minor bump. Bug fixes get a patch bump. Your team will thank you when they can safely run terraform init -upgrade without worrying about breakage.

Common Pitfalls in Provider Development

The first provider I built had a subtle bug that took weeks to surface. The Update function didn't read the current state before applying changes — it just sent the full desired state to the API. This worked fine until someone changed a single attribute. Because the Update didn't know what the current state was, it couldn't compute a diff and send a partial update. Instead, it sent the full resource specification, which the API interpreted as "reset everything to defaults except what you specified." Three unrelated attributes silently reverted to their defaults.

The fix: always read current state at the beginning of Update, compute the actual diff, and send only changed attributes. This mirrors how most cloud APIs expect update calls to work — as patches, not full replacements:

func (r *FeatureFlagResource) Update(ctx context.Context,
    req resource.UpdateRequest, resp *resource.UpdateResponse) {
    var plan, state FeatureFlagModel
    resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
    resp.Diagnostics.Append(req.State.Get(ctx, &state)...)

    updateReq := platformclient.UpdateFlagRequest{ID: state.ID.ValueString()}
    changed := false

    if !plan.Name.Equal(state.Name) {
        updateReq.Name = plan.Name.ValueStringPointer()
        changed = true
    }
    if !plan.Enabled.Equal(state.Enabled) {
        updateReq.Enabled = plan.Enabled.ValueBoolPointer()
        changed = true
    }

    if changed {
        _, err := r.client.UpdateFeatureFlag(ctx, updateReq)
        if err != nil {
            resp.Diagnostics.AddError("Update failed", err.Error())
            return
        }
    }

    // Always read back the full state
    r.readIntoState(ctx, state.ID.ValueString(), &plan)
    resp.State.Set(ctx, &plan)
}

Schema Design for Forward Compatibility

Think carefully about attribute types. Once you publish a provider version with an attribute typed as string, changing it to list(string) in a later version is a breaking change that forces all users to update their Terraform code. I've learned to be generous with structured types from the start — if an attribute might eventually support multiple values, make it a list now even if it only has one element today.

Computed attributes (values set by the API, not by the user) need special attention. Mark them as Computed: true in the schema. If you mark something as Required when the API actually sets it, users will have to provide a value that gets immediately overwritten, causing a perpetual diff on every plan. I've seen this confuse teams for days — "why does plan always show changes even though nothing changed?"

Optional attributes with defaults are another landmine. If your API has a default value for an attribute and you make it Optional in the schema without specifying a default, Terraform treats the absent attribute as null. If the API returns the default value on Read, you get a diff between null (in state) and the default (from the API) on every plan. Either specify the default in the schema or use UseStateForUnknown plan modifier.