linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
Intro
Lessons
Footer
linuxlab-TutorialsPricingAboutPrivacy & cookies
Copyright © 2026 LinuxLab. All rights reserved.
linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
  • Introduction
  • Lessons
  • How it works
  • Knowledge base
  • Cheat sheet
  • Capstone
  • Interview prep
home/terraform/lessons/tf-advanced-03-custom-provider

lesson ── terraform-advanced ── ~22 мин ── 6 шагов

Your own Terraform provider, Go and the Plugin Framework

A minimal provider of your own, written in Go: a localfile_marker resource that creates a file on the runner's disk and tracks it in state. It is a toy, but the model is the same one production providers use: Schema, CRUD methods, dev_overrides for local testing.

▶ интерактивный sandbox

Поднимется пара контейнеров: terraform 1.9 и localstack 3.8 в одной сети. В браузере откроется терминал, можно сразу terraform init. Каждый шаг проверяется автоматически. TTL 45 минут, без регистрации.

запустить sandbox →

stack ── terraform · localstack · 1 GB RAM · самоуничтожается через 45 мин простоя

Шаги

  1. 01

    Scaffold the Go provider

    bash
    cd /home/student/tf-provider-demo
    go mod init terraform-provider-localfile
    go get github.com/hashicorp/terraform-plugin-framework@latest
    go get github.com/hashicorp/terraform-plugin-framework/types@latest

    This pulls in the Plugin Framework. Next, main.go and the provider itself.

    ✓ Go module initialized.

  2. 02

    main.go and provider.go

    bash
    cat > main.go <<'EOF'
    package main
    import (
        "context"
        "log"
        "github.com/hashicorp/terraform-plugin-framework/providerserver"
    )
    func main() {
        err := providerserver.Serve(context.Background(),
            New("dev"),
            providerserver.ServeOpts{
                Address: "registry.terraform.io/example/localfile",
            },
        )
        if err != nil {
            log.Fatal(err.Error())
        }
    }
    EOF
    cat > provider.go <<'EOF'
    package main
    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"
    )
    type LocalFileProvider struct {
        version string
    }
    func New(version string) func() provider.Provider {
        return func() provider.Provider {
            return &LocalFileProvider{version: version}
        }
    }
    func (p *LocalFileProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
        resp.TypeName = "localfile"
        resp.Version = p.version
    }
    func (p *LocalFileProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
        resp.Schema = schema.Schema{}
    }
    func (p *LocalFileProvider) Configure(_ context.Context, _ provider.ConfigureRequest, _ *provider.ConfigureResponse) {
    }
    func (p *LocalFileProvider) Resources(_ context.Context) []func() resource.Resource {
        return []func() resource.Resource{
            NewMarkerResource,
        }
    }
    func (p *LocalFileProvider) DataSources(_ context.Context) []func() datasource.DataSource {
        return nil
    }
    EOF

    The provider has an empty schema (no api_token) and one resource, Marker.

    ✓ Provider skeleton is ready. Now the Resource.

  3. 03

    The localfile_marker resource

    bash
    cat > marker.go <<'EOF'
    package main
    import (
        "context"
        "fmt"
        "os"
        "github.com/hashicorp/terraform-plugin-framework/resource"
        "github.com/hashicorp/terraform-plugin-framework/resource/schema"
        "github.com/hashicorp/terraform-plugin-framework/types"
    )
    type MarkerResource struct{}
    func NewMarkerResource() resource.Resource {
        return &MarkerResource{}
    }
    type MarkerModel struct {
        Path    types.String `tfsdk:"path"`
        Content types.String `tfsdk:"content"`
        ID      types.String `tfsdk:"id"`
    }
    func (r *MarkerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
        resp.TypeName = req.ProviderTypeName + "_marker"
    }
    func (r *MarkerResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
        resp.Schema = schema.Schema{
            Attributes: map[string]schema.Attribute{
                "path": schema.StringAttribute{
                    Required:    true,
                    Description: "Filesystem path to create",
                },
                "content": schema.StringAttribute{
                    Required:    true,
                    Description: "File content",
                },
                "id": schema.StringAttribute{
                    Computed: true,
                },
            },
        }
    }
    func (r *MarkerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
        var plan MarkerModel
        resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
        if resp.Diagnostics.HasError() {
            return
        }
        if err := os.WriteFile(plan.Path.ValueString(), []byte(plan.Content.ValueString()), 0644); err != nil {
            resp.Diagnostics.AddError("write file", err.Error())
            return
        }
        plan.ID = types.StringValue(fmt.Sprintf("marker:%s", plan.Path.ValueString()))
        resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
    }
    func (r *MarkerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
        var state MarkerModel
        resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
        if resp.Diagnostics.HasError() {
            return
        }
        data, err := os.ReadFile(state.Path.ValueString())
        if err != nil {
            if os.IsNotExist(err) {
                resp.State.RemoveResource(ctx)
                return
            }
            resp.Diagnostics.AddError("read file", err.Error())
            return
        }
        state.Content = types.StringValue(string(data))
        resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
    }
    func (r *MarkerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
        var plan MarkerModel
        resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
        if resp.Diagnostics.HasError() {
            return
        }
        if err := os.WriteFile(plan.Path.ValueString(), []byte(plan.Content.ValueString()), 0644); err != nil {
            resp.Diagnostics.AddError("write file", err.Error())
            return
        }
        plan.ID = types.StringValue(fmt.Sprintf("marker:%s", plan.Path.ValueString()))
        resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
    }
    func (r *MarkerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
        var state MarkerModel
        resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
        if resp.Diagnostics.HasError() {
            return
        }
        _ = os.Remove(state.Path.ValueString())
    }
    EOF

    All four CRUD methods are covered. Create writes the file, Read checks that it still exists, Update rewrites it, Delete removes it.

    ✓ The resource with CRUD is written. Now the build.

  4. 04

    Build and dev_overrides

    bash
    go mod tidy 2>&1 | tail -3
    go install . 2>&1 | tail -3
    ls $HOME/go/bin/terraform-provider-localfile

    The binary is ready. Now tell Terraform to use it instead of a registry provider:

    bash
    cat > $HOME/.terraformrc <<EOF
    provider_installation {
      dev_overrides {
        "example/localfile" = "$HOME/go/bin"
      }
      direct {}
    }
    EOF

    From now on, any terraform plan/apply with source = "example/localfile" picks up your binary.

    ✓ Provider compiled and the override is set.

  5. 05

    A Terraform config with a custom provider

    bash
    mkdir -p /home/student/tf-provider-demo/example
    cd /home/student/tf-provider-demo/example
    cat > main.tf <<'EOF'
    terraform {
      required_providers {
        localfile = {
          source = "example/localfile"
        }
      }
    }
    provider "localfile" {}
    resource "localfile_marker" "hello" {
      path    = "/tmp/marker.txt"
      content = "hello from custom provider"
    }
    EOF
    terraform plan 2>&1 | tail -15
    terraform apply -auto-approve 2>&1 | tail -10
    cat /tmp/marker.txt

    The file is created by your own provider. State holds the resource localfile_marker.hello. That is a full Terraform CRUD loop over your code.

    ✓ The custom provider works end to end.

    The same thing on OpenTofu

    OpenTofu keeps its CLI and state compatible with Terraform for the commands in this step: migration usually goes through mv .terraform .terraform.bak; tofu init -upgrade. On your first switch, though, back up the state and do a run on a feature branch, since the differences cluster in the newer features (variables in the backend, state encryption, OCI registry-backed modules). See tf-opentofu-parity for the full matrix.

    • → OpenTofu parity
  6. 06

    Update through your own provider

    bash
    sed -i 's|hello from custom provider|updated content|' main.tf
    terraform plan 2>&1 | tail -10
    terraform apply -auto-approve 2>&1 | tail -10
    cat /tmp/marker.txt

    The plan shows ~ content, and apply calls the Update method. The file is rewritten without a destroy/create.

    Destroy:

    bash
    terraform destroy -auto-approve 2>&1 | tail -10
    ls /tmp/marker.txt 2>&1 || echo "file gone"

    A full CRUD cycle.

    ✓ The CRUD cycle is closed. Next, the scale of state.

    What a production provider needs

    This is a toy example. A production provider adds:

    1. Acceptance tests. TF_ACC=1 go test -v ./... against a real backend.
    2. Goreleaser for binaries. Multi-platform builds.
    3. GPG-signed releases. The registry requires it.
    4. Docs through tfplugindocs. Generated from the Schema descriptions.
    5. Schema versioning plus state migrations. When you change the schema, old states must be upgradeable.
    6. Importable resources. An ImportState method for terraform import.
    7. Validation in the Schema. validators.StringLengthAtLeast(3) and so on.

    See tf-provider-development and the official template repo terraform-provider-scaffolding-framework.

    • → Custom provider, the whole thing

Что ты узнал

Plugin Framework: Provider, then Resource, then Schema plus CRUD methods. go install . puts a binary in ~/go/bin/. A ~/.terraformrc with dev_overrides points at that binary. terraform plan/apply then uses your provider instead of a registry provider.

команды

  • go mod init terraform-provider-localfilestandard Go init.
  • go install .compile into $GOPATH/bin.
  • terraform planuses dev_overrides, so it runs your local binary.

концепции

  • · Provider Schema, the config your users write (api_token, endpoint)
  • · Resource Schema, what gets described in HCL (path, content)
  • · CRUD methods Create/Read/Update/Delete, each one gets a req/resp

← предыдущий

pre-commit hooks for Terraform

следующий →

Outputs: returning values to the outside

Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies