Cover image for Build a Terraform provider for your side project's API (it's smaller than you think)

DevOps Daily

Writing a Terraform provider sounds like a big-company activity, something HashiCorp partners do with a team and a roadmap. In practice, if your API has CRUD endpoints, the provider is mostly plumbing. Here is the map worth having before you start.

The running example throughout is the open-source Terraform provider for SMTPfast, a transactional email API. It is a small, complete provider you can read end to end as a reference:

https://github.com/smtpfast/terraform-provider-smtpfast

Why bother

Because your users' infrastructure already lives in Terraform. If creating a domain or an API key in your product requires clicking a dashboard, your product is the one manual step in their otherwise automated stack. A provider turns your product into:

resource "smtpfast_domain" "prod" {
  name = "mail.example.com"
}

resource "smtpfast_api_key" "ci" {
  name = "ci-sender"
}

Enter fullscreen mode Exit fullscreen mode

That is a real workflow now: domains and keys created in code review, not in a browser.

The stack in 2026: Plugin Framework

Use the Terraform Plugin Framework (terraform-plugin-framework), not the older SDKv2. It is the maintained path, strongly typed, and much less magical. A provider is a Go module with three kinds of pieces:

  1. The provider type: configuration (API endpoint, API key) and client setup.
  2. Resources: things Terraform creates, updates, and deletes. Each one implements Create, Read, Update, Delete.
  3. Data sources: read-only lookups.

The skeleton for a resource looks like this (trimmed):

type domainResource struct {
    client *apiClient
}

func (r *domainResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
    var plan domainModel
    resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)

    domain, err := r.client.CreateDomain(ctx, plan.Name.ValueString())
    if err != nil {
        resp.Diagnostics.AddError("create failed", err.Error())
        return
    }

    plan.ID = types.StringValue(domain.ID)
    plan.Status = types.StringValue(domain.Status)
    resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}

Enter fullscreen mode Exit fullscreen mode

Read fetches current state, Delete calls the delete endpoint, Update is often empty for immutable resources. If you have written an API client before, none of this is new; the framework just dictates where each piece lives.

The parts that actually take time

State drift is the whole game. Terraform's job is comparing desired state to real state, and your Read function is where reality comes from. Get lazy there (returning stale fields, ignoring server-side changes) and users get mysterious diffs on every plan. Rule of thumb: Read should map every attribute you declared, from the API response, every time.

Sensitive values need care. Some APIs return an API key only once, at creation (SMTPfast's does). That means the provider stores the secret in state at create time and must not try to re-read it later. Mark it Sensitive: true in the schema and document that state files must be treated as secrets (they always should be anyway).

Import support is what makes it adoptable. Users have existing domains. terraform import support (implementing ImportState) lets them adopt the provider without recreating anything. Skipping it is the difference between a toy and a tool.

Acceptance tests hit the real API. The framework's acceptance test harness (TF_ACC=1) spins resources up and down against a live account. Budget for a test tenant, because these tests create and destroy real things.

Publishing to the registry

This part is more checklist than code:

  1. Repo named terraform-provider-<name>, tagged releases with semver (v0.1.0).
  2. Release binaries built by GoReleaser for the usual OS/arch matrix.
  3. Sign the release with a GPG key.
  4. Publish the GPG public key on the Terraform Registry, then add the provider from your GitHub repo.

After that, terraform init fetches the provider like any other, and users write source = "smtpfast/smtpfast" in their required_providers block.

Is it worth it?

For the effort (roughly a weekend for v0 with two resources and a data source), unreasonably yes. It closes a real gap for users who automate everything, and writing the provider tends to surface API inconsistencies you did not know you had (drift makes a sloppy API visible). Infrastructure-as-code users are exactly the audience a developer API wants.

If your side project has an API, put a provider on the list. Start with your two most-created resources, implement Read properly, ship import support, and grow from there. The SMTPfast provider repo is MIT and small enough to read in one sitting; steal the structure.