Automating Infrastructure Validation: Writing Custom Terraform Providers for Internal Tooling
Learn how to build a custom Terraform provider to manage proprietary internal infrastructure and enforce organization-specific validation logic.
Introduction
Infrastructure as Code (IaC) has become the industry standard for managing cloud resources, but standard providers often fall short when dealing with proprietary, legacy, or highly specialized internal systems. While tools like automating-infrastructure-testing-terratest-terraform help validate existing deployments, there are scenarios where you need to treat internal APIs or custom hardware controllers as first-class resources within your Terraform workflow. Writing a custom Terraform provider allows you to bridge this gap, enabling you to manage non-standard infrastructure using the same declarative syntax and state management you use for AWS or GCP.
Why Build a Custom Provider?
Most teams start by using the null_resource or external data source to execute shell scripts that interact with internal APIs. While functional, this approach lacks the benefits of the Terraform lifecycle: state tracking, dependency management, and drift detection. By building a custom provider, you gain:
- Declarative State: Terraform tracks the actual state of your internal resource, preventing configuration drift.
- Dependency Graph: You can define relationships between your custom resources and standard cloud resources (e.g., a database must exist before the internal application configuration is applied).
- Validation: You can enforce complex schema validation rules during the
planphase, catching errors before execution. - Unified Workflow: Developers use
terraform planandapplyinstead of custom CLI wrappers or CI/CD scripts.
Architecture of a Terraform Provider
Terraform providers are written in Go and communicate with the Terraform CLI via a gRPC interface. The provider acts as a plugin that implements the terraform-plugin-framework or the older terraform-plugin-sdk. For new projects, the framework is the recommended choice due to its improved type safety and better handling of complex state transitions.
The Plugin Lifecycle
When you run a Terraform command, the CLI initializes the provider binary. The provider then exposes three primary components:
- Provider Configuration: Handles authentication and global settings (e.g., API base URLs, tokens).
- Resources: The core logic for CRUD (Create, Read, Update, Delete) operations on your infrastructure components.
- Data Sources: Read-only interfaces to fetch information about existing infrastructure.
Setting Up Your Development Environment
To begin, ensure you have Go installed (version 1.21 or later is recommended). Initialize a new Go module for your provider:
mkdir terraform-provider-internal
cd terraform-provider-internal
go mod init github.com/your-org/terraform-provider-internal
You will need the terraform-plugin-framework to define your resources. Add the dependency:
go get github.com/hashicorp/terraform-plugin-framework
Implementing a Simple Resource
Let's assume you have an internal service that manages user permissions via an HTTP API. We want to create a user_permission resource. Every resource requires a schema definition and a set of methods to handle the lifecycle.
Defining the Schema
In the framework, you define the schema using the Schema method. This tells Terraform what attributes the resource expects and how to validate them.
func (r *userPermissionResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"username": schema.StringAttribute{
Required: true,
},
"role": schema.StringAttribute{
Required: true,
},
},
}
}
Implementing CRUD Operations
Each resource must implement Create, Read, Update, and Delete. The Read method is particularly important as it is used to refresh the state and detect drift.
func (r *userPermissionResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state userPermissionModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
// Call your internal API to fetch the current state
data, err := r.client.GetUserPermission(state.Username.ValueString())
if err != nil {
resp.Diagnostics.AddError("Client Error", err.Error())
return
}
// Update the state with the actual values from the API
state.Role = types.StringValue(data.Role)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
Testing and Verification
Testing a custom provider requires a mix of unit tests for your API client logic and acceptance tests for the Terraform integration. The terraform-plugin-testing library is the standard tool for this.
Acceptance Testing
Acceptance tests perform actual API calls against a test environment. You should define a test configuration that creates, modifies, and destroys a resource.
func TestAccUserPermission(t *testing.T) {
resource.Test(t, resource.TestCase{
Providers: testAccProviders,
Steps: []resource.TestStep{
{
Config: `resource "internal_user_permission" "test" { username = "dev", role = "admin" }`,
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("internal_user_permission.test", "role", "admin"),
),
},
},
})
}
To verify your provider works as expected, run go test ./.... Ensure your environment variables for the internal API are set correctly before running these tests.
Deployment and Distribution
Once your provider is stable, you need to distribute it to your team. The most common approach is to use a private Terraform Registry or a local filesystem mirror. For internal tools, a filesystem mirror is often sufficient.
Create a directory structure that matches the provider's namespace and version:
~/.terraform.d/plugins/registry.terraform.io/your-org/internal/1.0.0/linux_amd64/terraform-provider-internal
Then, configure your Terraform project to look for this local provider:
terraform {
required_providers {
internal = {
source = "your-org/internal"
version = "1.0.0"
}
}
}
Operational Considerations
When building a custom provider, you are essentially building a piece of infrastructure software. Consider these operational realities:
- Error Handling: Always provide clear error messages. If an API call fails, return a diagnostic that explains why it failed, not just that it did.
- Rate Limiting: If your internal API has strict rate limits, implement backoff and retry logic within your provider's client. Terraform's concurrent execution can easily overwhelm legacy systems.
- Security: Never hardcode credentials in your provider. Use standard environment variables or Terraform provider configuration blocks to pass secrets securely.
- Versioning: Follow semantic versioning. Breaking changes in your API should be reflected in a major version bump of the provider to prevent breaking existing infrastructure deployments.
Conclusion
Building a custom Terraform provider is a high-leverage investment for teams managing proprietary infrastructure. By encapsulating your internal logic within a standard provider, you reduce the surface area for manual errors and provide a consistent interface for your developers. While it requires a deeper understanding of Go and the Terraform plugin framework, the result is a more resilient and manageable infrastructure lifecycle.
Frequently Asked Questions
When should I build a custom provider instead of using null_resource?
You should build a custom provider when you need to maintain state, track dependencies, or perform complex validation that nullresource cannot handle. While nullresource is fine for one-off scripts, it lacks the ability to detect drift or manage the lifecycle of resources, which makes it unsuitable for production-grade infrastructure management.
Do I need to know Go to write a custom provider?
Yes, Terraform providers are written in Go, and you will need to be comfortable with the language to implement the resource logic and interact with the plugin framework. While you don't need to be a Go expert, you should be familiar with struct definitions, interface implementation, and error handling patterns in Go.
How do I handle authentication for my internal API?
Authentication should be handled in the provider's Configure method, where you extract credentials from the provider block or environment variables. These credentials should then be passed into your API client, ensuring that sensitive information is never logged or stored in the Terraform state file.
Can I use a custom provider for non-cloud infrastructure?
Yes, custom providers are excellent for managing non-cloud infrastructure like internal databases, proprietary hardware controllers, or legacy application configuration files. As long as the system has an API or a CLI that you can interact with via Go, you can wrap it in a Terraform provider to manage it declaratively.