[Back to blog](https://zcp.zsoftly.ca/blog)
 [Engineering](https://zcp.zsoftly.ca/blog/category/engineering)

# Building the ZCP Terraform / OpenTofu Provider

ZCP infrastructure is now code. How we built our provider on the CLI SDK, what live testing taught us about state, networks, and public IPs, and why we ship to both registries with signed releases.

Ditah Kumbong

Founder & CTO

July 9, 2026

5 min read

Share

![The zsoftly/zcp provider page on the OpenTofu registry, showing version 0.1.0, the MIT license, and the required\_providers usage snippet.](https://zcp.zsoftly.ca/images/blog/2026/zcp-provider-opentofu-registry.webp)

You manage ZCP infrastructure as code now. The official provider is live on the [OpenTofu registry](https://search.opentofu.org/provider/zsoftly/zcp/latest) and the [Terraform Registry](https://registry.terraform.io/providers/zsoftly/zcp/latest), with 38 resources and 12 data sources covering everything our `zcp` CLI does: instances, volumes, snapshots and backups, VPCs and networks, firewall and egress rules, load balancers, auto-scaling, Kubernetes clusters, DNS, object storage, VPN, and account governance.

```hcl
terraform {
  required_providers {
    zcp = {
      source  = "zsoftly/zcp"
      version = "~> 0.1"
    }
  }
}
```

The same source address works with both tools. Here is what building it taught us.

## One SDK, two clients

The provider does not reimplement our API. It imports the same Go SDK the `zcp` CLI uses. Every request the provider sends is a request the CLI already sends. Authentication, retries, and platform quirks live in one place. When we fix a platform edge case in the SDK, both clients inherit the fix in their next release.

This forced a licensing decision. The CLI repository carried a source-available license. A Go binary statically links its dependencies. An MIT provider embedding a restricted SDK would have promised users rights we had not granted. We relicensed the CLI and its SDK under Apache-2.0. The dependency chain is now clean. Contributions carry a patent grant. Both registries serve a provider anyone is free to rebuild and redistribute.

## Declarative semantics are the hard part

Wrapping an API in resource schemas is the easy half of a provider. The hard half is honoring Terraform’s contract: a plan must tell the truth, an apply must converge, and a second plan must show no changes.

This contract surfaced platform behaviors a CLI user never notices:

- Cached state lies. Our control plane refreshes instance state lazily, so a VM reports `Starting` for minutes after it is running. A CLI user shrugs and reruns `list`. A provider blocking on `Running` would hang. We added a live state endpoint to the platform, and the provider polls it instead, reconciling against the hypervisor. Creates now return in about two minutes, exactly when the VM is up.
- DNS has no record IDs. Our DNS backend models records as sets addressed by name and type, the PowerDNS way. Faking per-record IDs would have produced resources Terraform has no reliable way to refresh or delete. Instead, `zcp_dns_record` embraces the model: one resource per name and type pair, with a synthetic ID built from both.
- Write-only fields need a plan. The API never returns an SSH key’s material or a sub-user’s password. Naive handling makes every `terraform import` plan a destroy-and-recreate. The provider adopts the configured value on the first apply after import, and only forces replacement when a known prior value changes.

## Live testing beats unit testing

Every resource has unit tests against fakes, but the findings we cared about came from applying real configurations against production. Two examples from one afternoon:

First, orphaned networks. Our instance resource accepts a `network_plan` shortcut, and the platform creates a network for the VM as a side effect. Terraform never sees it, so `destroy` removes the VM and leaves the network and its public address billing forever. The fix was not code, it was guidance: declare the network as its own resource and attach the instance to it. Our examples and tutorial now use the tracked pattern everywhere, and a full `destroy` leaves nothing behind. We verified the whole cycle live: apply, check the platform, destroy, confirm zero leftovers.

Second, the empty `public_ip`. On source-NAT networks the public address belongs to the network, not the VM, so the VM object never carries it and the attribute came back blank. The provider now resolves it from the account’s IP list by VM identifier, preferring static assignments. Outputs show the address the moment apply completes.

We also ran three adversarial review rounds over the codebase and verified every finding against the live API before fixing it. Several were platform bugs exposed by the provider’s strictness, not provider bugs. Those became backend tickets. The provider now fails loudly rather than recording state the platform did not honor.

## Signed releases, two registries, zero ceremony

CI builds and GPG-signs every release. The private key lives only in the release pipeline and in our vault. Both registries verify the signature against our published key before serving a version, and your `init` verifies it again on install.

We published to the [OpenTofu registry](https://search.opentofu.org/provider/zsoftly/zcp/latest) first. The whole process is two issue forms on a public GitHub repository. A bot validates the release artifacts, then a maintainer merges the request.

![The zsoftly/zcp provider page on the OpenTofu registry, showing version 0.1.0 and the usage snippet](https://zcp.zsoftly.ca/images/blog/2026/zcp-provider-opentofu-registry.webp)

The [Terraform Registry](https://registry.terraform.io/providers/zsoftly/zcp/latest) followed the same afternoon. Shipping a new version now takes one git tag. Both registries pick it up automatically.

![The zsoftly/zcp provider page on the Terraform Registry, version 0.1.0 in the Cloud Automation category](https://zcp.zsoftly.ca/images/blog/2026/zcp-provider-terraform-registry.webp)

## Try it

The [tutorial](https://docs.zcp.zsoftly.ca/tutorials/manage-infrastructure-terraform) takes you from an empty directory to a running VM and back to a clean account in about fifteen minutes, with OpenTofu and Terraform commands side by side. The provider reference lives on either registry, and the source is on [GitHub](https://github.com/zsoftly/terraform-provider-zcp) under MIT. Issues and contributions are welcome. Our first community contribution shipped in the CLI last week. We would be glad to see the same happen here.

If you are new to ZCP, [create an account](https://cloud.zcp.zsoftly.ca/register) and put your first `terraform apply` on Canadian infrastructure today.

## Related articles

Engineering

October 11, 2026

### [The Restart That Hid a Libvirt Bug](https://zcp.zsoftly.ca/blog/the-restart-that-hid-a-libvirt-bug)

A seven-minute VM create, two wrong turns, a self-healing timer I built and then deleted, and the package update that fixed it.

Engineering

October 5, 2026

### [Private Desktops: Lessons from Testing](https://zcp.zsoftly.ca/blog/what-we-learned-testing-private-employee-desktops)

What to test before a private desktop rollout: daily work, video calls, network paths, storage, and offboarding.

Engineering

September 29, 2026

### [What Do Private Desktops Cost?](https://zcp.zsoftly.ca/blog/what-private-employee-desktops-cost)

Compare private desktop costs: shared services, per-person workspaces, and Windows 365 pricing.
