Alle artikelen

Platformengineering

Azure Terraform Best Practices We Follow on Every Data Platform Engagement

Practical Azure Terraform guardrails for data platforms: secure remote state, reusable modules, tested networking, scoped identities and reviewed deployments.

  • Terraform
  • Azure
  • Infrastructure as Code
  • CI/CD
  • Networking
  • RBAC
  • Data Platform

Terraform can provision Azure resources from HCL configuration. 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.

To keep shared-platform deployments predictable, we require owned state, reusable modules and reviewed releases; apply these guardrails before production depends on them.

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. Use bundles for jobs and pipelines, not 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. 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
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. Disable public network access. Connect the deployment runner through a private endpoint. 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. 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.

Use the recovery runbook in this sequence:

  1. Pause deployments.
  2. Preserve the current blob.
  3. Identify the intended earlier version.
  4. Restore it through an approved process.
  5. Reconcile the recovered state with live resources.
  6. Inspect a new plan before resuming deployments.

Restoring state does not reverse infrastructure changes.

2. Separate environments and define ownership boundaries

Use separate state keys for development, test, acceptance and production. Give each environment its own resource scope, workspace, catalogs, Key Vault and deployment identity. Split state further by component when ownership, permissions or release cadence justify it.

terraform-state/
  networking/dev.tfstate
  networking/test.tfstate
  networking/acc.tfstate
  networking/prd.tfstate
  databricks/dev.tfstate
  databricks/test.tfstate
  databricks/acc.tfstate
  databricks/prd.tfstate
  monitoring/dev.tfstate
  monitoring/test.tfstate
  monitoring/acc.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. Put resource definitions in NexOps-managed child modules with one responsibility each. Extend an existing module when it almost fits; do not copy it.

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.

terraform
# 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.

terraform
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
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 constraints define allowed version ranges, not exact pins. The lock file records selected provider versions. Control the runner's Terraform CLI version separately.

4. Validate networking from the real execution paths

Our Azure Databricks standard requires VNet injection and secure cluster connectivity without public IP addresses. Use private endpoints for data services. Disable public network access. Document permitted outbound paths and routing and DNS ownership.

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:

EnvironmentVNet CIDRDatabricks private subnetDatabricks public subnetPrivate endpointsShared services
Dev10.1.0.0/1610.1.1.0/2410.1.2.0/2410.1.3.0/2410.1.4.0/24
Test10.2.0.0/1610.2.1.0/2410.2.2.0/2410.2.3.0/2410.2.4.0/24
Acc10.3.0.0/1610.3.1.0/2410.3.2.0/2410.3.3.0/2410.3.4.0/24
Prd10.4.0.0/1610.4.1.0/2410.4.2.0/2410.4.3.0/2410.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. Do not assume one IP address per node.

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.

terraform
variable "deployment_group_object_id" {
  description = "Object ID of the environment-specific Entra deployment security group"
  type        = string
}
 
variable "resource_group_id" {
  description = "Resource group scope approved for this deployment group"
  type        = string
}
 
# Define this assignment inside the managed groups-and-grants module.
resource "azurerm_role_assignment" "project_contributor" {
  scope                = var.resource_group_id
  role_definition_name = "Contributor"
  principal_id         = var.deployment_group_object_id
}

This module example assigns Contributor to an Entra security group at the supplied resource-group scope. Use it only when that role is necessary. Manage deployment-identity membership in Terraform. Authorise the role-assignment workflow separately.

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 workload identity federation for CI/CD and managed identities where supported. Store any required secrets in the environment's Azure Key Vault, not pipeline variables. Treat state as sensitive because it can contain secrets.

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 in CI/CD. Run validation after initialisation, then generate a saved plan for one target environment. Run the required lint, test, security, dependency and secret scans before approval.

bash
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.

bash
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. Execution can still fail because state is stale, credentials have changed or the live environment no longer supports the operations. If execution fails, investigate before creating a replacement plan. Obtain approval for any replacement plan.

Azure DevOps and GitHub Actions can implement this sequence. Neither a deployment job nor an environment matrix establishes promotion order automatically. Make checkout, artefact paths and per-state concurrency explicit. Prevent untrusted pull-request jobs from receiving production access.

  1. Promote through pull requests from dev to test, then acc, then main for production.
  2. Deploy the same build artefact through all four environments.
  3. Require approval before apply in acceptance and production.
  4. If a plan deletes or replaces a stateful resource, stop for explicit approval.

After deployment, test source access, DNS, workspace access and expected permissions. If a smoke test fails, stop promotion. Databricks documents plan-based and apply-based tests in its provider guide. Apply-based tests create and tear down resources; run them through CI/CD 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.

terraform
locals {
  common_tags = {
    environment           = var.environment
    owner                 = var.owner
    "cost-center"         = var.cost_center
    "data-classification" = var.data_classification
    "managed-by"          = "terraform"
    project               = var.project
    repository            = var.repository_url
  }
}

This tag map assumes the corresponding input variables are declared. Apply it to resources that support tags. The managed-by tag identifies Terraform management but does not prevent manual changes or enforce policy.

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. 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.

Keep emergency fixes in code and deploy them through the controlled pipeline. Do not use portal changes as a recovery procedure.

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

  1. Rehearse state restoration before an incident requires it.
  2. Prove environment isolation with permission tests, not naming conventions.
  3. Review module and provider changes as controlled dependencies.
  4. Verify private connectivity from the runner and workload compute.
  5. Promote reviewed artefacts through every environment with explicit release gates.
  6. Assign clear resource ownership and resolve drift through code.

Blijf op de hoogte

Ontvang het volgende artikel per e-mail

Volgende stap

Wil je hierover praten?

We praten graag over het vak. Heeft dit artikel je op een idee gebracht, of werk je aan iets vergelijkbaars? Dan maken we graag kennis.

Open voor projecten in Q4 2026