Terraform is easy to start with: write some HCL, run terraform apply, and resources appear in Azure. The problem is rarely the first deployment — it is keeping subsequent deployments predictable when several teams share a platform.
We treat Infrastructure as Code as an operating model, not just a provisioning tool. Remote state, module contracts, network tests and deployment identities need clear ownership before production depends on them.
This post sets out the Azure Terraform practices we use as platform guardrails. We distinguish stable Terraform behaviour from environment-specific choices — particularly Databricks networking, authentication and provider compatibility, which need validation against the actual deployment.
The Problem
A single root module is convenient until it owns networking, workspaces, storage, identities and monitoring for every environment. Then an unrelated change can require broad permissions, hold a shared state lock and produce a plan that is difficult to review.
Copying that root module into four directories does not solve the underlying problem. Without explicit contracts, each copy develops different defaults, secrets handling and network assumptions.
Our starting point is simple: a deployment should have a named owner, a bounded scope and a repeatable release path. Terraform can support that discipline, but it cannot create it on our behalf.
Why It Matters for Platform Teams
Terraform state maps configuration to managed resources; it is not a complete record of platform health or data correctness. It can also contain sensitive values, so access to state deserves the same scrutiny as access to deployment credentials.
Platform teams need to separate reusable standards from team-owned delivery. Centrally maintained modules can establish network and security conventions while product teams control their own release cadence.
We also need a clear boundary between platform infrastructure and application deployment. Where jobs and pipelines are deployed through bundles, avoid managing the same objects in Terraform — our declarative CI/CD approach describes that complementary release model.
Use Case: Shared Airport Data Platform with Autonomous Teams
Consider a hypothetical airport operator whose product teams ingest flight events, baggage-system extracts and operational reference data into a shared Azure Databricks platform. Curated datasets feed operational dashboards, while security teams require evidence that workspace access and deployment settings follow policy.
The usual approach is a copied Terraform repository per team, each with its own networking defaults and a broadly privileged deployment identity. A central module change then becomes a manual rollout, and a production incident may require reconstructing which team changed which setting.
We would establish centrally owned networking and workspace modules, with separate state for environments and independently operated components. Teams would consume reviewed module releases, deploy application assets through a separate release path, and test source connectivity from the compute that actually processes the data.
We would measure unexpected replacements in plans, unreviewed production changes, lock contention, drift and the time needed to restore a damaged state blob safely. We would also test whether deployment identities can access another environment — isolation must be demonstrated, not inferred from filenames.
Implementation
1. Bootstrap and protect remote state
Use remote state for shared environments. Azure Blob Storage provides a supported backend, and Microsoft's state-storage guidance confirms that blobs are automatically locked before operations that write state.
terraform {
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "sttfstatecompany"
container_name = "tfstate"
key = "data-platform/dev.tfstate"
}
}
The storage account and container must already exist before this root module is initialised. Replace the example names with your bootstrap resources and provide authentication through the deployment environment, not committed credentials.
Treat the state backend as critical infrastructure. Restrict network access using a design the actual runner can reach, and validate backend authentication separately from Azure resource deployment permissions.
Enable blob versioning at the storage-account level, not on the container. Earlier versions can help recover unwanted modifications, but versioning does not recover a deleted storage account or container; Microsoft documents these limits in its versioning overview.
Use a dedicated state account rather than assuming the data lake can serve both purposes. The supplied Microsoft guidance states that blob versioning is not supported on accounts with a hierarchical namespace enabled.
Choose soft-delete retention and version-clean-up policies deliberately. Do not describe soft delete alone as an undo mechanism for terraform state rm — rehearse recovery from the actual state-write operation instead.
A recovery runbook should pause deployments, preserve the current blob, identify the intended earlier version and restore it through an approved process. Before resuming, reconcile the recovered state with live resources and inspect a new plan; restoring state does not reverse infrastructure changes.
2. Separate environments and define ownership boundaries
Use separate state keys for development, test, acceptance and production. Split further by component when ownership, permissions or release cadence justify it.
terraform-state/
networking/dev.tfstate
networking/prd.tfstate
databricks/dev.tfstate
databricks/prd.tfstate
monitoring/dev.tfstate
monitoring/prd.tfstate
These are example blob keys, not a requirement to create filesystem directories. Each independently managed root module selects its own backend key.
Separate state reduces the scope of a Terraform operation, but it does not enforce Azure authorisation. A development runner with production permissions can still affect production, so state boundaries and identity boundaries must agree.
Serialise deployments that write the same state. When a lock remains after an interrupted run, investigate the owning operation before considering a force-unlock; do not disable locking to make a pipeline pass.
Keep root modules focused on composition and child modules focused on a coherent capability. Small roots may reasonably contain resources directly — the goal is understandable ownership, not a ban on resource blocks.
3. Build module contracts and control dependencies
Module outputs are an API. Export the identifiers consumers actually need, rather than making them rediscover resources by name.
# modules/networking/outputs.tf
# The networking module creates these resources in its main configuration.
output "vnet_id" {
description = "ID of the provisioned VNet"
value = azurerm_virtual_network.main.id
}
output "databricks_private_subnet_id" {
description = "ID of the subnet selected for the Databricks network design"
value = azurerm_subnet.databricks_private.id
}
output "private_endpoints_subnet_id" {
description = "Subnet ID for private endpoints"
value = azurerm_subnet.private_endpoints.id
}
output "private_dns_zone_ids" {
description = "Map of private DNS zone IDs by service"
value = {
blob = azurerm_private_dns_zone.blob.id
dfs = azurerm_private_dns_zone.dfs.id
dbw = azurerm_private_dns_zone.databricks.id
}
}
This is an outputs file for a module that already defines the referenced resources. The map provides identifiers only — it does not prove that DNS zones, links or endpoint configurations are correct.
Validate inputs early and keep validation claims narrow. A syntactically valid CIDR can still overlap another network or be too small for the planned workload.
variable "environment" {
type = string
description = "Deployment environment"
validation {
condition = contains(["dev", "test", "acc", "prd"], var.environment)
error_message = "Environment must be one of: dev, test, acc, prd."
}
}
variable "vnet_address_space" {
type = string
description = "VNet CIDR block"
validation {
condition = can(cidrhost(var.vnet_address_space, 0))
error_message = "Must be valid CIDR notation, for example 10.0.0.0/16."
}
}
The environment validation catches unsupported labels, while cidrhost checks whether Terraform can interpret the CIDR. Capacity and overlap checks require additional design validation.
For Git-based modules, select a reviewed release tag or commit rather than ref=main. A tag is only immutable if repository controls make it so; use a commit identifier when that distinction matters.
Control provider upgrades through constraints and commit .terraform.lock.hcl. The following preserves the original version ranges as a syntax example, not as a current recommendation or a verified compatibility matrix.
terraform {
required_version = ">= 1.5.0, < 2.0.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.14.0"
}
azuread = {
source = "hashicorp/azuread"
version = "~> 3.1.0"
}
databricks = {
source = "databricks/databricks"
version = "~> 1.65.0"
}
}
}
These are ranges, not exact pins: ~> 4.14.0 permits versions from 4.14.0 up to, but not including, 4.15.0. A two-component constraint such as ~> 4.14 is broader; the lock file records the selected provider versions, while the runner must separately control its Terraform CLI version.
4. Validate networking from the real execution paths
Choose networking from workload and security requirements, not from an unconditional “VNet injection everywhere” rule. Document which compute types must reach which sources, where outbound access is permitted, and who owns routing and DNS.
Do not assume that a setting such as no_public_ip defines every outbound route or applies identically to every compute type. The supplied Databricks sources document AWS Terraform workflows, not Azure workspace networking migrations or the AzureRM workspace schema.
For an Azure workspace module, verify the selected provider's resource schema and current Azure Databricks networking documentation before implementation. This includes supported migration paths, subnet and NSG association requirements, address consumption, private endpoint subresources and DNS dependencies.
We retain the original address plan as an illustrative allocation, not a product minimum or a capacity calculation:
| Environment | VNet CIDR | Databricks private subnet | Databricks public subnet | Private endpoints | Shared services |
|---|---|---|---|---|---|
| Dev | 10.1.0.0/16 | 10.1.1.0/24 | 10.1.2.0/24 | 10.1.3.0/24 | 10.1.4.0/24 |
| Test | 10.2.0.0/16 | 10.2.1.0/24 | 10.2.2.0/24 | 10.2.3.0/24 | 10.2.4.0/24 |
| Acc | 10.3.0.0/16 | 10.3.1.0/24 | 10.3.2.0/24 | 10.3.3.0/24 | 10.3.4.0/24 |
| Prd | 10.4.0.0/16 | 10.4.1.0/24 | 10.4.2.0/24 | 10.4.3.0/24 | 10.4.4.0/24 |
Check these ranges against the organisation's address inventory. Size subnets using the supported architecture, reserved addresses, peak compute demand and growth allowance — not an assumed one-IP-per-node rule.
Test connectivity from both the deployment runner and workload compute. A runner reaching the workspace API does not prove that a job can reach storage, and a private endpoint resource existing does not prove that DNS resolves correctly.
5. Scope identities and keep secrets out of configuration
Use dedicated deployment identities for automation, with environment-specific access. Separate the permissions needed to create infrastructure from those needed to create identities, assign roles or administer Databricks resources.
variable "deployer_principal_id" {
description = "Object ID of the pre-provisioned deployment identity"
type = string
}
resource "azurerm_role_assignment" "project_contributor" {
scope = azurerm_resource_group.data_platform.id
role_definition_name = "Contributor"
principal_id = var.deployer_principal_id
}
This example assumes the root module already defines azurerm_resource_group.data_platform. It assigns Contributor at that resource-group scope; the identity applying the assignment needs separate permission to assign roles.
Avoid subscription-wide deployment rights when resource-group scope is sufficient. Some bootstrap operations genuinely need broader scope — keep them in a separately controlled workflow rather than granting that access to every team pipeline.
Validate state access against the chosen backend authentication method. Do not assume an Azure management-plane role also grants blob data access, or that a single blob role covers every backend operation and endpoint-discovery path.
Use an approved secret store and short-lived authentication where supported by the deployment environment. Marking a Terraform value sensitive suppresses some display, but does not remove it from state.
A Databricks workspace URL is an address, not a secret connection string. Do not put it in Key Vault merely to imply security, and do not use ignore_changes = [value] as a substitute for deciding who owns secret rotation.
For Databricks, verify provider scope resource by resource. Jobs and notebooks are workspace resources in the supplied examples, while identity and Unity Catalog operations must not all be assumed to use one account-level provider.
6. Release reviewed changes and test the deployed result
Use a validate–plan–apply release sequence. Run validation after initialisation, then generate a saved plan for one target environment.
set -euo pipefail
# Run from the target root module; authentication is supplied by the runner.
terraform fmt -check -recursive
terraform init -backend-config=env/dev/backend.tfvars
terraform validate
terraform plan -var-file=env/dev/terraform.tfvars -out=tfplan
# Present this output for review; protect tfplan as a sensitive artefact.
terraform show -no-color tfplan
The backend file contains the target backend settings, and the variable file contains non-secret deployment inputs. Provider-schema validation is useful, but it does not prove network connectivity, sufficient permissions or application correctness.
In the apply stage, check out the same revision and restore the configuration, module dependencies, lock file and approved plan. Initialise with the same backend and use a compatible, controlled toolchain.
set -euo pipefail
# Execute only after the release system approves this specific plan artefact.
terraform init -backend-config=env/dev/backend.tfvars
terraform apply tfplan
Applying a saved plan executes that plan rather than generating a fresh one. It can still fail because the state is stale, credentials have changed or the live environment no longer supports the operations; re-plan and re-approve rather than silently substituting a new plan.
Azure DevOps and GitHub Actions can implement this sequence, but neither a deployment job nor an environment matrix establishes promotion order automatically. Make checkout, authentication, artefact paths, approvals and per-state concurrency explicit, and prevent untrusted pull-request jobs from receiving production credentials.
After deployment, test the contracts that matter: source access, DNS, workspace access and expected permissions. Databricks also documents Terraform plan-based and apply-based tests in its provider guide; apply-based tests create and tear down resources, so run them in an isolated test environment.
Keep naming and tagging consistent, without treating an abbreviation as proof of compliance with Microsoft guidance. Storage account names, for example, must be globally unique, contain only lowercase letters and numbers, and be between 3 and 24 characters.
locals {
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
Repository = var.repository_url
CostCenter = var.cost_center
}
}
This tag map assumes the corresponding input variables are declared. Apply it to resources that support tags; ManagedBy communicates ownership but does not prevent manual changes or enforce policy by itself.
Pitfalls and When Not to Use It
Over-splitting state creates coordination overhead. Split where ownership and release boundaries are real, not simply because two resources have different Azure service names.
Lifecycle rules are guardrails, not backups. prevent_destroy can reject planned destruction while the rule remains configured, but it does not protect against out-of-band deletion or removal of the resource configuration; use storage recovery controls and appropriate Azure resource locks alongside it.
Use ignore_changes only for a documented attribute owned by another system. Broad exclusions hide drift, and guessed platform-managed attribute paths can obscure a genuine configuration error.
Terraform is not automatically the best owner for frequently changing application assets. Use one authoritative deployment mechanism per resource, and keep emergency manual changes auditable and reconciled afterwards.
Cost estimates can help reviewers understand proposed infrastructure, but they are not bills or guarantees. Validate the estimator's coverage and current integration syntax before making it a release gate, and compare estimates with measured usage after deployment.
Local state is acceptable for a disposable, isolated exercise with no shared ownership. It is not our choice for a team-operated platform whose recovery and auditability depend on repeatable deployments.
Key Takeaways
- Protect state as critical infrastructure. Use a secured remote backend, automatic locking, storage-account-level versioning and a rehearsed recovery process.
- Align state boundaries with permissions. Separate environments and independently operated components, then prove that deployment identities cannot cross those boundaries.
- Make reuse explicit. Publish narrow module contracts, validate inputs and review dependency upgrades with a committed provider lock file.
- Test the actual network paths. Validate Azure-specific schemas and architecture, then test both runner access and workload connectivity.
- Apply approved plans through a controlled release. Protect artefacts, preserve the toolchain and configuration, and re-approve when a new plan is required.
- Give each resource one owner. Use lifecycle rules narrowly, reconcile drift and separate platform infrastructure from application deployment where appropriate.
Open Source: Databricks Lakehouse Terraform Boilerplate — Beyond Generated Code
An opinionated Terraform starting point for Azure Databricks — focused on ownership, tested networking and deployment evidence rather than generated code.
Platform Engineering · 8 minBuilding RIVM’s Self-Service Data Onboarding Platform
How we approach self-service data onboarding at RIVM: validated configuration, governed access and automated deployment without removing human accountability.
Case study · Hybrid Data Platform EngineeringHow we did this at DHL — RMA Logistics: Hybrid Data & AI Platform →
Invoice processing fell from 2 days to under 10 seconds with a hybrid Azure Databricks platform for DHL in partnership with RMA Logistics.