Clicking together one workspace is fine. By the third environment, catalogs drift, grants differ, and nobody knows why prod has an external location that dev lacks. Terraform keeps it consistent, if the code is structured well.
Layer the configuration
| Layer | Provider / host | Contents | Change rate |
|---|---|---|---|
| Azure | azurerm | Resource group, VNet, workspace, storage, access connector | Rare |
| Account | databricks with host = "https://accounts.azuredatabricks.net" | Metastore assignment, groups, service principals, workspace bindings | Occasional |
| Workspace / data | databricks with the workspace URL | Storage credentials, external locations, catalogs, schemas, grants, cluster policies, SQL warehouses | Frequent |
Separate state per layer (and per environment). A mistake in the grants layer then cannot plan a workspace replacement.
Providers
provider "azurerm" {
features {}
}
provider "databricks" {
alias = "account"
host = "https://accounts.azuredatabricks.net"
account_id = var.databricks_account_id
# auth from environment: ARM_CLIENT_ID / ARM_TENANT_ID with OIDC, or azure-cli locally
}
provider "databricks" {
alias = "workspace"
host = azurerm_databricks_workspace.this.workspace_url
}
Use Entra ID authentication (Azure CLI locally, workload identity federation in pipelines). Avoid personal access tokens in CI.
Azure layer essentials
resource "azurerm_databricks_access_connector" "uc" {
name = "dbac-sales-prod"
resource_group_name = azurerm_resource_group.this.name
location = var.location
identity { type = "SystemAssigned" }
}
resource "azurerm_role_assignment" "uc_blob" {
scope = azurerm_storage_account.lake.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azurerm_databricks_access_connector.uc.identity[0].principal_id
}
Data layer: credential, location, catalog
resource "databricks_storage_credential" "lake" {
provider = databricks.workspace
name = "sc-sales-prod"
azure_managed_identity {
access_connector_id = azurerm_databricks_access_connector.uc.id
}
comment = "Managed by Terraform"
}
resource "databricks_external_location" "lake" {
provider = databricks.workspace
name = "el-sales-prod"
url = "abfss://lake@${azurerm_storage_account.lake.name}.dfs.core.windows.net/"
credential_name = databricks_storage_credential.lake.name
depends_on = [azurerm_role_assignment.uc_blob]
}
resource "databricks_catalog" "sales" {
provider = databricks.workspace
name = "sales_prod"
storage_root = "${databricks_external_location.lake.url}managed/sales_prod"
isolation_mode = "ISOLATED"
}
resource "databricks_schema" "silver" {
provider = databricks.workspace
catalog_name = databricks_catalog.sales.name
name = "silver"
}
isolation_mode = "ISOLATED" plus a databricks_workspace_binding limits the catalog to the prod workspace.
Grants: authoritative vs additive
| Resource | Behaviour |
|---|---|
databricks_grants | Authoritative for that securable: removes any grant not in the code |
databricks_grant | Manages one principal's grants on a securable, leaves others alone |
resource "databricks_grants" "silver" {
provider = databricks.workspace
schema = "${databricks_catalog.sales.name}.${databricks_schema.silver.name}"
grant {
principal = "sales-engineers"
privileges = ["USE_SCHEMA", "SELECT", "MODIFY", "CREATE_TABLE"]
}
grant {
principal = "sales-analysts"
privileges = ["USE_SCHEMA", "SELECT"]
}
}
Caution
Introducing
databricks_grantson an existing catalog or schema removes every grant someone added by hand. Export current grants first (SHOW GRANTS ON SCHEMA ...), add them to code, and read the plan carefully. Usedatabricks_grantwhen several teams manage grants on the same object.
Import existing objects
terraform import 'databricks_catalog.sales' sales_prod
terraform import 'databricks_schema.silver' sales_prod.silver
terraform import 'databricks_external_location.lake' el-sales-prod
Newer Terraform versions support import blocks, which are reviewable in a PR. The Databricks Terraform exporter (terraform-provider-databricks exporter) can generate code for an existing workspace as a starting point.
Pipeline
terraform fmt -check,terraform validate,tflint.terraform plan -out plan.tfplanper layer and environment; publish the plan.- Gate: any
destroyorreplaceof catalogs, schemas, external locations, storage credentials, or the workspace requires manual approval.
terraform show -json plan.tfplan | jq -r '.resource_changes[] | select(.change.actions|index("delete")) | .address'
- Apply with a service principal that is a metastore admin (data layer) or account admin (account layer), never a person.
Protect what you cannot recreate
resource "databricks_catalog" "sales" {
# ...
force_destroy = false
lifecycle { prevent_destroy = true }
}
Do the same for storage accounts holding managed tables and for the workspace itself.
Common problems
| Error / symptom | Cause | Fix |
|---|---|---|
PERMISSION_DENIED creating storage credential | Pipeline SP lacks CREATE STORAGE CREDENTIAL on the metastore | Grant it, or make SP metastore admin |
| External location validation fails | Role assignment not propagated yet | depends_on the role assignment; retry |
| Plan wants to recreate a catalog | Changed storage_root or name | Revert; these are force-new attributes |
| Grants flip-flop every apply | Two configs manage databricks_grants on the same object | One owner per securable |
Prevent it next time
- One state per layer per environment.
prevent_destroyon catalogs, credentials, locations and storage.- Grants only through Terraform for production catalogs; read access for humans to propose changes via PR.