# Creating a Dokploy Terraform provider

> What it takes to write a Terraform provider in Go for a young API - and why the acceptance tests talk to a real server.

- date: 2026-08-22
- tags: go, terraform, dokploy

I wanted `terraform apply` to be the only deploy button on my personal
infrastructure. Dokploy had an API and no living provider, so now there is
[`vanillauys/dokploy`](https://registry.terraform.io/providers/vanillauys/dokploy):
written in Go on the terraform-plugin-framework, at v0.10.0 after ten minor releases.

Here's the path from an empty repo to a provider on the registry, with the lessons
where they hurt.

## 1. Scaffold on the plugin framework

Start from HashiCorp's
[scaffolding template](https://github.com/hashicorp/terraform-provider-scaffolding-framework)
and keep the standard shape: a thin API client package that knows nothing about
Terraform, and a resource layer that maps schema to client calls.

```go
func (r *ProjectResource) Create(ctx context.Context,
	req resource.CreateRequest, resp *resource.CreateResponse) {
	var plan ProjectModel
	resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)

	project, err := r.client.CreateProject(ctx, plan.Name.ValueString())
	// map the response back into state, nothing clever
}
```

The framework does the heavy lifting: plan diffs, state upgrades, unknown-value
propagation. Your job is to be honest about the API you are wrapping.

## 2. Test against the real thing

The acceptance suite runs against an actual Dokploy instance in CI on every pull
request - a container on the runner's own Docker, no docker-in-docker:

```bash
TF_ACC=1 go test ./internal/provider/ -run TestAcc -v
```

Mocks would have passed builds that failed against the real server, because a young
API has opinions the docs do not mention. Every one of these came out of a failing
acceptance test, not a bug report:

- Computed collections need `UseStateForUnknown`, or every plan recomputes them and
  downstream references cascade into unknowns.
- Where the API recomputes a collection anyway, the answer is a data source and
  documentation, not wishful state.
- Some deletes are asynchronous. `Delete` has to poll until the resource is actually
  gone, or the next test's `Create` collides with a name that still exists.

## 3. Release through the registry

The Terraform registry wants tagged releases with signed artifacts and a manifest;
goreleaser produces the whole matrix from one config, and the registry ingests each
tag within minutes:

```bash
git tag v0.10.0
git push origin v0.10.0   # CI runs goreleaser; the registry picks up the tag
```

After that, releasing often is cheap - six of the ten minors shipped in single-day
bursts as the resource surface grew. Generate the docs with
[tfplugindocs](https://github.com/hashicorp/terraform-plugin-docs) so the registry
pages and the repo never drift.

## 4. Hold v1.0 until it earns it

v1.0 is deliberately not tagged yet. The rule: the provider must survive one Dokploy
minor upgrade without breaking changes first. A 1.0 is a promise about the future,
and I want at least one data point before making it.