API Advice
How To Create a Terraform Provider — a Guide for Absolute Beginners
Tristan Cartledge
October 10, 2025 - 19 min read
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
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/baseThe 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/binTo make this permanent, add the export to your shell profile (for example, .bashrc or .zshrc):
echo 'export PATH=$PATH:$HOME/go/bin' >> ~/.zshrcGenerate the SDK from the OpenAPI document using the following command:
speakeasy quickstartProvide 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@latestAdd 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-sdkNote: Adjust the path (
./poke-api-sdk) to match the actual directory name Speakeasy created.
After editing go.mod, update the dependency tree:
go mod tidyThis 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
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 optionalendpointattribute that users can set in their Terraform configs.Configure()initializes the Speakeasy SDK client withpokeapisdk.New(), instead of writing dozens of lines of HTTP client setup, and then stores it inresp.ResourceData.Resources()registers thePokemonResourceso Terraform knows which resources this provider manages.DataSources()andFunctions()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()orreq.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
AttrTypesstructure 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.
}
EOFThe 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-pokeapiThe 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 examplesCreate 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 changesTerraform 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 = 60Terraform 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 applyYou 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 planto see what would change. - Run
terraform destroyto 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 .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
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