Provider Configuration Lab

10 minute read

Lab Overview

In this hands-on lab, you’ll build a complete provider configuration for a fictional “WebMonitor” service that monitors website uptime and performance.

What You’ll Build:

  • Provider with multiple authentication methods
  • Configuration from multiple sources
  • HTTP client with custom settings
  • Configuration validation
  • Connection testing
  • Shared client pattern

Time: 60-75 minutes

Prerequisites

# Required tools
go version  # 1.21+
terraform version  # 1.5+

# Create lab directory
mkdir terraform-provider-webmonitor
cd terraform-provider-webmonitor
go mod init github.com/yourorg/terraform-provider-webmonitor

Step 1: Project Structure

Create the following structure:

terraform-provider-webmonitor/
├── main.go
├── go.mod
├── internal/
│   ├── provider/
│   │   ├── provider.go
│   │   └── monitor_resource.go
│   └── client/
│       └── client.go
└── examples/
    └── provider/
        └── provider.tf
mkdir -p internal/provider internal/client examples/provider

Step 2: API Client

Create internal/client/client.go:

package client

import (
    "context"
    "crypto/tls"
    "fmt"
    "net/http"
    "time"
)

type Config struct {
    APIKey            string
    APISecret         string
    BaseURL           string
    Timeout           time.Duration
    MaxRetries        int
    InsecureSkipVerify bool
}

type Client struct {
    config     Config
    httpClient *http.Client
}

func NewClient(config Config) (*Client, error) {
    // Validate required fields
    if config.APIKey == "" {
        return nil, fmt.Errorf("API key is required")
    }
    
    // Set defaults
    if config.BaseURL == "" {
        config.BaseURL = "https://api.webmonitor.com"
    }
    if config.Timeout == 0 {
        config.Timeout = 30 * time.Second
    }
    if config.MaxRetries == 0 {
        config.MaxRetries = 3
    }
    
    // Create HTTP client with custom transport
    transport := &http.Transport{
        TLSClientConfig: &tls.Config{
            InsecureSkipVerify: config.InsecureSkipVerify,
        },
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
    }
    
    httpClient := &http.Client{
        Timeout:   config.Timeout,
        Transport: NewRetryTransport(transport, config.MaxRetries),
    }
    
    return &Client{
        config:     config,
        httpClient: httpClient,
    }, nil
}

// TestConnection verifies API connectivity and authentication
func (c *Client) TestConnection(ctx context.Context) error {
    req, err := http.NewRequestWithContext(ctx, "GET", c.config.BaseURL+"/api/v1/health", nil)
    if err != nil {
        return fmt.Errorf("failed to create request: %w", err)
    }
    
    // Add authentication
    req.Header.Set("X-API-Key", c.config.APIKey)
    if c.config.APISecret != "" {
        req.Header.Set("X-API-Secret", c.config.APISecret)
    }
    
    resp, err := c.httpClient.Do(req)
    if err != nil {
        return fmt.Errorf("connection failed: %w", err)
    }
    defer resp.Body.Close()
    
    if resp.StatusCode == 401 || resp.StatusCode == 403 {
        return fmt.Errorf("authentication failed (HTTP %d)", resp.StatusCode)
    }
    
    if resp.StatusCode >= 400 {
        return fmt.Errorf("API returned error (HTTP %d)", resp.StatusCode)
    }
    
    return nil
}

// RetryTransport wraps an HTTP transport with retry logic
type RetryTransport struct {
    Transport  http.RoundTripper
    MaxRetries int
}

func NewRetryTransport(transport http.RoundTripper, maxRetries int) *RetryTransport {
    return &RetryTransport{
        Transport:  transport,
        MaxRetries: maxRetries,
    }
}

func (t *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    var resp *http.Response
    var err error
    
    for attempt := 0; attempt <= t.MaxRetries; attempt++ {
        resp, err = t.Transport.RoundTrip(req)
        
        // Success
        if err == nil && resp.StatusCode < 500 {
            return resp, nil
        }
        
        // Don't retry on last attempt
        if attempt == t.MaxRetries {
            break
        }
        
        // Wait before retry (exponential backoff)
        backoff := time.Duration(attempt+1) * time.Second
        time.Sleep(backoff)
    }
    
    return resp, err
}

