Speakeasy Logo
Skip to Content

API Advice

How To Create a Terraform Provider — a Guide for Absolute Beginners

Tristan Cartledge

Tristan Cartledge

October 10, 2025 - 19 min read

API Advice

This tutorial shows you how to create a simple Terraform provider for your web service.

Terraform providers let your customers manage your API through declarative configuration files, the same way they manage AWS, Azure, or any other infrastructure. But HashiCorp’s official tutorials are lengthy and overwhelming for developers new to provider development.

This guide cuts through the complexity, demonstrating how to build a working provider that implements create, read, update, and delete (CRUD) operations against a real API (PokéAPI ). You don’t need any experience using Terraform to follow along — we’ll explain everything as we go.

Prerequisites

For this tutorial, you need the following installed on your machine:

Set up your system

First, clone the example project.

git clone https://github.com/speakeasy-api/examples cd examples/terraform-provider-pokeapi/base

The base project includes the OpenAPI document for the PokéAPI.

Generate the PokéAPI SDK

Terraform providers require writing your own logic to create, read, update, and delete resources via API calls. Writing those HTTP client functions, request-response models, and error handling can take hundreds of lines or repetitive code. Instead, we’ll use Speakeasy to generate a type-safe Go SDK that handles all API interactions for us.

First, install staticcheck, which is required by Speakeasy for linting:

go install honnef.co/go/tools/cmd/staticcheck@latest export PATH=$PATH:$HOME/go/bin

To make this permanent, add the export to your shell profile (for example, .bashrc or .zshrc):

echo 'export PATH=$PATH:$HOME/go/bin' >> ~/.zshrc

Generate the SDK from the OpenAPI document using the following command:

speakeasy quickstart

Provide the following information as Speakeasy requests it:

  • The OpenAPI document location: openapi.yml
  • Give your SDK a name: PokeAPISDK
  • What you would like to generate: Software Development Kit (SDK)
  • The SDK language you would like to generate: Go
  • Choose a modulePath: github.com/pokeapi/sdk
  • Choose a sdkPackageName: PokeAPISDK
  • The directory the Go files should be written to: /path/to/project/poke-api-sdk

This creates a complete SDK in the poke-api-sdk directory with built-in HTTP client logic, type-safe models, and error handling.

Initialize the Go module

We’re building a provider that connects Terraform to the PokéAPI. This requires two key dependencies.

  • terraform-plugin-framework: The official library for building Terraform providers.
  • terraform-plugin-log: The plugin for diagnostic logging and debugging.

Initialize the module following Terraform’s naming convention:

go mod init example.com/me/terraform-provider-pokeapi go get github.com/hashicorp/terraform-plugin-framework@latest go get github.com/hashicorp/terraform-plugin-log@latest

Add the Speakeasy-generated SDK as a local dependency in go.mod:

// go.mod module example.com/me/terraform-provider-pokeapi go 1.24.0 toolchain go1.24.8 + require ( + github.com/pokeapi/sdk v0.0.0-00010101000000-000000000000 +) require ( github.com/hashicorp/terraform-plugin-framework v1.16.1 // indirect github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect ) + replace github.com/pokeapi/sdk => ./poke-api-sdk

Note: Adjust the path (./poke-api-sdk) to match the actual directory name Speakeasy created.

After editing go.mod, update the dependency tree:

go mod tidy

This command downloads all transitive dependencies and updates go.sum with checksums for the modules your provider needs.

Create the provider files

So you have a web service, and in reality, you may even have an SDK in Python, Go, Java, and other languages that your customers could use to call your service. Why do you need Terraform, too?

We answer this question in detail in our blog post about using Terraform as a SaaS API interface . In summary, Terraform allows your customers to manage multiple environments with a single service (Terraform) through declarative configuration files that can be stored in Git. This means that if one of your customers wants to add a new user, or a whole new franchise, they can copy a Terraform resource configuration file from an existing franchise, update it, check it into GitHub, and get it approved. Then Terraform can run it automatically using continuous integration. Terraform provides benefits for your customers in terms of speed, safety, repeatability, auditing, and correctness.

A Terraform provider consists of three essential files that work together:

  • An entry point that Terraform calls
  • A provider configuration that sets up the API client
  • Resource files that implement CRUD operations

The entry point

Create a main.go file in your project root:

package main import ( "context" "flag" "log" "example.com/me/terraform-provider-pokeapi/internal/provider" "github.com/hashicorp/terraform-plugin-framework/providerserver" ) var ( version string = "dev" ) func main() { var debug bool flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers like delve") flag.Parse() opts := providerserver.ServeOpts{ Address: "example.com/me/pokeapi", Debug: debug, } err := providerserver.Serve(context.Background(), provider.New(version), opts) if err != nil { log.Fatal(err.Error()) } }

This file starts the provider server that Terraform connects to. The Address is a unique identifier for your provider (like a package name). Users will reference this same address in their Terraform configurations to specify which provider they want to use. For example:

terraform { required_providers { pokeapi = { source = "example.com/example/pokeapi" } } }

The provider configuration

The provider configuration file configures the provider with the PokéAPI endpoint and initializes the Speakeasy SDK client.

Create an internal/provider/provider.go file:

package provider import ( "context" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/function" "github.com/hashicorp/terraform-plugin-framework/path" "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" pokeapisdk "github.com/pokeapi/sdk" ) var _ provider.Provider = &PokeAPIProvider{} var _ provider.ProviderWithFunctions = &PokeAPIProvider{} type PokeAPIProvider struct { version string } type PokeAPIProviderModel struct { Endpoint types.String `tfsdk:"endpoint"` } func (p *PokeAPIProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { resp.TypeName = "pokeapi" resp.Version = p.version } func (p *PokeAPIProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: `The PokeAPI provider allows you to manage Pokemon data using the Pokemon API.`, Attributes: map[string]schema.Attribute{ "endpoint": schema.StringAttribute{ MarkdownDescription: "PokeAPI endpoint URL. Defaults to https://pokeapi.co", Optional: true, }, }, } } func (p *PokeAPIProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { var data PokeAPIProviderModel resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } endpoint := "https://pokeapi.co" if !data.Endpoint.IsNull() { endpoint = data.Endpoint.ValueString() } if endpoint == "" { resp.Diagnostics.AddAttributeError( path.Root("endpoint"), "Missing PokeAPI Endpoint", "The provider cannot create the PokeAPI client as there is a missing or empty value for the PokeAPI endpoint.", ) } if resp.Diagnostics.HasError() { return } client := pokeapisdk.New( pokeapisdk.WithServerURL(endpoint), ) resp.DataSourceData = client resp.ResourceData = client } func (p *PokeAPIProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ NewPokemonResource, } } func (p *PokeAPIProvider) DataSources(ctx context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{} } func (p *PokeAPIProvider) Functions(ctx context.Context) []func() function.Function { return []func() function.Function{} } func New(version string) func() provider.Provider { return func() provider.Provider { return &PokeAPIProvider{ version: version, } } }

The Go file above implements the provider interface with several key functions:

  • Schema() defines the provider’s configuration schema with an optional endpoint attribute that users can set in their Terraform configs.
  • Configure() initializes the Speakeasy SDK client with pokeapisdk.New(), instead of writing dozens of lines of HTTP client setup, and then stores it in resp.ResourceData.
  • Resources() registers the PokemonResource so Terraform knows which resources this provider manages.
  • DataSources() and Functions() return empty lists, since we’re not implementing data sources or custom functions in this tutorial.

The Pokémon resource

This is where the complexity begins to show – you must define schemas for every Pokémon attribute and implement CRUD operations. Large APIs with nested objects require hundreds of lines of repetitive schema definitions and type mappings.

Create a internal/provider/pokemon_resource.go file. Then, create the resources and schemas:

// internal/provider/pokemon_resource.go package provider import ( "context" "fmt" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/types" pokeapisdk "github.com/pokeapi/sdk" "github.com/pokeapi/sdk/models/operations" ) var _ resource.Resource = &PokemonResource{} var _ resource.ResourceWithImportState = &PokemonResource{} func NewPokemonResource() resource.Resource { return &PokemonResource{} } type PokemonResource struct { client *pokeapisdk.PokeApisdk } type PokemonResourceModel struct { ID types.Int64 `tfsdk:"id"` Name types.String `tfsdk:"name"` Height types.Int64 `tfsdk:"height"` Weight types.Int64 `tfsdk:"weight"` BaseExperience types.Int64 `tfsdk:"base_experience"` Stats types.List `tfsdk:"stats"` Types types.List `tfsdk:"types"` Abilities types.List `tfsdk:"abilities"` Sprites types.Object `tfsdk:"sprites"` } func (r *PokemonResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_pokemon" } func (r *PokemonResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: "Pokemon resource", Attributes: map[string]schema.Attribute{ "id": schema.Int64Attribute{ MarkdownDescription: "Pokemon ID", Required: true, }, "name": schema.StringAttribute{ MarkdownDescription: "Pokemon name", Computed: true, }, "height": schema.Int64Attribute{ MarkdownDescription: "Height in decimetres", Computed: true, }, "weight": schema.Int64Attribute{ MarkdownDescription: "Weight in hectograms", Computed: true, }, "base_experience": schema.Int64Attribute{ MarkdownDescription: "Base experience", Computed: true, }, "stats": schema.ListNestedAttribute{ MarkdownDescription: "Base stats", Computed: true, NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "base_stat": schema.Int64Attribute{ Computed: true, }, "effort": schema.Int64Attribute{ Computed: true, }, "stat": schema.SingleNestedAttribute{ Computed: true, Attributes: map[string]schema.Attribute{ "name": schema.StringAttribute{Computed: true}, "url": schema.StringAttribute{Computed: true}, }, }, }, }, }, "types": schema.ListNestedAttribute{ MarkdownDescription: "Pokemon types", Computed: true, NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "slot": schema.Int64Attribute{Computed: true}, "type": schema.SingleNestedAttribute{ Computed: true, Attributes: map[string]schema.Attribute{ "name": schema.StringAttribute{Computed: true}, "url": schema.StringAttribute{Computed: true}, }, }, }, }, }, "abilities": schema.ListAttribute{ MarkdownDescription: "Abilities", Computed: true, ElementType: types.ObjectType{ AttrTypes: map[string]attr.Type{ "is_hidden": types.BoolType, "slot": types.Int64Type, "ability": types.ObjectType{ AttrTypes: map[string]attr.Type{ "name": types.StringType, "url": types.StringType, }, }, }, }, }, "sprites": schema.SingleNestedAttribute{ MarkdownDescription: "Sprite images", Computed: true, Attributes: map[string]schema.Attribute{ "front_default": schema.StringAttribute{Computed: true}, "back_default": schema.StringAttribute{Computed: true}, }, }, }, } } func (r *PokemonResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { if req.ProviderData == nil { return } client, ok := req.ProviderData.(*pokeapisdk.PokeAPISDK) if !ok { resp.Diagnostics.AddError( "Unexpected Resource Configure Type", fmt.Sprintf("Expected *pokeapisdk.PokeAPISDK, got: %T", req.ProviderData), ) return } r.client = client }

The PokemonResourceModel struct uses Terraform-specific types (like types.Int64 and types.List) instead of Go’s native types, because Terraform needs to track null values and unknown states during planning.

The Schema() function explicitly defines the structure of each attribute. Notice how nested objects like stats require verbose NestedObject definitions with their own attribute maps, making this one of the most tedious parts of provider development.

Now, add the CRUD operations:

// internal/provider/pokemon_resource.go ... func (r *PokemonResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data PokemonResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } pokemonID := data.ID.ValueInt64() pokemon, err := r.client.Pokemon.PokemonRead(ctx, pokemonID) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read Pokemon: %s", err)) return } if err := r.mapPokemonToState(ctx, pokemon, &data); err != nil { resp.Diagnostics.AddError("Mapping Error", fmt.Sprintf("Unable to map Pokemon: %s", err)) return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *PokemonResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var data PokemonResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } pokemonID := data.ID.ValueInt64() pokemon, err := r.client.Pokemon.PokemonRead(ctx, pokemonID) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read Pokemon: %s", err)) return } if err := r.mapPokemonToState(ctx, pokemon, &data); err != nil { resp.Diagnostics.AddError("Mapping Error", fmt.Sprintf("Unable to map Pokemon: %s", err)) return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *PokemonResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var data PokemonResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } pokemonID := data.ID.ValueInt64() pokemon, err := r.client.Pokemon.PokemonRead(ctx, pokemonID) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read Pokemon: %s", err)) return } if err := r.mapPokemonToState(ctx, pokemon, &data); err != nil { resp.Diagnostics.AddError("Mapping Error", fmt.Sprintf("Unable to map Pokemon: %s", err)) return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *PokemonResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // Pokemon can't be deleted from the API, just remove from state } func (r *PokemonResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) }

Each CRUD function follows the same pattern:

  • Get the current state with req.Plan.Get() or req.State.Get().
  • Call the API using the Speakeasy SDK (r.client.Pokemon.PokemonRead()).
  • Map the response to Terraform types.
  • Save the response with resp.State.Set().

Since PokéAPI is read-only, Create(), Read(), and Update() all fetch Pokémon data from the same endpoint, while Delete() is a no-op that just removes the resource from Terraform’s state file.

Now, handle the data mapping:

// internal/provider/pokemon_resource.go ... func (r *PokemonResource) mapPokemonToState(ctx context.Context, pokemonResp *operations.PokemonReadResponse, data *PokemonResourceModel) error { if pokemonResp == nil || pokemonResp.Object == nil { return fmt.Errorf("received nil response from API") } pokemon := *pokemonResp.Object if pokemon.ID != nil { data.ID = types.Int64Value(int64(*pokemon.ID)) } if pokemon.Name != nil { data.Name = types.StringValue(*pokemon.Name) } if pokemon.Height != nil { data.Height = types.Int64Value(int64(*pokemon.Height)) } if pokemon.Weight != nil { data.Weight = types.Int64Value(int64(*pokemon.Weight)) } if pokemon.BaseExperience != nil { data.BaseExperience = types.Int64Value(int64(*pokemon.BaseExperience)) } // Map stats statsElements := []attr.Value{} if pokemon.Stats != nil { for _, stat := range pokemon.Stats { if stat.BaseStat == nil || stat.Effort == nil || stat.Stat.Name == nil || stat.Stat.URL == nil { continue } statObj, _ := types.ObjectValue( map[string]attr.Type{ "base_stat": types.Int64Type, "effort": types.Int64Type, "stat": types.ObjectType{ AttrTypes: map[string]attr.Type{ "name": types.StringType, "url": types.StringType, }, }, }, map[string]attr.Value{ "base_stat": types.Int64Value(*stat.BaseStat), "effort": types.Int64Value(*stat.Effort), "stat": types.ObjectValueMust( map[string]attr.Type{ "name": types.StringType, "url": types.StringType, }, map[string]attr.Value{ "name": types.StringValue(*stat.Stat.Name), "url": types.StringValue(*stat.Stat.URL), }, ), }, ) statsElements = append(statsElements, statObj) } } statsList, _ := types.ListValue( types.ObjectType{ AttrTypes: map[string]attr.Type{ "base_stat": types.Int64Type, "effort": types.Int64Type, "stat": types.ObjectType{ AttrTypes: map[string]attr.Type{ "name": types.StringType, "url": types.StringType, }, }, }, }, statsElements, ) data.Stats = statsList // Map types typesElements := []attr.Value{} if pokemon.Types != nil { for _, typeInfo := range pokemon.Types { if typeInfo.Slot == nil || typeInfo.Type.Name == nil || typeInfo.Type.URL == nil { continue } typeObj, _ := types.ObjectValue( map[string]attr.Type{ "slot": types.Int64Type, "type": types.ObjectType{ AttrTypes: map[string]attr.Type{ "name": types.StringType, "url": types.StringType, }, }, }, map[string]attr.Value{ "slot": types.Int64Value(*typeInfo.Slot), "type": types.ObjectValueMust( map[string]attr.Type{ "name": types.StringType, "url": types.StringType, }, map[string]attr.Value{ "name": types.StringValue(*typeInfo.Type.Name), "url": types.StringValue(*typeInfo.Type.URL), }, ), }, ) typesElements = append(typesElements, typeObj) } } typesList, _ := types.ListValue( types.ObjectType{ AttrTypes: map[string]attr.Type{ "slot": types.Int64Type, "type": types.ObjectType{ AttrTypes: map[string]attr.Type{ "name": types.StringType, "url": types.StringType, }, }, }, }, typesElements, ) data.Types = typesList // Create empty collections for other fields abilitiesEmpty, _ := types.ListValue( types.ObjectType{ AttrTypes: map[string]attr.Type{ "is_hidden": types.BoolType, "slot": types.Int64Type, "ability": types.ObjectType{ AttrTypes: map[string]attr.Type{ "name": types.StringType, "url": types.StringType, }, }, }, }, []attr.Value{}, ) data.Abilities = abilitiesEmpty spritesEmpty, _ := types.ObjectValue( map[string]attr.Type{ "front_default": types.StringType, "back_default": types.StringType, }, map[string]attr.Value{ "front_default": types.StringNull(), "back_default": types.StringNull(), }, ) data.Sprites = spritesEmpty return nil }

The mapPokemonToState() function converts the SDK’s response types to the Terraform type system.

For each nested list like stats, you need to:

  • Define the exact AttrTypes structure that matches your schema
  • Iterate through the API response, checking for nil values
  • Construct properly typed objects using types.ObjectValue()

In this manual, repetitive mapping consumes most of the provider development time, and any mismatch between the AttrTypes map here and the schema definition can cause cryptic runtime type errors.

Run the provider

At this point, you have:

  • Generated an SDK with Speakeasy to handle API calls to PokéAPI
  • Created three provider files, an entry point (main.go), a provider configuration (provider.go), and a Pokémon resource with CRUD operations (pokemon_resource.go)
  • Implemented complex data mapping that links API responses to Terraform state

Now it’s time to test the provider by pretending you’re a user who wants to manage Pokémon data through Terraform.

Configure local development

Because you haven’t published your provider to the Terraform registry yet, you need to tell Terraform to use your local build. Create a .terraformrc file in your home directory:

cat > ~/.terraformrc << 'EOF' provider_installation { dev_overrides { "example.com/me/pokeapi" = "/absolute/path/to/your/project/bin" } direct {} # For all other providers, install directly from their origin provider. } EOF

The dev_overrides setting tells Terraform to use your local binary instead of downloading from the registry.

Replace /absolute/path/to/your/project/bin with the actual absolute path to your project’s bin directory. For example: /Users/yourusername/terraform-provider-pokeapi/bin.

Build the server

Compile the provider into an executable binary:

go build -o ./bin/terraform-provider-pokeapi

The command above creates the provider plugin that Terraform will call to manage Pokémon resources.

Create a test configuration

Create an examples directory to hold your Terraform configuration:

mkdir -p examples cd examples

Create a main.tf file inside the examples directory. This is what a user of your provider would write:

# Configure the PokeAPI provider terraform { required_providers { pokeapi = { source = "example.com/me/pokeapi" } } } provider "pokeapi" { endpoint = "https://pokeapi.co" } resource "pokeapi_pokemon" "pikachu" { id = 25 # Pikachu's ID } resource "pokeapi_pokemon" "charizard" { id = 6 # Charizard's ID } output "pikachu_name" { description = "The name of Pikachu" value = pokeapi_pokemon.pikachu.name } output "pikachu_height" { description = "Pikachu's height in decimetres" value = pokeapi_pokemon.pikachu.height } output "pikachu_weight" { description = "Pikachu's weight in hectograms" value = pokeapi_pokemon.pikachu.weight } output "pikachu_base_experience" { description = "Base experience gained from defeating Pikachu" value = pokeapi_pokemon.pikachu.base_experience } output "pikachu_stats" { description = "Pikachu's base stats - this is a complex nested list" value = pokeapi_pokemon.pikachu.stats } output "pikachu_types" { description = "Pikachu's types - this is a nested list with slot and type information" value = pokeapi_pokemon.pikachu.types } output "charizard_name" { description = "Charizard's name" value = pokeapi_pokemon.charizard.name }

This configuration tells Terraform to fetch data for two Pokémon (Pikachu and Charizard) and output some of their attributes.

Apply the configuration

Run Terraform to execute the configuration:

terraform plan # Preview what Terraform will do terraform apply -auto-approve # Apply the changes

Terraform should output the following:

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # pokeapi_pokemon.charizard will be created + resource "pokeapi_pokemon" "charizard" { + abilities = (known after apply) + base_experience = (known after apply) + cries = (known after apply) + game_indices = (known after apply) + height = (known after apply) + held_items = (known after apply) + id = 6 + is_default = (known after apply) + location_area_encounters = (known after apply) + moves = (known after apply) + name = (known after apply) + order = (known after apply) + past_abilities = (known after apply) + past_types = (known after apply) + species = (known after apply) + sprites = (known after apply) + stats = (known after apply) + types = (known after apply) + weight = (known after apply) } # pokeapi_pokemon.pikachu will be created + resource "pokeapi_pokemon" "pikachu" { + abilities = (known after apply) + base_experience = (known after apply) + cries = (known after apply) + game_indices = (known after apply) + height = (known after apply) + held_items = (known after apply) + id = 25 + is_default = (known after apply) + location_area_encounters = (known after apply) + moves = (known after apply) + name = (known after apply) + order = (known after apply) + past_abilities = (known after apply) + past_types = (known after apply) + species = (known after apply) + sprites = (known after apply) + stats = (known after apply) + types = (known after apply) + weight = (known after apply) } Plan: 2 to add, 0 to change, 0 to destroy. Changes to Outputs: + charizard_name = (known after apply) + pikachu_base_experience = (known after apply) + pikachu_height = (known after apply) + pikachu_name = (known after apply) + pikachu_stats = (known after apply) + pikachu_types = (known after apply) + pikachu_weight = (known after apply) pokeapi_pokemon.pikachu: Creating... pokeapi_pokemon.charizard: Creating... pokeapi_pokemon.pikachu: Creation complete after 0s [name=pikachu] pokeapi_pokemon.charizard: Creation complete after 0s [name=charizard] Apply complete! Resources: 2 added, 0 changed, 0 destroyed. Outputs: charizard_name = "charizard" pikachu_base_experience = 112 pikachu_height = 4 pikachu_name = "pikachu" pikachu_stats = tolist([ { "base_stat" = 35 "effort" = 0 "stat" = { "name" = "hp" "url" = "https://pokeapi.co/api/v2/stat/1/" } }, { "base_stat" = 55 "effort" = 0 "stat" = { "name" = "attack" "url" = "https://pokeapi.co/api/v2/stat/2/" } }, { "base_stat" = 40 "effort" = 0 "stat" = { "name" = "defense" "url" = "https://pokeapi.co/api/v2/stat/3/" } }, { "base_stat" = 50 "effort" = 0 "stat" = { "name" = "special-attack" "url" = "https://pokeapi.co/api/v2/stat/4/" } }, { "base_stat" = 50 "effort" = 0 "stat" = { "name" = "special-defense" "url" = "https://pokeapi.co/api/v2/stat/5/" } }, { "base_stat" = 90 "effort" = 2 "stat" = { "name" = "speed" "url" = "https://pokeapi.co/api/v2/stat/6/" } }, ]) pikachu_types = tolist([ { "slot" = 1 "type" = { "name" = "electric" "url" = "https://pokeapi.co/api/v2/type/13/" } }, ]) pikachu_weight = 60

Terraform called your provider’s Create() function for each Pokemon resource, which:

  • Made an API call to PokéAPI using the Speakeasy SDK
  • Mapped the API response to Terraform’s type system
  • Stored the Pokemon data in terraform.tfstate

Check the state file terraform.tfstate that was created. It will look similar to the following example:

{ "version": 4, "terraform_version": "1.9.5", "serial": 3, "lineage": "317ebf1d-403e-03fa-787e-4694386c5acd", "outputs": { "charizard_name": { "value": "charizard", "type": "string" }, "pikachu_abilities": { "value": [], "type": [ "list", [ "object", ... }

This file contains Terraform’s view of the Pokémon resources. Never edit the state file manually; always let Terraform manage it.

Debugging tips

If you need to debug the provider, enable logging:

export TF_LOG=DEBUG terraform apply

You can also add logging in your provider code:

import "github.com/hashicorp/terraform-plugin-log/tflog" // Inside any function tflog.Info(ctx, "Reading Pokemon", map[string]any{"id": pokemonID})

To push the current project a bit more, try modifying your configuration to see how Terraform handles changes:

  • Add more Pokémon resources with different IDs.
  • Run terraform plan to see what would change.
  • Run terraform destroy to remove resources from state.

Limitations and further reading

You now have a working minimal example of a Terraform provider, but it isn’t ready for production use yet. We recommend first enhancing it with features such as the following:

  • Markup responses (JSON or XML): PokéAPI returns JSON, which simplified our implementation. However, your API might return XML, Protobuf, or custom formats that require additional parsing logic. You need to handle serialization, deserialization, and error responses for the format your service uses.
  • Versioning and continuous integration: Your web service will change over time, and the provider needs to change to match it. Your customers will need to use the correct versions of both the web service and the provider. We also recommend automatically building and releasing your provider from GitHub, using GitHub Actions.
  • Testing: A real web service is complex, and you need to write a lot of integration tests to ensure that every provider version you release does exactly what it’s supposed to when calling the service.
  • Documentation: Your customers want to know exactly how to set up and configure your provider to manage whatever resources your service offers.
  • Publishing the provider to the Terraform registry: Until you add metadata to your provider and release it in the Terraform ecosystem, no one can use it.

You can also add additional functionality, like handling data sources (which are different from resources) and external imports of resources.

If you want to learn how to enhance your provider, the best place to start is the official Terraform provider creation tutorial . You can also clone the provider scaffolding repository  and read through it to see how Terraform structures a provider and uses .github to offer continuous integration.

Once you’ve worked through the tutorial, we recommend reading about how Terraform works with plugins .

HashiCorp released an automated provider generator  in technical preview during 2024, but development has stalled, and it doesn’t handle API operation logic or data type conversions - you still write most code manually. We cover this tool and production-ready alternatives in our article on building Terraform providers .

A simpler way

You might feel that creating and maintaining your own Terraform provider is far too much work when you’re busy trying to run a business and provide your core service. Luckily, there is a much easier alternative. We at Speakeasy are passionate about and dedicated to making web APIs easy for customers to use. Our service can automatically generate a complete Terraform provider with documentation that’s ready for you to offer to your customers on the Terraform registry. All you need is an OpenAPI document for your service and a few custom attributes.

Read our guide to generating a Terraform provider with Speakeasy  and see how we can massively reduce your workload by setting up a Terraform provider in a few clicks.

Last updated on

Organize your
dev universe,

faster and easier.

Try Speakeasy Now