Manages Onyx application configuration declaratively via the Onyx admin API: LLM providers, the deployment default model, API keys, workspace settings, and embedding providers.
Not to be confused with
deployment/terraform/, which provisions the infrastructure Onyx runs on (EKS, RDS, ...). This provider configures what runs inside an Onyx deployment.
| Name | Manages | Import id |
|---|---|---|
onyx_api_key |
API keys (/admin/api-key) |
numeric id |
onyx_llm_provider |
LLM providers + their model list (/admin/llm/provider) |
numeric id |
onyx_llm_provider_default |
The deployment default (and vision) model — a singleton | default |
onyx_settings |
Workspace settings — a singleton, partially managed | settings |
onyx_embedding_provider |
Cloud embedding provider credentials | provider type (e.g. openai) |
onyx_credential |
Connector credentials (/manage/credential) |
numeric id |
onyx_connector |
Connector definitions (/manage/admin/connector) |
numeric id |
onyx_cc_pair |
Connector-credential pairs (/manage/connector/.../credential/...) |
numeric id |
onyx_document_set |
Document sets (/manage/admin/document-set) |
numeric id |
onyx_custom_tool |
Custom actions (/admin/tool/custom) |
numeric id |
onyx_agent |
Agents / assistants (/persona) |
numeric id |
onyx_mcp_server |
MCP servers Onyx connects to (/admin/mcp) |
numeric id |
onyx_user_group |
User groups: roster, managers, permission grants (EE only) | numeric id |
data.onyx_llm_providers |
Read-only list of providers + defaults | — |
data.onyx_embedding_providers |
Read-only list of embedding providers | — |
data.onyx_settings |
Read-only current settings (incl. license tier) |
— |
data.onyx_connectors |
Read-only list of connectors | — |
Generated per-resource docs live in docs/.
examples/bootstrap/ is a runnable day-one configuration: a chat
model, one indexed site, a document set built from it, and an agent that answers from it.
The provider needs an API key in the seeded Admin group (or an unrestricted PAT created by an admin user). Create one in the Onyx admin panel (API Keys) or via the API — pass the Admin group id, since a key with no group has no admin permissions:
curl -X POST https://your-onyx/api/admin/api-key \
-H "Cookie: fastapiusersauth=<admin session>" \
-H "Content-Type: application/json" \
-d '{"name": "terraform", "group_ids": [<admin group id>]}'examples/bootstrap/mint_api_key.sh does the whole
sequence — register, log in, resolve the Admin group, mint the key — for a scripted setup. It
requires ONYX_ADMIN_EMAIL and ONYX_ADMIN_PASSWORD rather than defaulting them: on a
deployment with no users it registers that account, and the first user to register becomes an
admin.
This first key is inherently chicken-and-egg: it must exist before Terraform can run, so
either leave it unmanaged, or terraform import it afterwards (its api_key attribute
stays null — the material is only ever returned at creation).
provider "onyx" {
endpoint = "https://your-onyx.example.com" # or ONYX_SERVER_URL
api_key = var.onyx_api_key # or ONYX_API_KEY
# api_prefix defaults to "/api" (the web proxy). Set to "" when pointing
# directly at the backend (e.g. http://localhost:8080). Also: ONYX_API_PREFIX.
}API keys work regardless of the deployment's human AUTH_TYPE (basic/OIDC/SAML/cloud),
and on Onyx Cloud the tenant is embedded in the key itself.
Every secret this provider takes has two forms. The plain attribute is stored in Terraform
state, where anyone who can read the state file can read the secret. The _wo twin is a
write-only argument:
Terraform strips the value from the plan and the state, so it lives only in your
configuration and never reaches a state file. Prefer the twin.
| Resource | Stored | Write-only |
|---|---|---|
onyx_llm_provider |
api_key, custom_config |
api_key_wo, custom_config_wo |
onyx_embedding_provider |
api_key |
api_key_wo |
onyx_credential |
credential_json |
credential_json_wo |
onyx_mcp_server |
api_token, admin_credentials |
api_token_wo, admin_credentials_wo |
onyx_custom_tool |
custom_headers |
custom_headers_wo |
Set one or the other, never both. onyx_credential needs exactly one of the two, because
the payload is mandatory.
Three secrets have no twin, and cannot get one:
onyx_api_key.api_keyis the key Onyx mints, not one you supply. Terraform can only hand back a generated value through state. Treat the state file as holding it.onyx_mcp_server.auth_template_headersis computed — Onyx writes the template itself for a shared token — and Terraform does not allow an argument to be both computed and write-only. Its placeholder values are filled fromadmin_credentials_wo.- The provider's own
api_keyis provider configuration, which Terraform does not write to state at all. Supply it fromONYX_API_KEYrather than in a.tffile.
resource "onyx_llm_provider" "openai" {
name = "openai"
provider_type = "openai"
api_key_wo = var.openai_api_key
api_key_wo_version = 1
model_configurations = [{ name = "gpt-5-mini" }]
}Write-only arguments need Terraform 1.11 or later. An older CLI rejects a configuration that sets one.
A value Terraform never stores is a value it cannot diff, so changing api_key_wo on its
own plans nothing at all. Each twin has a _wo_version counter for this: raise it, and the
diff that produces makes the next apply send the current secret.
Do not derive the counter from the secret (md5(var.token) and friends). Unlike the
secret, the counter is kept in state.
The counter only decides when an apply is triggered. Onyx replaces all fields on update, so the provider sends the secret on every apply it runs, whatever moved the plan.
onyx_custom_tool stops refreshing its headers. Onyx returns action headers in full
rather than masked, so custom_headers is normally refreshed and out-of-band edits show up
in terraform plan. It cannot do that for custom_headers_wo without writing the secret
into state, so it does not: a header changed in the admin UI goes unreported until the next
apply overwrites it. This is the one place where the write-only form gives up something.
Importing takes one extra apply. Import reads what the server has, so a secret Onyx returns unmasked lands in the stored attribute. The first apply against a configuration that uses the twin clears it from state and moves the resource onto the write-only path.
- Secret drift is undetectable. The API masks
api_key/custom_configon read, so rotating them out-of-band (e.g. in the admin UI) is invisible toterraform plan. The configured value is authoritative and is re-asserted on the next apply. onyx_settingsandonyx_llm_provider_defaultdon't really delete. Onyx has no reset-settings API and no unset API for the text/vision defaults; destroy removes them from state with a warning and leaves the live values alone. The chat-naming default is the exception: it has an unset API and is cleared on destroy when managed.onyx_embedding_providerupdates replace all fields. Keepapi_key(orapi_key_wo) in configuration — an update applied without it clears the stored key (the API has no keep-stored-key flag). The currently-active embedding provider also cannot be deleted.model_configurationsis the list of record. Models omitted from it are removed server-side, and removing the model currently set as deployment default fails — repointonyx_llm_provider_defaultfirst (references order this correctly).onyx_credentialpayloads are never read back. The API always returns the payload masked, so it is never refreshed or diffed.admin_public,curator_publicandgroupshave no update endpoint and force replacement instead.onyx_connectordoes not own its access control.access_typeandgroupsare validated on write but stored on the cc-pair, so Terraform cannot refresh them. Onyx also rewrites an unsetprune_freqto 7 days on the first update, which the provider then keeps as the value of record.onyx_connectordoes not set access control. Onyx applies it when a credential is associated, so it belongs to the connector-credential pair. The connector endpoints still require anaccess_typein the request body but ignore it, so the provider sends a fixed value rather than offering a knob that would do nothing.- A private credential can look deleted. The API hides a credential with
admin_public = falsefrom admins other than its creator, and that is indistinguishable from a deleted one, so Terraform would drop it from state and recreate it. Keepadmin_public = true(the default) for credentials Terraform manages, or run Terraform with the key that created them. - Deleting an agent leaves a tombstone. Onyx marks the row deleted instead of removing it, so the name stays taken. A later create under that name revives the tombstone, which is why destroy-then-apply returns the same agent id rather than a new one.
- A deleted agent answers 400, not 404. The lookup raises a plain
ValueError, which Onyx renders as a bad request, so "gone" cannot be read off the status. The provider confirms against the agent listing instead of matching on the message text. Making that endpoint return 404 is a worthwhile backend fix. onyx_agentdoes not own every field on an agent. Attached folders and documents are cleared by an omitted list, and sending null is rejected (422), so the provider reads them and sends them back unchanged. That leaves a narrow window in which an attachment added between the read and the write is reverted; making the two fields nullable server-side would close it. Also,search_start_dateis sent but never read back, because Onyx returns it as a parsed timestamp that would not match a plain date. Avatar images are not managed at all.display_priorityis create-only on the upsert. Onyx reads it when an agent is created and ignores it on every later write, so the provider applies a change through the display-priority endpoint as a second call. That endpoint only sets a number, so the attribute is computed: removing it from the configuration leaves the last value rather than reporting a difference that never settles.- Two built-in actions are hidden from the API.
OktaProfileToolandMemoryToolare left out of the agent snapshot, so an agent holding one reports fewertool_idsthan were written and the difference never settles. Attach custom actions and the ordinary built-ins instead. - Deleting a custom action detaches it from every agent that uses it, including agents Terraform does not manage, without an error or a warning.
onyx_user_groupis Enterprise Edition only. The routes live in the EE application and do not exist on Community Edition, where every call answers 404. Its acceptance tests skip whenee_features_enabledis false.onyx_user_groupdoes not manage what a group can see. Connectors, document sets, agents, LLM providers, MCP servers and credentials each carry their owngroupsattribute and own that link. The group exposescc_pair_ids,document_set_idsandagent_idsread-only, so the two sides never fight over the same edge.- A roster change must not disturb those links, and how it avoids that depends on the change. Onyx's update endpoint replaces connector links along with members. A roster that only gains members therefore goes through the add-users endpoint instead, which takes members alone and lets Onyx preserve the links itself, inside the transaction that holds the membership lock. A roster that loses one has no such endpoint: the provider reads the connector ids and sends them back, so a connector share made between that read and the write is overwritten by the older list. The window is one round-trip and only opens for a removal. Sending an empty list instead — the obvious-looking alternative — would unshare every connector from the group with nothing in the plan saying so.
- A group's computed links lag by one apply. Terraform creates a group before the
onyx_cc_pairthat references it, socc_pair_idsis still empty in the state written by that first apply and fills in on the next refresh. - Onyx refuses membership, rename and delete while a group is syncing, and a newly created group starts out syncing, so the provider waits before each of those. Managers, incognito and permissions are not gated. The user group tests therefore need Celery beat as well as the workers — the sync that clears the gate is beat-scheduled every 20 seconds, so without beat every one of those writes waits until it times out.
- A syncing group answers HTTP 404, not a conflict, on the membership routes and on
delete, because those handlers map every
ValueErrorto not-found. The rename route gets this right. So a 404 from any of them does not mean the group is gone — the destroy confirms each one against the listing before reporting success, since trusting it would drop a live group out of state and leave the next apply failing on the name it still holds. onyx_user_grouppermissions use Onyx's wire tokens, for examplemanage:connectors, not the enum names. Only toggleable permissions can be set;basic,admin,craft_sandbox,manage:skillsand the implied read tokens are managed by Onyx and are neither read back nor writable.- A seeded default group (
Admin,Basic) holds members and nothing else. Importing one and managing its roster works, but a rename, a delete, or a permission or incognito change is refused with a conflict. - Onyx refuses a membership removal that would strand someone, leaving them in no group
at all — a person with no group has no permissions. Destroying a group is checked the same
way, because it drops the whole roster, so a
terraform destroycan fail on a member whose only group this is. It also guards self-removal by a manager, privilege amplification, and the survival of admin access. onyx_mcp_servermanages only servers that need no interactive sign-in.NONEandAPI_TOKENare supported;OAUTHandPT_OAUTHneed a browser round-trip and are refused while the plan is built, with a diagnostic naming the admin panel.- Which tools an MCP server exposes is not managed. Onyx only learns them by calling the server, and it rejects both a tool selection and a Craft approval policy naming a tool it has never seen. Neither attribute is exposed rather than exposing one that silently does nothing on a server Terraform just created.
- An MCP server's
descriptionleft out of the configuration is cleared, not kept. Onyx reads a missing description as "leave it alone", so the provider always sends the field and an unstated one goes out empty — the same rule asgroupsandusersbelow. The upsert cannot carryavailable_in_craft, which lives on a different endpoint, so setting it costs a second call. - An MCP server added from the admin panel but never configured imports with empty strings.
That flow leaves
auth_typeandtransportunset, and Terraform has no value to show for them, so the first plan after such an import moves them to the schema defaults. It settles in one apply. - A configured
auth_template_headersis never refreshed from Onyx. A header value may be a literal rather than a{placeholder}, and Onyx masks those on the way out, so refreshing would store the mask and leave a difference that never settles. Onyx's own template is read back only when the configuration states none. Editing the headers in the admin panel is therefore invisible toterraform plan, like any other secret. auth_performer = "PER_USER"credentials belong to the identity that applied them. Onyx storesadmin_credentialsagainst the applying user rather than the server, so a Terraform-managed per-user server holds the API key's own credentials, not an administrator's. It also masks them partially rather than fully, unlike a shared token.- An MCP server's header template is never reset. Onyx keeps the stored template
whenever a write omits one, so switching a server from
PER_USERto a shared token leaves the per-user headers in place rather than restoring the defaultAuthorizationheader. Recreate the server to start over. groupsanduserson an MCP server are owned by the configuration. Onyx reads a missing list as "leave it alone", so the provider sends an empty one instead. Removing either from the configuration clears it on the server, including entries added from the admin panel.- An MCP server URL cannot point at the Onyx host. The SSRF guard refuses
localhostand link-local addresses by name at every protection level, not only the strictest. - The model list read is the API's display view. It hides obsolete models and dated duplicates, so writes (including the auto-mode pass-through, which is also not atomic with its read) cannot preserve rows the API hides. The admin UI round-trips the same filtered view; a keep-models flag on the upsert API is the planned structural fix.
Requires Go (see go.mod) and the Terraform CLI.
go build ./... # build
go test ./... # unit tests (no Onyx needed)Point Terraform at your locally-built binary with a dev_overrides block in
~/.terraformrc:
provider_installation {
dev_overrides {
"onyx-dot-app/onyx" = "/path/to/onyx/terraform-provider-onyx"
}
direct {}
}Then go build here and run terraform plan/apply (skip terraform init) in any config
using the provider.
Acceptance tests run real CRUD cycles against a live Onyx deployment (they create and destroy providers/keys and briefly modify workspace settings — use a dev deployment):
TF_ACC=1 ONYX_TF_ACC_SERVER_URL=http://localhost:8080 go test ./internal/provider/ -vONYX_TF_ACC_API_PREFIXdefaults to""(direct backend). Set/apiwhen targeting the web server.- Auth: set
ONYX_TF_ACC_API_KEYto an existing admin key, or let the harness bootstrap one by logging in asONYX_TF_ACC_ADMIN_EMAIL/ONYX_TF_ACC_ADMIN_PASSWORD(defaults:admin_user@example.com/TestPassword123!; on a fresh deployment the first registered user becomes admin automatically).
Without TF_ACC these tests skip, so plain go test ./... stays green with no Onyx
running. That is also what pr-golang-tests.yml runs, so the acceptance suite does not
run there.
pr-terraform-provider-tests.yml is the lane that does run it. It stands up api_server
and background from docker compose, so the workers and beat below come with it, and runs
the suite twice: once letting the harness bootstrap its own key, and once against a key
minted first by examples/bootstrap/mint_api_key.sh. On pull requests it only triggers
for provider and compose changes; the nightly run is what catches a backend change that
breaks the provider.
To test against an API server that does not touch your dev database, give it a database of its own. This reuses the running Postgres, Redis, OpenSearch and MinIO containers (the container name follows your compose project, so adjust it if yours differs):
docker exec onyx-relational_db-1 psql -U postgres -c "CREATE DATABASE onyx_tf_acc;"
cd backend && POSTGRES_DB=onyx_tf_acc uv run alembic upgrade head
POSTGRES_DB=onyx_tf_acc AUTH_TYPE=basic LICENSE_ENFORCEMENT_ENABLED=false \
ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true \
USER_AUTH_SECRET="$(openssl rand -hex 32)" \
uv run uvicorn onyx.main:app --port 8081Each variable earns its place. AUTH_TYPE=basic gives the harness a login to bootstrap
its key with. License enforcement must be off or API key creation answers 402. The
enterprise features flag registers the user-group routes, which the harness reads to find
the Admin group its key needs.
The onyx_cc_pair, onyx_document_set and onyx_user_group tests also need Celery,
because those objects are synced and deleted in the background. Without a worker the rows
never go away and the destroy step waits until it times out. They need the same
environment as the API server, plus PYTHONPATH pointing at backend/ so beat can load
the Enterprise schedule:
source /path/to/the/same/env # the variables above
export PYTHONPATH=/path/to/onyx/backend
celery -A onyx.background.celery.versioned_apps.beat beat --loglevel=INFO &
celery -A onyx.background.celery.versioned_apps.primary worker \
--pool=threads --concurrency=4 --loglevel=INFO --hostname=tfacc-primary@%n -Q celery &
celery -A onyx.background.celery.versioned_apps.light worker \
--pool=threads --concurrency=8 --loglevel=INFO --hostname=tfacc-light@%n \
-Q vespa_metadata_sync,connector_deletion,doc_permissions_upsert,checkpoint_cleanup,index_attempt_cleanup,opensearch_migration &The primary worker picks up the deletion checks the API server dispatches; the light worker runs the deletions and the document set sync themselves.
Beat is required for the user group tests specifically. Onyx refuses to change or
delete a group while it is syncing, a new group starts out syncing, and only the
beat-scheduled check-for-vespa-sync (every 20 seconds) clears that state. The workers
alone never run it, so without beat every group rename, membership change and destroy
waits until it times out.
The pair tests use the mock_connector source on purpose. Creating a pair runs the
connector's real validate_connector_settings, which reaches the source system; Onyx
short-circuits that check for mock_connector and ingestion_api, so the tests cover the
whole lifecycle without any live source or credentials.
docs/ is generated — edit schema MarkdownDescriptions and examples/, then:
go generate . # runs tfplugindocs; needs terraform on PATHThe public Terraform Registry only serves a provider from a repository named after it, so
releases go out through a mirror, onyx-dot-app/terraform-provider-onyx. The mirror holds
no source of its own. It is written from this directory and never edited directly.
Two workflows split the work, one in each repository:
ods release tf-providerpushes atf-provider/vX.Y.Ztag on the monorepo..github/workflows/release-terraform-provider.ymlcommits this directory's tree onto the mirror's history and tags itvX.Y.Z. (git subtree splitwould walk all ~10,000 monorepo commits to find the handful that touch this directory, which takes longer than the rest of the release put together.)- That tag starts
publish.ymlin the mirror. It lives here, at.github/workflows/publish.yml, and travels with the directory it publishes — GitHub only reads workflows from a repository's root, so it is inert in the monorepo. - goreleaser builds every platform archive, signs the checksum file with the release GPG key, and publishes a GitHub release. The registry ingests it from there.
goreleaser runs in the mirror rather than the monorepo because it takes the version from
whatever tag the checkout has. Here it would find tags like nightly-latest-... and stamp
them into every artifact name.
$ ods release tf-provider # bumps the patch version
$ ods release tf-provider --bump minor
$ ods release tf-provider --version 1.0.0To rehearse, run Publish by hand from the mirror's Actions tab. It defaults to a dry run, which builds every archive and publishes none of them.
v0.1.0 is published, so everything the registry needs is in place:
- The public repo
onyx-dot-app/terraform-provider-onyxexists. - The mirror carries its own
LICENSE. - The release GPG key exists and its public half is registered under the
onyx-dot-appnamespace. - The mirror holds
TF_PROVIDER_GPG_PRIVATE_KEYandTF_PROVIDER_GPG_PASSPHRASE, which is where the signing happens. The monorepo never holds the key. - The mirror is linked on registry.terraform.io as the
onyx-dot-apporganisation.
One step is left, and it is the only reason a release is still cut by hand:
- Give this repo a credential that can push to the mirror. The
release-terraform-providerenvironment exists but is empty, sorelease-terraform-provider.ymlhas nothing to authenticate with and has never run. Create a GitHub App, install it on the mirror alone with contents: write, and put two values in that environment: - variableTF_PROVIDER_RELEASE_APP_CLIENT_ID— the App's Client ID (Iv23li...) from its settings page, not the numeric App ID. - secretTF_PROVIDER_RELEASE_APP_PRIVATE_KEY— the whole generated.pem,BEGIN/ENDlines included.
Until that exists, a release is the workflow's own commands run locally against the
mirror, followed by the tag — about a minute. That is how v0.1.0 shipped.
MIT, the same terms the Onyx monorepo applies to content outside its ee directories.
The provider contains no Enterprise-licensed code. LICENSE holds the plain MIT text
with nothing added, so licence scanners and GitHub detect it as MIT rather than
reporting NOASSERTION.