Provider Configuration Lab
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