Skip to content

Manage the deployment with Terraform

Put a cluster’s deployment configuration under Terraform: providers, credentials, budgets, profiles and guardrails, the keys a deployment document holds. It goes beside whatever else already writes configuration, without any of them being able to flatten the others.

The arrangement rests on one idea, which is not Terraform’s: configuration is stored as named sources, each owned by one writer. Give Terraform a source of its own and there is nothing to contend over. That holds not because the provider is careful, but because a source has one owner and the composition refuses an overlap out loud. Read Manage configuration through the API first if that is new. Everything below assumes it.

You need a cluster. Sources are stored in the raft log, so a single-node deployment being served from its file has nowhere to keep one and these endpoints answer 501.

You also need a token, and there is no way to make one here. The gateway is a resource server that verifies tokens and mints none. In CI the token comes from your identity provider and lands in PISTRA_TOKEN. It is either a client-credentials grant for the pipeline, or the CI platform’s own OIDC token if your IdP will exchange it. Against a dev gateway whose admin listener is on loopback and needs no token, leave it empty.

The provider is not on the Terraform registry yet. Build it and point Terraform at the binary with a dev override:

Terminal window
$ cd terraform-provider-pistra && go build -o ../bin/terraform-provider-pistra .

Then write the override into ~/.terraformrc:

~/.terraformrc
provider_installation {
dev_overrides {
"pistra-dev/pistra" = "/path/to/pistra/bin"
}
direct {}
}

The address has to be pistra-dev/pistra exactly. A dev override under any other name is not an error. Terraform does not apply it, and then fails to find a provider it was told where to get.

Pick a name and grant it, in the same policy file that grants everybody else:

@id("terraform-owns-its-own")
permit(
principal in Role::"terraform",
action == Action::"putConfigSource",
resource in Resource::"config/terraform"
);

That line is the whole of who may write it. in covers the source and every object in its document; a write is decided once per object it changes, and this grant answers for all of them. See Restrict what an admin caller can do for the rest of the policy.

Use the following example:

terraform {
required_providers {
pistra = {
source = "pistra-dev/pistra"
}
}
}
provider "pistra" {
endpoint = "https://gw.example:8485" # or PISTRA_ENDPOINT
token = var.admin_token # or PISTRA_TOKEN
ca_certificate_pem = file("${path.module}/gateway-ca.pem")
}

ca_certificate_pem is there because the front-door deployment mints its own certificate, so the system trust pool is usually the wrong answer for it. Leave it out where the gateway carries a certificate the machine already trusts.

They differ by what they claim ownership of:

resource claims reach for it when
pistra_config_object one element of a name-keyed collection most of the time
pistra_config_field one single-owner top-level field the thing you are managing is a block, not a list element
pistra_config_source a whole source document the deployment is rendered entirely from HCL and nothing else writes it

pistra_config_object takes a kind of providers, credentials, budgets, profiles, mcp, a2a or access_rules, and a name that is the element’s identity. The first object written to a source creates it.

resource "pistra_config_object" "openai" {
source = "terraform"
kind = "providers"
name = "openai"
body = <<-EOT
preset: openai
credential: openai-key
EOT
}
resource "pistra_config_object" "openai_key" {
source = "terraform"
kind = "credentials"
name = "openai-key"
body = "api_key: $${secret:OPENAI_API_KEY}"
}

The $${ is Terraform’s escape for a literal ${. The gateway receives ${secret:OPENAI_API_KEY}, a reference to a value written once into the sealed store and never read back. Terraform never sees the credential. A secret in HCL is a secret in the state file. See Store a secret through the API.

pistra_config_field takes the fields composition gives exactly one owner across all sources: guardrails, reconcile, models, auth, max_body_bytes, default_provider, on_fidelity_loss, public_url.

resource "pistra_config_field" "guardrails" {
source = "terraform"
kind = "guardrails"
value = file("${path.module}/guardrails.yaml")
}

Keep the guardrail policy in its own file rather than a heredoc. That file is the one pistra guardrails eval and the rule suite can be pointed at before the apply.

There is no way to write an empty field, and the zero value is refused. “Set this to nothing” and “do not set this” are different intentions, and only one of them is expressible. Destroy the resource to unset it.

Every write is validated the whole way to a snapshot before anything is stored, so the failure arrives at terraform apply rather than at the next fleet restart:

Terminal window
$ terraform apply
│ Error: invalid deployment: providers[0] (openai): unknown credential "openai-key"

That is the gateway’s own sentence, from the same validation the config file goes through. A document the deployment cannot build does not become a stored revision that every node then declines to load.

Three behaviours are worth knowing before you meet them:

  • Object writes race safely. Terraform applies resources in parallel, and several objects of one source arriving at once retry past each other’s revisions. You do not need depends_on between objects to serialise them.
  • Whole-source writes never retry. A moved revision under pistra_config_source means a second writer on a document that is supposed to have one, and the error says to go find them rather than quietly winning the race.
  • YAML compares semantically. The server re-renders what it splices. State absorbs the rendering without reading it as drift, so respelling your HCL plans as the no-op it is.

A team’s configuration usually references the platform’s, for example a profile naming a provider somebody else defines. Read it. Do not copy it:

data "pistra_config_source" "platform" {
name = "platform"
}

document is the stored YAML byte for byte and revision is what it was last written at. Both are useful in a precondition when your configuration is only valid against a platform document that has something in it.

Nothing has to be recreated to come under management:

Terminal window
$ terraform import pistra_config_object.openai providers/openai
$ terraform import pistra_config_field.guardrails guardrails
$ terraform import pistra_config_source.platform platform

An object is imported by kind and name, and the source it lives in is what the read reports. Importing does not move an object between sources. Moving it means writing it into the new source and deleting it from the old. Taking those steps in that order leaves both defined for the moment in between, and the composition refuses that. Delete first.

Three things are left out deliberately:

  • The node file: listeners, cluster, admin, tls, the directories. Those configure a process rather than a deployment and take effect from that node’s own file, which is the chart’s or the machine image’s job. The split is the subject of Two configuration documents.
  • Secret values. ${secret:NAME} references them. The sealed store holds them. A Terraform resource that carried the value would put it in the state file. That is the problem the store exists to solve.
  • Virtual keys. They are minted, shown once and revoked, not declared, so a resource whose terraform destroy revoked a key somebody is using would be a footgun with a plan file. Use pistra admin or the console.