best practices.md
Best Practices
Patterns for running the Barndoor Terraform provider in production, and the platform semantics worth knowing before you rely on it.
One owner per object
Give every Barndoor object exactly one owner: either Terraform or the portal, never both. If an admin edits a Terraform-managed policy in the app, the next terraform plan shows a diff and the next apply reverts their change — usually a surprise for both sides.
Practical rules:
- Agree which product areas are code-managed and note it where admins will see it (a policy's
descriptionortags = ["terraform-managed"]work well). - To reference portal-managed objects from Terraform without owning them, use the data sources:
barndoor_mcp_server,barndoor_agent,barndoor_policy,barndoor_llm_provider,barndoor_dlp_detection_engine, plus catalog lookupsbarndoor_mcp_server_directoryandbarndoor_agent_directory. - To take ownership of a portal-created object, import it (below) — from then on, hands off in the UI.
Secrets and state hygiene
- Supply
client_secretviaBARNDOOR_CLIENT_SECRET, never in configuration. In CI, use your platform's secret store (see CI/CD). - Mark your own secret inputs (
api_keyvariables and the like) assensitive = true. - Barndoor's write-only credential attributes (
barndoor_llm_provider.api_key, MCP server OAuth secrets, log-export access keys) are stored in the platform's secret store and never returned by the API — but the values you supplied still live in your Terraform state file. Treat state as secret: use a remote backend with encryption and restricted access. - Rotate the provider credential on a schedule that fits your team. Rotation keeps the client ID and invalidates only the secret — update
BARNDOOR_CLIENT_SECRETwherever it's stored, and remember one credential is active per organization, so every pipeline shares it.
Pin the provider version
tf
terraform {
required_providers {
barndoor = {
source = "barndoor-ai/barndoor"
version = "~> 0.3"
}
}
}
~> 0.3 accepts patch and minor updates within the 0.x line as they release. Check the changelog before widening the range.
Importing existing configuration
Every resource supports terraform import, so an organization configured through the portal can move to code incrementally. With Terraform 1.5+, prefer import blocks — they're reviewable and plannable:
import {
to = barndoor_policy.search_read_only
id = "6f1c9312-8f3e-4bde-9c2b-b3a1d1cf34a4"
}
tf
terraform plan -generate-config-out=generated.tf
Most resources import by their UUID, with these exceptions:
| Resource | Import ID |
|---|---|
barndoor_connection |
The server's ID or slug (a server has at most one tenant-wide connection) |
barndoor_log_export |
Your organization ID, or {organization_id}/{export_type} |
barndoor_llm_model_pricing |
The rule's logical identity: {model_provider}|{model_pattern}, or a bare {model_pattern} for provider-unscoped rules |
barndoor_dlp_org_config, barndoor_idp, barndoor_llm_governance_config |
Your organization ID (they're org singletons) |
Two things to expect right after an import:
- Write-only attributes come back empty. Credentials the platform never returns (OAuth secrets, API keys, log-export access keys) can't be populated by import. The first plan after importing will propose re-sending any such values present in your configuration — that in-place update is expected and safe.
- Some resources only adopt via import.
barndoor_idp,barndoor_dlp_field_control_policy, and livebarndoor_llm_model_pricingrules deliberately refuse to create over an existing object, so out-of-band setup is never silently overwritten — the error message directs you to import instead.
Destroy is not always delete
terraform destroy maps to whatever removal means for each object on the platform:
| Resource | What destroy actually does |
|---|---|
barndoor_policy |
Archives the policy (terminal lifecycle state, kept for audit; never hard-deleted) |
barndoor_llm_model_pricing |
Archives the rule with full version history; recreating the same rule restores it |
barndoor_mcp_server |
Soft-deletes the server; its connections and stored credentials are torn down, the name is freed |
barndoor_agent |
Unregisters the agent; the platform archives dependent policies |
barndoor_log_export |
Removes the destination configuration; the platform-provisioned export slot survives |
barndoor_dlp_org_config, barndoor_llm_governance_config |
Resets the org singleton to platform defaults; nothing is deleted |
| Everything else | Deletes the object |
Plans that look like drift but aren't
barndoor_llm_model_pricing.idchanges on every price update — pricing is an append-only versioned store, and each change creates a new version. That's by design, not drift.barndoor_llm_rate_limit: removingrequests_per_minuteortokens_per_minutefrom configuration clears that metric on the platform — the plan is telling the truth; make sure it's what you meant.- After importing resources with write-only credentials, the first plan re-sends those values (see Importing).
CI/CD with GitHub Actions
The standard shape — plan on pull requests, apply on merge to main:
name: terraform
on:
pull_request:
push:
branches: [main]
env:
BARNDOOR_BASE_URL: https://platform.barndoor.ai
BARNDOOR_TOKEN_URL: https://auth.barndoor.ai/realms/barndoor/protocol/openid-connect/token
BARNDOOR_CLIENT_ID: ${{ vars.BARNDOOR_CLIENT_ID }}
BARNDOOR_ORGANIZATION_ID: ${{ vars.BARNDOOR_ORGANIZATION_ID }}
BARNDOOR_CLIENT_SECRET: ${{ secrets.BARNDOOR_CLIENT_SECRET }}
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform plan -input=false
if: github.event_name == 'pull_request'
- run: terraform apply -input=false -auto-approve
if: github.ref == 'refs/heads/main'
Notes:
- Only
BARNDOOR_CLIENT_SECRETis a secret; the other four values are identifiers and can live in plain repository variables. - Because one provider credential is active per organization, CI and humans share it — if you rotate it, update the CI secret in the same change.
- Use a remote state backend (S3, GCS, Terraform Cloud, …) so CI runs and local runs see the same state.
Troubleshooting
- Error: ... requires the organization admin role
The credential reached the API but was refused. Confirmorganization_idmatches the organization the credential was generated in (copy both from Settings → API Tokens), and that the credential hasn't been revoked. - Token request failed: invalid_client or unauthorized_client
The token endpoint rejected the client ID/secret pair — the secret is wrong, was rotated out from under you, or the credential was revoked. Mint a fresh secret by rotating in Settings → API Tokens. - base_url must be the platform host root
base_urlmay not carry a path suffix; the provider appends each service's API prefix itself. Use the bare origin. - 409 Conflict on create
You're creating something that already exists and has a uniqueness constraint — a duplicate policy name, a second rate limit on the same scope and traffic type, a second connection on a server. Either import the existing object or change the conflicting field. - Blocks vs. attribute syntax: 'Blocks of type X are not expected here'
Nested values in this provider are attributes, assigned with=— for exampledestination = { ... }onbarndoor_log_export. Writing them as blocks (destination { ... }) is a syntax error. - Intermittent failures under heavy applies
Since v0.4.0 the provider retries transient failures (rate limiting, gateway errors, connection blips) with exponential backoff, so most blips never surface. If an apply still fails partway, re-runterraform apply— it converges from wherever it stopped. - Seeing the full API exchange
Run withTF_LOG=DEBUG— the provider logs request/response detail, including full error bodies, at debug level.