Step 3: Provider Schema

Create internal/provider/provider.go:

package provider

import (
    "context"
    "encoding/json"
    "fmt"
    "os"
    "path/filepath"
    "time"
    
    "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"
    "github.com/hashicorp/terraform-plugin-log/tflog"
    
    "github.com/yourorg/terraform-provider-webmonitor/internal/client"
)

var _ provider.Provider = &WebMonitorProvider{}

type WebMonitorProvider struct {
    version string
}

type WebMonitorProviderModel struct {
    APIKey             types.String `tfsdk:"api_key"`
    APISecret          types.String `tfsdk:"api_secret"`
    Endpoint           types.String `tfsdk:"endpoint"`
    Timeout            types.Number `tfsdk:"timeout"`
    MaxRetries         types.Number `tfsdk:"max_retries"`
    InsecureSkipVerify types.Bool   `tfsdk:"insecure_skip_verify"`
    ConfigFile         types.String `tfsdk:"config_file"`
}

func New(version string) func() provider.Provider {
    return func() provider.Provider {
        return &WebMonitorProvider{
            version: version,
        }
    }
}

func (p *WebMonitorProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) {
    resp.TypeName = "webmonitor"
    resp.Version = p.version
}

Step 4: Provider Schema Definition

Add to internal/provider/provider.go:

func (p *WebMonitorProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) {
    resp.Schema = schema.Schema{
        MarkdownDescription: "WebMonitor provider for monitoring website uptime and performance.",
        
        Attributes: map[string]schema.Attribute{
            "api_key": schema.StringAttribute{
                Optional:            true,
                Sensitive:           true,
                MarkdownDescription: "API key for authentication. Can be set via WEBMONITOR_API_KEY environment variable.",
            },
            "api_secret": schema.StringAttribute{
                Optional:            true,
                Sensitive:           true,
                MarkdownDescription: "API secret for enhanced authentication. Can be set via WEBMONITOR_API_SECRET.",
            },
            "endpoint": schema.StringAttribute{
                Optional:            true,
                MarkdownDescription: "API endpoint URL. Defaults to https://api.webmonitor.com. Can be set via WEBMONITOR_ENDPOINT.",
            },
            "timeout": schema.NumberAttribute{
                Optional:            true,
                MarkdownDescription: "Request timeout in seconds. Defaults to 30. Range: 1-300.",
            },
            "max_retries": schema.NumberAttribute{
                Optional:            true,
                MarkdownDescription: "Maximum number of retry attempts. Defaults to 3. Range: 0-10.",
            },
            "insecure_skip_verify": schema.BoolAttribute{
                Optional:            true,
                MarkdownDescription: "Skip TLS certificate verification. Not recommended for production.",
            },
            "config_file": schema.StringAttribute{
                Optional:            true,
                MarkdownDescription: "Path to configuration file. Supports JSON format. Defaults to ~/.webmonitor/config.json",
            },
        },
    }
}

Step 5: Configuration Loading

Add to internal/provider/provider.go:

// ConfigFile represents the structure of config file
type ConfigFile struct {
    APIKey             string `json:"api_key"`
    APISecret          string `json:"api_secret"`
    Endpoint           string `json:"endpoint"`
    Timeout            int    `json:"timeout"`
    MaxRetries         int    `json:"max_retries"`
    InsecureSkipVerify bool   `json:"insecure_skip_verify"`
}

// loadConfigFile loads configuration from a JSON file
func loadConfigFile(path string) (*ConfigFile, error) {
    // Expand home directory
    if path == "" {
        homeDir, err := os.UserHomeDir()
        if err != nil {
            return nil, err
        }
        path = filepath.Join(homeDir, ".webmonitor", "config.json")
    } else if strings.HasPrefix(path, "~/") {
        homeDir, err := os.UserHomeDir()
        if err != nil {
            return nil, err
        }
        path = filepath.Join(homeDir, path[2:])
    }
    
    // Check if file exists
    if _, err := os.Stat(path); os.IsNotExist(err) {
        return &ConfigFile{}, nil // File doesn't exist, return empty config
    }
    
    // Read file
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("failed to read config file: %w", err)
    }
    
    // Parse JSON
    var config ConfigFile
    if err := json.Unmarshal(data, &config); err != nil {
        return nil, fmt.Errorf("failed to parse config file: %w", err)
    }
    
    return &config, nil
}

// resolveString resolves configuration with precedence: explicit > env > file > default
func resolveString(attr types.String, envVar string, fileValue string, defaultValue string) string {
    // 1. Explicit configuration
    if !attr.IsNull() && attr.ValueString() != "" {
        return attr.ValueString()
    }
    
    // 2. Environment variable
    if val := os.Getenv(envVar); val != "" {
        return val
    }
    
    // 3. Config file
    if fileValue != "" {
        return fileValue
    }
    
    // 4. Default
    return defaultValue
}

func resolveInt(attr types.Number, fileValue int, defaultValue int) int {
    if !attr.IsNull() {
        val, _ := attr.ValueBigFloat().Int64()
        return int(val)
    }
    if fileValue != 0 {
        return fileValue
    }
    return defaultValue
}

func resolveBool(attr types.Bool, fileValue bool, defaultValue bool) bool {
    if !attr.IsNull() {
        return attr.ValueBool()
    }
    if fileValue {
        return fileValue
    }
    return defaultValue
}

Step 6: Provider Configure Method

Add to internal/provider/provider.go:

func (p *WebMonitorProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
    var config WebMonitorProviderModel
    diags := req.Config.Get(ctx, &config)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }
    
    // Load config file
    fileConfig, err := loadConfigFile(config.ConfigFile.ValueString())
    if err != nil {
        resp.Diagnostics.AddWarning(
            "Failed to Load Config File",
            fmt.Sprintf("Could not load configuration file: %s. Continuing with other sources.", err.Error()),
        )
        fileConfig = &ConfigFile{}
    }
    
    // Resolve configuration with precedence
    apiKey := resolveString(config.APIKey, "WEBMONITOR_API_KEY", fileConfig.APIKey, "")
    apiSecret := resolveString(config.APISecret, "WEBMONITOR_API_SECRET", fileConfig.APISecret, "")
    endpoint := resolveString(config.Endpoint, "WEBMONITOR_ENDPOINT", fileConfig.Endpoint, "https://api.webmonitor.com")
    timeout := resolveInt(config.Timeout, fileConfig.Timeout, 30)
    maxRetries := resolveInt(config.MaxRetries, fileConfig.MaxRetries, 3)
    insecureSkipVerify := resolveBool(config.InsecureSkipVerify, fileConfig.InsecureSkipVerify, false)
    
    tflog.Debug(ctx, "Provider configuration resolved", map[string]any{
        "endpoint":             endpoint,
        "timeout":              timeout,
        "max_retries":          maxRetries,
        "insecure_skip_verify": insecureSkipVerify,
    })
    
    // Validate required configuration
    if apiKey == "" {
        resp.Diagnostics.AddError(
            "Missing API Key",
            "API key must be provided via one of:\n"+
                "  - api_key attribute in provider block\n"+
                "  - WEBMONITOR_API_KEY environment variable\n"+
                "  - config_file (~/.webmonitor/config.json)\n\n"+
                "Example:\n"+
                "  provider \"webmonitor\" {\n"+
                "    api_key = \"wm-abc123...\"\n"+
                "  }",
        )
        return
    }
    
    // Validate timeout range
    if timeout < 1 || timeout > 300 {
        resp.Diagnostics.AddError(
            "Invalid Timeout",
            fmt.Sprintf("Timeout must be between 1 and 300 seconds, got %d", timeout),
        )
        return
    }
    
    // Validate max_retries range
    if maxRetries < 0 || maxRetries > 10 {
        resp.Diagnostics.AddError(
            "Invalid Max Retries",
            fmt.Sprintf("max_retries must be between 0 and 10, got %d", maxRetries),
        )
        return
    }
    
    // Warn about insecure configuration
    if insecureSkipVerify {
        resp.Diagnostics.AddWarning(
            "TLS Verification Disabled",
            "TLS certificate verification is disabled. This is not recommended for production use.",
        )
    }
    
    // Create API client
    client, err := client.NewClient(client.Config{
        APIKey:             apiKey,
        APISecret:          apiSecret,
        BaseURL:            endpoint,
        Timeout:            time.Duration(timeout) * time.Second,
        MaxRetries:         maxRetries,
        InsecureSkipVerify: insecureSkipVerify,
    })
    
    if err != nil {
        resp.Diagnostics.AddError(
            "Failed to Create Client",
            fmt.Sprintf("Could not create API client: %s", err.Error()),
        )
        return
    }
    
    // Test connection
    tflog.Info(ctx, "Testing API connection")
    
    if err := client.TestConnection(ctx); err != nil {
        resp.Diagnostics.AddError(
            "Connection Test Failed",
            fmt.Sprintf(
                "Could not connect to API.\n"+
                    "Endpoint: %s\n"+
                    "Error: %s\n\n"+
                    "Please verify:\n"+
                    "  - API key is valid\n"+
                    "  - Endpoint is correct\n"+
                    "  - Network connectivity",
                endpoint,
                err.Error(),
            ),
        )
        return
    }
    
    tflog.Info(ctx, "Successfully connected to API", map[string]any{
        "endpoint": endpoint,
    })
    
    // Share client with resources and data sources
    resp.ResourceData = client
    resp.DataSourceData = client
}

func (p *WebMonitorProvider) Resources(ctx context.Context) []func() resource.Resource {
    return []func() resource.Resource{
        NewMonitorResource,
    }
}

func (p *WebMonitorProvider) DataSources(ctx context.Context) []func() datasource.DataSource {
    return []func() datasource.DataSource{}
}

Step 7: Sample Resource

Create internal/provider/monitor_resource.go:

package provider

import (
    "context"
    "fmt"
    
    "github.com/hashicorp/terraform-plugin-framework/resource"
    "github.com/hashicorp/terraform-plugin-framework/resource/schema"
    "github.com/hashicorp/terraform-plugin-framework/types"
    
    "github.com/yourorg/terraform-provider-webmonitor/internal/client"
)

type MonitorResource struct {
    client *client.Client
}

type MonitorResourceModel struct {
    ID   types.String `tfsdk:"id"`
    Name types.String `tfsdk:"name"`
    URL  types.String `tfsdk:"url"`
}

var _ resource.Resource = &MonitorResource{}
var _ resource.ResourceWithConfigure = &MonitorResource{}

func NewMonitorResource() resource.Resource {
    return &MonitorResource{}
}

func (r *MonitorResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
    resp.TypeName = req.ProviderTypeName + "_monitor"
}

func (r *MonitorResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
    resp.Schema = schema.Schema{
        MarkdownDescription: "Manages a website monitor",
        
        Attributes: map[string]schema.Attribute{
            "id": schema.StringAttribute{
                Computed: true,
            },
            "name": schema.StringAttribute{
                Required: true,
            },
            "url": schema.StringAttribute{
                Required: true,
            },
        },
    }
}

func (r *MonitorResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
    if req.ProviderData == nil {
        return
    }
    
    client, ok := req.ProviderData.(*client.Client)
    if !ok {
        resp.Diagnostics.AddError(
            "Unexpected Resource Configure Type",
            fmt.Sprintf("Expected *client.Client, got: %T", req.ProviderData),
        )
        return
    }
    
    r.client = client
}

func (r *MonitorResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
    var plan MonitorResourceModel
    resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
    
    if r.client == nil {
        resp.Diagnostics.AddError("Unconfigured Client", "Client not configured")
        return
    }
    
    // Simulate API call
    plan.ID = types.StringValue("mon-12345")
    
    resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}

func (r *MonitorResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {}
func (r *MonitorResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {}
func (r *MonitorResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {}

Step 8: Main Entry Point

Create main.go:

package main

import (
    "context"
    "flag"
    "log"
    
    "github.com/hashicorp/terraform-plugin-framework/providerserver"
    
    "github.com/yourorg/terraform-provider-webmonitor/internal/provider"
)

var version = "dev"

func main() {
    var debug bool
    flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers")
    flag.Parse()
    
    opts := providerserver.ServeOpts{
        Address: "registry.terraform.io/yourorg/webmonitor",
        Debug:   debug,
    }
    
    err := providerserver.Serve(context.Background(), provider.New(version), opts)
    if err != nil {
        log.Fatal(err.Error())
    }
}

Step 9: Install Dependencies

go get github.com/hashicorp/terraform-plugin-framework@latest
go get github.com/hashicorp/terraform-plugin-log@latest
go mod tidy

Step 10: Build and Test

Build Provider

go build -o terraform-provider-webmonitor

Create Test Configuration

Create examples/provider/provider.tf:

terraform {
  required_providers {
    webmonitor = {
      source = "registry.terraform.io/yourorg/webmonitor"
    }
  }
}

# Test 1: API Key via attribute
provider "webmonitor" {
  api_key    = "wm-test123456789012345678901234"
  endpoint   = "https://api.example.com"
  timeout    = 60
  max_retries = 5
}

resource "webmonitor_monitor" "example" {
  name = "My Website"
  url  = "https://example.com"
}

Test with Environment Variables

export WEBMONITOR_API_KEY="wm-test123456789012345678901234"
export WEBMONITOR_ENDPOINT="https://api.example.com"
export TF_LOG=DEBUG

Create Config File

mkdir -p ~/.webmonitor
cat > ~/.webmonitor/config.json <<EOF
{
  "api_key": "wm-test123456789012345678901234",
  "endpoint": "https://api.example.com",
  "timeout": 60,
  "max_retries": 5
}
EOF

Install and Test

# Install locally
mkdir -p ~/.terraform.d/plugins/registry.terraform.io/yourorg/webmonitor/1.0.0/darwin_arm64
cp terraform-provider-webmonitor ~/.terraform.d/plugins/registry.terraform.io/yourorg/webmonitor/1.0.0/darwin_arm64/

# Test
cd examples/provider
terraform init
terraform plan

Lab Validation

Test Checklist

  • Provider builds without errors
  • Schema is properly defined
  • API key loads from attribute
  • API key loads from environment variable
  • API key loads from config file
  • Configuration precedence works correctly
  • Validation catches missing API key
  • Validation catches invalid timeout
  • Connection test executes
  • Warning shows for insecure_skip_verify
  • Client is shared with resources
  • Resource can access shared client

Expected Output

terraform plan

Planning complete!

Challenge Exercises

1. Add Token Authentication

Add support for bearer token authentication as an alternative to API key.

2. Add Profile Support

Support multiple named profiles in config file:

{
  "profiles": {
    "production": {
      "api_key": "...",
      "endpoint": "https://api.prod.com"
    },
    "staging": {
      "api_key": "...",
      "endpoint": "https://api.staging.com"
    }
  }
}

3. Add Custom User Agent

Add a user_agent configuration option and include it in API requests.

4. Implement Connection Pooling Tuning

Add configuration for:

  • max_idle_connections
  • max_connections_per_host
  • idle_connection_timeout

Key Takeaways

  • ✅ Provider configuration requires schema, Configure method, and validation
  • ✅ Support multiple configuration sources with clear precedence
  • ✅ Test API connectivity during provider configuration
  • ✅ Share configured client with all resources and data sources
  • ✅ Provide clear error messages with actionable solutions
  • ✅ Log configuration steps for debugging
  • ✅ Validate configuration early to catch errors during plan
  • ✅ Use sensitive attribute for credentials
  • ✅ Support environment variables for automation
  • ✅ Implement retry logic for reliability

What’s Next?

Test your knowledge with practice questions!

Next: Provider Configuration Practice →


Previous: ← Configuration Validation

Last updated: June 30, 2026

Updated: