← All posts

Building Your First Terraform Provider with Pyvider

This is part 1 of a four-part series. Part 2 adds a data source. Part 3 adds a provider function. Part 4 adds an ephemeral resource.

Get the code: all four parts live in provide-io/pyvider-tutorial. Clone and follow along:

git clone https://github.com/provide-io/pyvider-tutorial.git
cd pyvider-tutorial/part1-resource
uv sync && uv run pyvider install && tofu init && tofu apply

This tutorial walks through building a minimal Terraform provider in Python using Pyvider. By the end you’ll have a working provider that creates, reads, updates, and deletes a simple server resource.

Prerequisites

  • Python 3.11+
  • uv or pip
  • Terraform or OpenTofu installed

Setup

uv init my-provider
cd my-provider
uv add pyvider

Define Your Provider

Create my_provider/__init__.py:

from pyvider.providers import BaseProvider, ProviderMetadata, register_provider
from pyvider.schema import PvsSchema, s_provider


@register_provider("mycloud")
class MyCloudProvider(BaseProvider):
    """A minimal cloud provider."""

    def __init__(self) -> None:
        super().__init__(
            metadata=ProviderMetadata(
                name="mycloud",
                version="0.1.0",
                protocol_version="6",
            )
        )

    @classmethod
    def get_schema(cls) -> PvsSchema:
        return s_provider({})  # No provider-level config needed yet

Define a Resource

Create my_provider/server.py. The resource stores servers in a class-level dict (standing in for a real API) so that other components — like the data source in part 2 — can query the same state.

from typing import Any, ClassVar

from attrs import define
from pyvider.resources import BaseResource, ResourceContext, register_resource
from pyvider.schema import PvsSchema, a_str, s_resource


@define
class ServerConfig:
    name: str


@define
class ServerState:
    id: str
    name: str
    status: str


@register_resource("server")
class Server(BaseResource):
    config_class = ServerConfig
    state_class = ServerState

    # In-memory store — replace with real API calls in production
    _servers: ClassVar[dict[str, dict[str, Any]]] = {}
    _next_id: ClassVar[int] = 1

    @classmethod
    def get_schema(cls) -> PvsSchema:
        return s_resource({
            "id":     a_str(computed=True, description="Server identifier"),
            "name":   a_str(required=True,  description="Server name"),
            "status": a_str(computed=True,  description="Server status"),
        })

    async def _validate_config(self, config: ServerConfig) -> list[str]:
        if not config.name:
            return ["name cannot be empty"]
        return []

    async def _create_apply(self, ctx: ResourceContext) -> tuple[ServerState | None, None]:
        server_id = f"srv-{Server._next_id:03d}"
        Server._next_id += 1
        data = {"id": server_id, "name": ctx.config.name, "status": "running"}
        Server._servers[server_id] = data
        return ServerState(**data), None

    async def read(self, ctx: ResourceContext) -> ServerState | None:
        data = Server._servers.get(ctx.state.id)
        return ServerState(**data) if data else None

    async def _update_apply(self, ctx: ResourceContext) -> tuple[ServerState | None, None]:
        data = Server._servers[ctx.state.id]
        data["name"] = ctx.config.name
        return ServerState(**data), None

    async def _delete_apply(self, ctx: ResourceContext) -> None:
        Server._servers.pop(ctx.state.id, None)

Create the Terraform Configuration

Create main.tf:

terraform {
  required_providers {
    mycloud = {
      source  = "example.com/tutorial/mycloud"
      version = "0.1.0"
    }
  }
}

provider "mycloud" {}

resource "mycloud_server" "web" {
  name = "web-01"
}

output "server_id"     { value = mycloud_server.web.id }
output "server_status" { value = mycloud_server.web.status }

Run It

pyvider install   # registers the provider with Terraform
terraform init
terraform plan
terraform apply

pyvider install writes a shim into Terraform’s plugin directory that activates your virtualenv and runs your provider.

After apply you should see server_id = "srv-001" and server_status = "running" in the output.

Required Methods on Every Resource

MethodPurpose
get_schemaDeclares attributes Terraform sees
_validate_configReturn a list of errors, or [] to pass
readRefresh state from the real backend
_delete_applyDestroy the resource

_create_apply and _update_apply have defaults (pass planned state through), but you’ll want to override them to call your real API.


Next: Part 2 — Adding a Data Source to query your servers without managing them.