Hands-On Lab

7 minute read

Lab Overview

Now it’s time to put everything together! In this hands-on lab, you’ll build a complete (albeit simple) Terraform provider from scratch, install it locally, and test it with real Terraform configurations.

This lab consolidates everything you’ve learned about Go, project structure, and dependencies.

Lab Objectives

By the end of this lab, you will:

  • ✅ Create a working provider
  • ✅ Build the provider binary
  • ✅ Install it locally for Terraform
  • ✅ Write Terraform configurations
  • ✅ Execute terraform init/plan/apply
  • ✅ Verify provider functionality

Estimated time: 45-60 minutes

Prerequisites

Before starting:

  • Go 1.21+ installed
  • Terraform 1.5+ installed
  • Git installed
  • Text editor ready (VS Code recommended)
  • Terminal access

Verify:

go version       # Should show 1.21+
terraform version # Should show 1.5+
git --version

Lab Architecture

You’ll build terraform-provider-time, a simple provider that creates time-based resources.

Why this provider?

  • No external API required
  • Demonstrates core concepts
  • Easy to test and verify
  • Actually useful!

What it does:

  • Creates a resource that stores a timestamp
  • Supports create, read, and delete operations
  • Uses standard Plugin Framework patterns

Step 1: Create Project

1.1 Create Directory

# Create project directory
mkdir -p ~/terraform-providers/terraform-provider-time
cd ~/terraform-providers/terraform-provider-time

1.2 Initialize Git

git init
echo "# Terraform Provider: Time" > README.md
git add README.md
git commit -m "Initial commit"

1.3 Initialize Go Module

go mod init github.com/YOUR_USERNAME/terraform-provider-time

Replace YOUR_USERNAME with your actual GitHub username (or use any valid module path).

Expected output:

go: creating new go.mod: module github.com/YOUR_USERNAME/terraform-provider-time

1.4 Create Directory Structure

mkdir -p internal/provider
mkdir -p examples/provider
mkdir -p examples/resources/time_static

Verify:

tree -L 2

Expected:

.
├── README.md
├── go.mod
├── examples/
│   ├── provider/
│   └── resources/
└── internal/
    └── provider/

Step 2: Add Dependencies

# Add Plugin Framework
go get github.com/hashicorp/terraform-plugin-framework@v1.5.0

# Add Plugin Go
go get github.com/hashicorp/terraform-plugin-go@v0.21.0

# Add Plugin Log
go get github.com/hashicorp/terraform-plugin-log@v0.9.0

# Tidy up
go mod tidy

Verify go.mod:

cat go.mod

Should look like:

module github.com/YOUR_USERNAME/terraform-provider-time

go 1.22

require (
    github.com/hashicorp/terraform-plugin-framework v1.5.0
    github.com/hashicorp/terraform-plugin-go v0.21.0
    github.com/hashicorp/terraform-plugin-log v0.9.0
)

Step 3: Create main.go

Create main.go in the project root:

package main

import (
    "context"
    "flag"
    "log"

    "github.com/hashicorp/terraform-plugin-framework/providerserver"
    "github.com/YOUR_USERNAME/terraform-provider-time/internal/provider"
)

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: "registry.terraform.io/YOUR_USERNAME/time",
        Debug:   debug,
    }

    err := providerserver.Serve(context.Background(), provider.New(version), opts)

    if err != nil {
        log.Fatal(err.Error())
    }
}

Remember: Replace YOUR_USERNAME with your actual username!

Step 4: Create Provider Implementation

Create internal/provider/provider.go:

package provider

import (
    "context"

    "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"
)

// Ensure provider defined types fully satisfy framework interfaces.
var _ provider.Provider = &TimeProvider{}

// TimeProvider defines the provider implementation.
type TimeProvider struct {
    version string
}

// TimeProviderModel describes the provider data model.
type TimeProviderModel struct {}

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

func (p *TimeProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) {
    resp.Schema = schema.Schema{
        MarkdownDescription: "The time provider is used to create time-based resources.",
    }
}

func (p *TimeProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
    var data TimeProviderModel

    resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)

    if resp.Diagnostics.HasError() {
        return
    }
}

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

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

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

Step 5: Create Resource

Create internal/provider/resource_time_static.go:

package provider

import (
    "context"
    "fmt"
    "time"

    "github.com/hashicorp/terraform-plugin-framework/resource"
    "github.com/hashicorp/terraform-plugin-framework/resource/schema"
    "github.com/hashicorp/terraform-plugin-framework/types"
)

// Ensure provider defined types fully satisfy framework interfaces.
var _ resource.Resource = &TimeStaticResource{}

func NewTimeStaticResource() resource.Resource {
    return &TimeStaticResource{}
}

// TimeStaticResource defines the resource implementation.
type TimeStaticResource struct{}

// TimeStaticResourceModel describes the resource data model.
type TimeStaticResourceModel struct {
    ID        types.String `tfsdk:"id"`
    Timestamp types.String `tfsdk:"timestamp"`
}

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

func (r *TimeStaticResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
    resp.Schema = schema.Schema{
        MarkdownDescription: "Provides a static timestamp resource.",
        Attributes: map[string]schema.Attribute{
            "id": schema.StringAttribute{
                Computed:            true,
                MarkdownDescription: "Resource identifier",
            },
            "timestamp": schema.StringAttribute{
                Computed:            true,
                MarkdownDescription: "Static timestamp in RFC3339 format",
            },
        },
    }
}

func (r *TimeStaticResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
    var data TimeStaticResourceModel

    resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)

    if resp.Diagnostics.HasError() {
        return
    }

    // Generate timestamp
    timestamp := time.Now().UTC().Format(time.RFC3339)
    data.Timestamp = types.StringValue(timestamp)
    data.ID = types.StringValue(timestamp)

    resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}

func (r *TimeStaticResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var data TimeStaticResourceModel

    resp.Diagnostics.Append(req.State.Get(ctx, &data)...)

    if resp.Diagnostics.HasError() {
        return
    }

    // Timestamp is static, no updates needed
    resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}

func (r *TimeStaticResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
    var data TimeStaticResourceModel

    resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)

    if resp.Diagnostics.HasError() {
        return
    }

    resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}

func (r *TimeStaticResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
    // Nothing to do - timestamp just disappears from state
}

Step 6: Build Provider

# Build the provider
go build -o terraform-provider-time

# Verify binary exists
ls -lh terraform-provider-time

Expected output:

-rwxr-xr-x  1 user  staff    23M Nov 15 10:30 terraform-provider-time

Step 7: Install Provider Locally

Terraform looks for providers in specific directories. Let’s install there.

7.1 Determine Installation Path

macOS/Linux ARM64 (M1/M2):

PROVIDER_PATH=~/.terraform.d/plugins/registry.terraform.io/YOUR_USERNAME/time/0.1.0/darwin_arm64

macOS/Linux AMD64:

PROVIDER_PATH=~/.terraform.d/plugins/registry.terraform.io/YOUR_USERNAME/time/0.1.0/darwin_amd64

Linux AMD64:

PROVIDER_PATH=~/.terraform.d/plugins/registry.terraform.io/YOUR_USERNAME/time/0.1.0/linux_amd64

Windows AMD64:

$PROVIDER_PATH="$env:APPDATA\terraform.d\plugins\registry.terraform.io\YOUR_USERNAME\time\0.1.0\windows_amd64"

Replace YOUR_USERNAME with your actual username!

7.2 Create Directory and Install

# Create directory
mkdir -p $PROVIDER_PATH

# Copy binary
cp terraform-provider-time $PROVIDER_PATH/

# Verify
ls -lh $PROVIDER_PATH/

Step 8: Create Terraform Configuration

Create a test directory:

mkdir -p ~/terraform-test
cd ~/terraform-test

Create main.tf:

terraform {
  required_providers {
    time = {
      source = "registry.terraform.io/YOUR_USERNAME/time"
      version = "0.1.0"
    }
  }
}

provider "time" {}

resource "time_static" "example" {}

output "timestamp" {
  value = time_static.example.timestamp
}

Remember: Replace YOUR_USERNAME!

Step 9: Initialize Terraform

terraform init

Expected output:

Initializing the backend...

Initializing provider plugins...
- Finding registry.terraform.io/YOUR_USERNAME/time versions matching "0.1.0"...
- Installing registry.terraform.io/YOUR_USERNAME/time v0.1.0...
- Installed registry.terraform.io/YOUR_USERNAME/time v0.1.0 (unauthenticated)

Terraform has been successfully initialized!

Step 10: Plan and Apply

10.1 Create Plan

terraform plan

Expected output:

Terraform will perform the following actions:

  # time_static.example will be created
  + resource "time_static" "example" {
      + id        = (known after apply)
      + timestamp = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + timestamp = (known after apply)

10.2 Apply Configuration

terraform apply -auto-approve

Expected output:

time_static.example: Creating...
time_static.example: Creation complete after 0s [id=2024-11-15T10:30:45Z]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

timestamp = "2024-11-15T10:30:45Z"

10.3 Verify State

terraform show

Expected output:

# time_static.example:
resource "time_static" "example" {
    id        = "2024-11-15T10:30:45Z"
    timestamp = "2024-11-15T10:30:45Z"
}

Outputs:

timestamp = "2024-11-15T10:30:45Z"

Step 11: Test Modifications

11.1 Run Apply Again

terraform apply -auto-approve

Expected: No changes (timestamp is static)

time_static.example: Refreshing state... [id=2024-11-15T10:30:45Z]

No changes. Your infrastructure matches the configuration.

11.2 Destroy Resource

terraform destroy -auto-approve

Expected output:

time_static.example: Destroying... [id=2024-11-15T10:30:45Z]
time_static.example: Destruction complete after 0s

Destroy complete! Resources: 1 destroyed.

Success Criteria

You’ve successfully completed the lab if:

  • ✅ Provider compiles without errors
  • ✅ terraform init succeeds
  • ✅ terraform plan shows resource creation
  • ✅ terraform apply creates resource
  • ✅ Output shows valid RFC3339 timestamp
  • ✅ terraform destroy removes resource

Lab Enhancements (Optional)

Want to go further? Try these:

Enhancement 1: Add Configuration

Modify provider to accept a custom prefix:

type TimeProviderModel struct {
    Prefix types.String `tfsdk:"prefix"`
}

// In Schema():
resp.Schema = schema.Schema{
    Attributes: map[string]schema.Attribute{
        "prefix": schema.StringAttribute{
            Optional: true,
        },
    },
}

Enhancement 2: Add Validation

Require timestamps to be after a certain date:

"timestamp": schema.StringAttribute{
    Computed: true,
    Validators: []validator.String{
        // Add custom validator
    },
}

Enhancement 3: Add Data Source

Create a data source that returns current time:

func NewTimeCurrentDataSource() datasource.DataSource {
    return &TimeCurrentDataSource{}
}

Common Lab Issues

Issue: Provider Not Found

Error:

Error: Failed to query available provider packages

Solutions:

  1. Check installation path matches your OS/architecture
  2. Verify username matches in all files
  3. Run terraform init again

Issue: Build Fails

Error:

undefined: provider.New

Solutions:

  1. Verify all imports are correct
  2. Run go mod tidy
  3. Check internal/provider/provider.go exports New() function

Issue: Import Errors

Error:

no required module provides package github.com/YOUR_USERNAME/terraform-provider-time/internal/provider

Solution:

go mod tidy
go build

Issue: Wrong Architecture

Error:

incompatible architecture

Solution: Rebuild for correct architecture:

# For ARM64 Mac
GOARCH=arm64 GOOS=darwin go build

# For AMD64 Mac
GOARCH=amd64 GOOS=darwin go build

# For Linux
GOARCH=amd64 GOOS=linux go build

Debugging Tips

Enable Debug Logging

TF_LOG=DEBUG terraform apply

Check Provider Path

# macOS/Linux
ls -R ~/.terraform.d/plugins/

# Windows
dir /s %APPDATA%\terraform.d\plugins\

Verify Binary

./terraform-provider-time --help

Should show:

Usage of ./terraform-provider-time:
  -debug
    	set to true to run the provider with support for debuggers

Clean Up

After completing the lab:

# Remove test directory
cd ~
rm -rf ~/terraform-test

# Keep provider for future use, or remove:
rm -rf ~/.terraform.d/plugins/registry.terraform.io/YOUR_USERNAME/time

Key Takeaways

  • ✅ Provider development follows standard Go project structure
  • ✅ Local installation enables rapid testing
  • ✅ Terraform init resolves providers from local cache
  • ✅ Plugin Framework simplifies resource implementation
  • ✅ Hands-on practice solidifies concepts

What’s Next?

Test your knowledge with practice questions!

Next: Practice Questions →


Previous: ← Dependencies and Modules

Last updated: June 30, 2026

Updated: