← All posts

Deploying Your Provider as a Binary

This is part 5 of a five-part series. The earlier parts cover a resource, a data source, a function, and an ephemeral resource.

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

git clone https://github.com/provide-io/pyvider-tutorial.git
cd pyvider-tutorial/part5-deploy
uv sync && uv run flavor pack && tofu init && tofu apply

Everything we’ve built so far has run through pyvider install — a wrapper script that activates a local venv and invokes Python. That’s great for iteration, but it’s not how you ship a provider to other people. They don’t have your venv. They don’t want to uv sync. They want a binary.

This is what Flavorpack does: it bundles your provider code, its Python dependencies, and a Python runtime into a single self-contained executable. The user drops the binary into ~/.terraform.d/plugins/ and it just works.

Dev Install vs Binary Deploy

pyvider install flavor pack
Output Wrapper shell script Single executable (~90 MB)
Requires Python on target yes no
Requires your venv yes no
Startup time instant (local Python) slower first run, fast after
Best for local development distribution to others

Same provider code, two different delivery mechanisms. You pick at build time.

Configure Flavorpack

Flavorpack reads pyproject.toml. Add a [tool.flavor] section to the provider we built in part 4:

[project]
name = "terraform-provider-mycloud"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["pyvider>=0.3.33", "flavorpack"]

[project.scripts]
terraform-provider-mycloud = "my_provider:main"

[project.entry-points."pyvider"]
mycloud = "my_provider"

[tool.pyvider]
provider_name = "mycloud"

[tool.flavor]
entry_point = "pyvider.cli:main"
output_path = "dist/terraform-provider-mycloud"

Three things changed:

  • flavorpack added to dependencies so uv run flavor works without a global install.
  • entry_point points at the same pyvider.cli:main that pyvider install uses. The binary launches that.
  • output_path is where the .psp file goes — Flavorpack appends .psp automatically.

Build

uv sync
uv run flavor pack

The first run takes a minute: Flavorpack downloads a pinned CPython build, resolves your dependencies, creates a slot tarball, and writes the whole thing into a PSPF (Progressive Secure Package Format) file with an Ed25519 signature.

🚀 Packaging application...
📋 Building PSPF package structure...
🔐 Setting up package signing keys...
✍️  Writing PSPF package file...
🔍 Verifying .../dist/terraform-provider-mycloud.psp...
  ✅ Package integrity verified
✅ Successfully built 1 package(s)

The result is one file: dist/terraform-provider-mycloud.psp. Run it directly and it extracts itself on first launch, caches the extraction, and then re-uses that cache on subsequent runs.

Install to Terraform’s Plugin Directory

Terraform discovers local providers under a platform-specific directory tree:

~/.terraform.d/plugins/
└── <source>/
    └── <version>/
        └── <platform>/
            └── terraform-provider-<name>

So for our mycloud provider at version 0.1.0 on macOS ARM64:

PLUGIN_DIR="$HOME/.terraform.d/plugins/local/providers/mycloud/0.1.0/darwin_arm64"
mkdir -p "$PLUGIN_DIR"
mv dist/terraform-provider-mycloud.psp "$PLUGIN_DIR/terraform-provider-mycloud"
chmod +x "$PLUGIN_DIR/terraform-provider-mycloud"

Two details to notice:

  • Strip the .psp extension. Terraform expects the binary name to match terraform-provider-<name> exactly.
  • Match the platform string. darwin_arm64, linux_amd64, windows_amd64, etc. Flavorpack builds for the platform it runs on; cross-compilation is a separate topic.

Use It

Your main.tf doesn’t change. The same provider block works against either the dev wrapper or the binary:

terraform {
  required_providers {
    mycloud = {
      source  = "local/providers/mycloud"
      version = "0.1.0"
    }
  }
}

provider "mycloud" {}

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

Run it:

tofu init
tofu apply -auto-approve
tofu output

On the first tofu apply, the binary extracts its embedded Python + dependencies to a cache directory. You’ll notice a pause. Every run after that reuses the cache and starts quickly — warm-start time is typically a few seconds.

What’s Actually in the Binary

A .psp file is a polyglot: valid executable at the front, structured package at the back. If you inspect one with a hex editor or run flavor inspect, you’ll find:

Section Contents
Launcher Native Rust binary that handles extraction and exec
Metadata Manifest: entry point, version, environment passthroughs
Python slot CPython interpreter for the target platform
App slot Your package + all Python dependencies (wheels)
Signature Ed25519 signature of the index and metadata
Trailer Offsets and checksums for O(1) lookup

The launcher verifies the signature, checks the integrity seal, extracts the app and Python slots into a cache keyed by package checksum, and then execs into your entry point. All of that happens before pyvider.cli:main sees control.

Signing and Trust

By default, flavor pack generates ephemeral signing keys per build. That’s fine for local testing. For distribution, generate a persistent keypair once and reuse it:

uv run flavor keygen --out-dir keys

Reference the keys in [tool.flavor.signing]:

[tool.flavor.signing]
private_key_path = "keys/provider-private.key"
public_key_path  = "keys/provider-public.key"

Commit the public key. Keep the private key in a secret manager and inject it at release time. Anyone running your binary can verify it was built with your private key and hasn’t been tampered with.

What’s in the Complete Provider

part5-deploy/
├── pyproject.toml      # now includes [tool.flavor]
├── main.tf
└── my_provider/
    ├── __init__.py         # provider class + component imports
    ├── server.py           # mycloud_server          (resource)
    ├── server_info.py      # mycloud_server_info     (data source)
    ├── names.py            # provider::mycloud::generate_name  (function)
    └── session_token.py    # mycloud_session_token   (ephemeral resource)

The Python code is identical to part 4. The only thing that changed is how you ship it.


That’s the series. You’ve built a provider with a resource, a data source, a function, and an ephemeral — and packaged it as a distributable binary.

Where to go next: