Terraform Lesson 44 of 89

Terraform on Azure: Key Vault (Secrets), Azure SQL Database & Azure DNS as Code

Three Azure services show up in almost every real workload, and they are exactly the three where a wrong click in the portal quietly becomes a security incident: Key Vault (where secrets live), Azure SQL Database (the data that secret protects), and Azure DNS (the name the world uses to reach it). This lesson wires all three together with Terraform in one working, copy-pasteable demo — a Key Vault that uses Azure RBAC and holds a generated SQL admin password, an Azure SQL server + database that consume that password behind a firewall rule, and a public DNS A record that points a hostname at the app. You will run it end to end: terraform init → plan → apply → verify → destroy.

The reason to teach these three as a unit is that their seams are where engineers get hurt. The Key Vault is easy; granting Terraform permission to write the first secret is where the modern RBAC vs access-policy fork lives, and the wrong choice gives you a 403 Forbidden on your own apply. The SQL server is easy; the fact that its admin password — and any secret you read back with a data source — lands in the state file in cleartext is the thing that turns “I used Key Vault” into “I leaked a password.” The firewall rule is one resource; its most-used form (0.0.0.00.0.0.0) does not mean what its numbers suggest. And the DNS record is trivial; the delegation that makes it resolve is not in Terraform at all. We cover each seam explicitly, with the exact HCL and the exact failure.

This is a Senior-tier, hands-on lesson. It assumes you already know core Terraform — HCL, providers, variables, state, modules, for_each — and that you can authenticate the azurerm provider to a subscription (via Azure CLI, a service principal, or OIDC). If the provider/auth/remote-backend setup is new to you, the companion lesson Terraform on Azure: getting started, provider authentication & remote backend covers it in full; this lesson takes that as read and pins hashicorp/azurerm ~> 4.0 throughout.

What you’ll build

The scenario is the data tier for a small line-of-business app. The app needs a SQL database; the database needs an admin credential; that credential must never be typed by a human or committed to Git; and the app must be reachable at app.kv-demo.example. In portal terms that is four blades and a dozen fields, several of which default to insecure. In Terraform it is one directory you can read, review, and destroy in a single command — and, crucially, one you can diff: the day someone loosens the SQL firewall by hand, the next plan shows it as drift.

Concretely, terraform apply will stand up: a resource group; a random_password that mints a 24-character admin secret at apply time; a Key Vault with rbac_authorization_enabled = true, soft-delete and purge protection, plus an azurerm_role_assignment granting the caller Key Vault Secrets Officer so Terraform can write; an azurerm_key_vault_secret holding that password; an azurerm_mssql_server (with an Entra ID admin) and an azurerm_mssql_database that use the password; two firewall rules (allow-Azure-services and your client IP); a public IP; a public azurerm_dns_zone; and an azurerm_dns_a_record that points app at the IP. The whole thing costs a few rupees a day if you leave it running and is fully removed by terraform destroy (with one purge-protection caveat we will hit deliberately).

The three services map to Terraform resources like this — keep this table open, it is the spine of the whole lesson:

Azure service Primary Terraform resource(s) What it models Key companion resource
Key Vault azurerm_key_vault The vault (SKU, tenant, auth mode, network) azurerm_role_assignment (RBAC) or azurerm_key_vault_access_policy (legacy)
Key Vault data azurerm_key_vault_secret / _key / _certificate A stored secret / crypto key / cert random_password (generate the value)
Azure SQL azurerm_mssql_server The logical server (admin, Entra admin, TLS) azuread_administrator block
Azure SQL DB azurerm_mssql_database One database (SKU, size, retention, HA) azurerm_mssql_firewall_rule (access)
Azure DNS azurerm_dns_zone A public DNS zone azurerm_dns_a_record / _cname_record / alias
Private DNS azurerm_private_dns_zone A private zone for private endpoints azurerm_private_dns_zone_virtual_network_link

Why Terraform for this at all, rather than the portal, az CLI, or ARM/Bicep? Because these resources are stateful, security-sensitive, and long-lived, which is exactly the profile Terraform is built for:

Approach Repeatable Drift-detectable Secret handling Verdict for this tier
Portal No (manual clicks) No Human types passwords Fine to learn a service; unsafe as source of truth
az CLI scripts Partially (imperative) No You script the secret plumbing OK for one-off ops, not lifecycle
ARM / Bicep Yes (declarative) Weak (what-if only) Native Key Vault references Good on Azure-only shops; no cross-cloud, weaker module ecosystem
Terraform (azurerm) Yes Yes (plan = drift) random_password + Key Vault, but ⚠️ value in state Best fit: one language for KV+SQL+DNS+network, reviewable, destroyable

The one honest caveat in that table — secrets can land in Terraform state — is not a reason to avoid Terraform; it is a thing you manage, and half of this lesson is how. Let’s build the pieces.

Azure Key Vault as code

A Key Vault is a hardened, access-controlled store for three kinds of material: secrets (arbitrary strings — passwords, connection strings, API keys), keys (asymmetric/symmetric crypto keys you use without exporting), and certificates (X.509 certs with lifecycle). The azurerm_key_vault resource creates the vault itself; separate resources create the material inside it.

The vault has two SKUs. The difference is where keys live, not how many secrets you can store:

Setting standard premium
Secrets & certificates Yes Yes
Software-protected keys Yes Yes
HSM-backed keys (FIPS 140-2 L2) No Yes
Typical use App secrets, connection strings Regulated CMK / bring-your-own-key
Relative cost Lower Higher (per-key HSM charge)

Here is the vault. Note rbac_authorization_enabled (the modern auth mode — more on the fork below), the soft-delete/purge settings, and the network_acls block:

data "azurerm_client_config" "current" {}

resource "azurerm_key_vault" "kv" {
  name                = "kv-demo-prod-001"          # 3-24 chars, globally unique
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  tenant_id           = data.azurerm_client_config.current.tenant_id
  sku_name            = "standard"

  # --- modern auth: Azure RBAC on the data plane ---
  rbac_authorization_enabled = true                 # azurerm v4 name (was enable_rbac_authorization)

  # --- data protection (⚠️ purge protection is irreversible) ---
  soft_delete_retention_days = 90
  purge_protection_enabled   = true

  # --- network ---
  public_network_access_enabled = true
  network_acls {
    default_action             = "Deny"
    bypass                     = "AzureServices"
    ip_rules                   = [var.my_ip_cidr]    # e.g. "203.0.113.10/32"
    virtual_network_subnet_ids = []
  }

  tags = local.tags
}

RBAC authorization vs access policies — the fork

This is the single most important decision in the file, and the one that most often produces a 403 on your first apply. A vault authorizes data-plane operations (read/write a secret, use a key) one of two ways, set by rbac_authorization_enabled:

Dimension Azure RBAC (rbac_authorization_enabled = true) Access policies (= false, the default/legacy)
Where permissions live Azure RBAC role assignments at vault/secret scope An access-policy list inside the vault resource
Terraform resource azurerm_role_assignment azurerm_key_vault_access_policy
Granularity Per-object (grant on one secret) possible Per-vault, per operation-category
Model Consistent with all other Azure RBAC Vault-only, separate mental model
Propagation Role assignment can take seconds–minutes to apply Immediate on the vault write
Microsoft guidance Recommended Legacy; kept for compatibility
Classic gotcha Your own principal has no data access until you grant a role Forgetting yourself in the policy list

With RBAC, the vault’s ARM (management-plane) creation does not grant you rights to write secrets. You must add a role assignment, and — because the secret write depends on that grant — you must order it with depends_on:

resource "azurerm_role_assignment" "kv_secrets_officer" {
  scope                = azurerm_key_vault.kv.id
  role_definition_name = "Key Vault Secrets Officer"          # write/read/delete secrets
  principal_id         = data.azurerm_client_config.current.object_id
}

The built-in Key Vault roles you will actually use:

Role Data plane it grants Assign to
Key Vault Secrets Officer Full secret CRUD (get/set/list/delete/purge/recover) Terraform identity that writes secrets
Key Vault Secrets User Read secret values only Apps / managed identities that consume secrets
Key Vault Certificates Officer Full certificate management Cert automation
Key Vault Crypto Officer Create/manage keys Key management
Key Vault Crypto User Use keys (encrypt/decrypt/sign) Apps doing crypto, CMK consumers
Key Vault Administrator All data-plane operations Break-glass admins only
Key Vault Reader (management) See vault metadata, not secret values Auditors

The legacy alternative, for a vault with rbac_authorization_enabled = false, is an access policy — note it lives as its own resource and uses verb lists:

# Only valid when rbac_authorization_enabled = false
resource "azurerm_key_vault_access_policy" "legacy" {
  key_vault_id = azurerm_key_vault.kv.id
  tenant_id    = data.azurerm_client_config.current.tenant_id
  object_id    = data.azurerm_client_config.current.object_id

  secret_permissions = ["Get", "List", "Set", "Delete", "Recover", "Purge"]
  key_permissions    = ["Get", "List", "Create", "Delete"]
}

⚠️ Do not mix modes. If rbac_authorization_enabled = true, an azurerm_key_vault_access_policy is ignored (and will error). If it’s false, azurerm_role_assignment at vault scope has no data-plane effect. Pick one; for anything new, pick RBAC.

Secrets, keys and certificates

The three material types are three resources. They differ in what they hold and what “the value” even is:

Resource Holds You provide Read back gives Common use
azurerm_key_vault_secret Arbitrary string value (the string) The plaintext value Passwords, conn strings, API keys
azurerm_key_vault_key Crypto key key_type, key_size/curve, key_opts Public key + ops (never the private key) CMK, signing, wrap/unwrap
azurerm_key_vault_certificate X.509 cert + key A certificate (import) or a certificate_policy (generate) Cert data, thumbprint, a linked secret TLS certs, mTLS, code signing

The secret — this is the one our SQL password uses. Its value comes from random_password, and it depends_on the role assignment so Terraform actually has permission when it writes:

resource "random_password" "sql" {
  length           = 24
  special          = true
  override_special = "!#$%*-_=+"
  min_upper        = 2
  min_lower        = 2
  min_numeric      = 2
}

resource "azurerm_key_vault_secret" "sql_admin_password" {
  name         = "sql-admin-password"
  value        = random_password.sql.result
  key_vault_id = azurerm_key_vault.kv.id
  content_type = "password"

  depends_on = [azurerm_role_assignment.kv_secrets_officer]  # RBAC must land first
}

A key (for example a customer-managed key you’d point storage or SQL TDE at) and a self-generated certificate look like this — you won’t need them for the SQL demo, but they round out the trio:

resource "azurerm_key_vault_key" "cmk" {
  name         = "cmk-tde"
  key_vault_id = azurerm_key_vault.kv.id
  key_type     = "RSA"
  key_size     = 2048
  key_opts     = ["decrypt", "encrypt", "sign", "unwrapKey", "verify", "wrapKey"]
  depends_on   = [azurerm_role_assignment.kv_crypto_officer]
}

resource "azurerm_key_vault_certificate" "self_signed" {
  name         = "app-tls"
  key_vault_id = azurerm_key_vault.kv.id

  certificate_policy {
    issuer_parameters { name = "Self" }
    key_properties {
      exportable = true
      key_type   = "RSA"
      key_size   = 2048
      reuse_key  = true
    }
    secret_properties { content_type = "application/x-pkcs12" }
    x509_certificate_properties {
      subject            = "CN=app.kv-demo.example"
      validity_in_months = 12
      key_usage          = ["digitalSignature", "keyEncipherment"]
    }
  }
  depends_on = [azurerm_role_assignment.kv_certs_officer]
}

Soft-delete, purge protection and the destroy trap

Key Vault has two layers of deletion safety, and the second one will bite your terraform destroy on purpose the first time you meet it:

Setting Argument Effect Reversible?
Soft-delete soft_delete_retention_days (7–90) Deleted vault/secret is recoverable for N days; the name stays reserved Recover within window
Purge protection purge_protection_enabled = true You cannot hard-delete (purge) before the window elapses — even as admin No — one-way once on

Soft-delete is always on for vaults now (you only tune the retention days). Purge protection is opt-in and irreversible: once true, you cannot set it back to false, and a destroyed vault’s name is locked until soft-delete retention expires. The provider’s features block controls whether destroy even attempts a purge:

provider "azurerm" {
  subscription_id = var.subscription_id     # required in azurerm v4
  features {
    key_vault {
      purge_soft_delete_on_destroy    = true   # try to purge on destroy...
      recover_soft_deleted_key_vaults = true   # ...and recover a soft-deleted one on create
    }
  }
}

⚠️ Even with purge_soft_delete_on_destroy = true, a vault with purge_protection_enabled = true will not purge until its soft-delete window passes. For a throwaway demo, either leave purge protection off, or accept that the name is parked for the retention period. In production you want purge protection on — it is a compliance control — and you simply never destroy the vault casually.

Consuming a secret (and why the value lands in state)

Two other pieces of Terraform can read a secret back: the azurerm_key_vault_secret data source and any resource attribute that references random_password.sql.result. Both write the plaintext into the state file. This is the security seam of the entire lesson:

How a secret is used Value in state? When to use it
random_password → resource attribute (our SQL password) Yes (.result is in state) Bootstrapping a credential you must set once
data "azurerm_key_vault_secret" → resource attribute Yes (.value is in state) Terraform must know the value to configure a resource
App-side Key Vault reference (@Microsoft.KeyVault(...)) No App Service / Functions read the secret at runtime
Managed identity reads KV at runtime No Any app that can hold an identity

The rule that follows: prefer letting the application read Key Vault at runtime (via a Key Vault reference or its managed identity granted Key Vault Secrets User) so the secret never enters Terraform state at all. Use Terraform to provision the vault, generate the secret, and grant the identity — not to shuttle the plaintext into the next resource where you can avoid it. When Terraform genuinely must know a value (like setting a SQL admin password once), keep the blast radius small: an encrypted, RBAC-locked remote backend (Azure Storage blob) so the state itself is protected. The companion remote-backend lesson shows that backend; treat it as mandatory whenever secrets touch state.

Azure SQL Database as code

Azure SQL Database is a PaaS relational database. Its topology in Terraform is two resources: an azurerm_mssql_server (a logical server — an endpoint and an administration boundary, not a VM you can log into) and one or more azurerm_mssql_database on it. Access is governed separately by firewall rules and/or private endpoints.

The server carries the admin identity and the security posture:

Argument Purpose Note
name Global DNS label → <name>.database.windows.net Must be globally unique, lowercase
version Logical server version Effectively always "12.0"
administrator_login SQL-auth admin username Immutable after create
administrator_login_password SQL-auth admin password ⚠️ write-only; lands in state
azuread_administrator {} Entra ID admin (see below) Enables Entra auth
minimum_tls_version Enforce TLS floor Set "1.2"
public_network_access_enabled Public endpoint on/off false when using private endpoint only
resource "azurerm_mssql_server" "sql" {
  name                         = "sql-demo-prod-001"
  resource_group_name          = azurerm_resource_group.rg.name
  location                     = azurerm_resource_group.rg.location
  version                      = "12.0"
  administrator_login          = var.sql_admin_login
  administrator_login_password = random_password.sql.result   # ⚠️ in state
  minimum_tls_version          = "1.2"
  public_network_access_enabled = true

  azuread_administrator {
    login_username              = var.entra_admin_upn
    object_id                   = var.entra_admin_object_id
    tenant_id                   = data.azurerm_client_config.current.tenant_id
    azuread_authentication_only = false     # true = disable SQL-auth entirely
  }

  tags = local.tags
}

Entra ID admin and passwordless auth

The azuread_administrator block designates an Entra ID user or group as a database administrator. Its real payoff is azuread_authentication_only = true, which disables SQL authentication altogether — then there is no admin password to generate, store, or leak, and you drop the administrator_login* arguments entirely:

Auth model Server arguments Password in state? Recommended for
SQL auth only administrator_login + administrator_login_password Yes Legacy apps, bootstrapping
SQL + Entra Both, plus azuread_administrator {} Yes (SQL pw still set) Migration window
Entra-only azuread_administrator { azuread_authentication_only = true } No (no SQL pw at all) New workloads

We use SQL auth in the demo precisely so the Key Vault + random_password flow is visible end to end. In production, Entra-only is the target: the app authenticates with its managed identity, mapped to a database user via CREATE USER [<identity>] FROM EXTERNAL PROVIDER, and no password exists anywhere.

Purchasing models: DTU vs vCore

The database SKU (sku_name) is the money and performance decision, and it comes in two purchasing models. Getting this table right saves the most rupees:

Model sku_name examples Sizing unit Scales Best for
DTU – Basic Basic 5 DTU, 2 GB max Fixed tiers Tiny dev/test DBs
DTU – Standard S0S12 10–3000 DTU Tier steps Predictable small/medium apps
DTU – Premium P1P15 125–4000 DTU Tier steps Latency-sensitive, zone-redundant
vCore – General Purpose GP_Gen5_2GP_Gen5_80 vCores + storage Independently Most production; balanced cost
vCore – GP Serverless GP_S_Gen5_2 Auto-scales vCores; auto-pause Per-second billing Intermittent/dev workloads
vCore – Business Critical BC_Gen5_2 vCores + local SSD + replicas Independently Low-latency, HA, read replica
vCore – Hyperscale HS_Gen5_2 Up to 100 TB Rapid, independent Very large / fast-growing DBs

DTU bundles compute+IO+storage into one number (simple, less tunable); vCore separates compute and storage (transparent, tunable, supports serverless auto-pause and reserved-capacity discounts). For a first production DB, General Purpose vCore is the safe default; serverless GP is the cheapest thing that is still “real” for dev because it pauses when idle.

The database resource carries size, HA and retention:

resource "azurerm_mssql_database" "db" {
  name         = "appdb"
  server_id    = azurerm_mssql_server.sql.id      # v4 uses server_id (not server_name/rg)
  sku_name     = "S0"                             # DTU Standard S0 for the demo
  max_size_gb  = 250
  collation    = "SQL_Latin1_General_CP1_CI_AS"
  zone_redundant       = false                    # Premium/BC (and newer GP) only
  storage_account_type = "Geo"                    # Geo | Zone | Local | GeoZone backups

  short_term_retention_policy {
    retention_days = 7                            # 1-35 days of PITR
  }
  long_term_retention_policy {
    weekly_retention  = "P4W"                     # ISO-8601 durations
    monthly_retention = "P12M"
    yearly_retention  = "P5Y"
    week_of_year      = 1
  }

  tags = local.tags
}

Key database arguments and their traps:

Argument What it does Trap
server_id Which server hosts the DB v4 arg name; v3 used server_name+resource_group_name
sku_name Purchasing tier Cross-model change (DTU↔vCore) can force a longer op
max_size_gb Storage ceiling Must fit the tier’s max; shrinking may be blocked
zone_redundant Spread replicas across AZs Not on Basic/Standard/GP-classic; set false there or apply fails
storage_account_type Backup redundancy Geo default; Local/Zone cheaper, less durable
short_term_retention_policy Point-in-time restore window 1–35 days
long_term_retention_policy Weekly/monthly/yearly backups ISO-8601 strings (P4W, P12M, P5Y)
For serverless auto_pause_delay_in_minutes, min_capacity Required on GP_S_*; omit on provisioned

Firewall rules — the resource that lies about its name

By default a SQL server accepts no connections. You open access with azurerm_mssql_firewall_rule — and its most common form does not mean what its IP range suggests:

Rule (start–end IP) What it actually allows Safe?
0.0.0.00.0.0.0 “Allow Azure services and resources” (not the internet!) Convenient; broad (any Azure tenant’s resources)
<your IP><your IP> Just your client ✅ Yes, for admin access
10.0.0.010.0.0.255 A specific range ✅ Scoped
0.0.0.0255.255.255.255 The entire internet Never
# "Allow Azure services" — the special 0.0.0.0 / 0.0.0.0 rule
resource "azurerm_mssql_firewall_rule" "allow_azure" {
  name             = "AllowAzureServices"
  server_id        = azurerm_mssql_server.sql.id
  start_ip_address = "0.0.0.0"
  end_ip_address   = "0.0.0.0"
}

# Your workstation, so you can connect and verify
resource "azurerm_mssql_firewall_rule" "my_ip" {
  name             = "AdminWorkstation"
  server_id        = azurerm_mssql_server.sql.id
  start_ip_address = var.my_ip
  end_ip_address   = var.my_ip
}

⚠️ The 0.0.0.0255.255.255.255 “open to the world” rule is the classic breach vector — someone adds it “just to test” and forgets. Because it is now in Terraform, a reviewer sees it in the PR diff and blocks it, and a plan flags it if it appears out of band. That reviewability is the whole point.

For production, prefer a private endpoint over any public firewall rule. The three connectivity postures:

Posture How Public exposure Needs
Public + firewall azurerm_mssql_firewall_rule allow-list Public endpoint, IP-scoped Nothing extra
Private endpoint azurerm_private_endpoint + private DNS None (public_network_access_enabled = false) VNet, subnet, private DNS zone
Service endpoint azurerm_mssql_virtual_network_rule Public endpoint, VNet-scoped VNet + subnet service endpoint

The private-endpoint path (which ties into the private DNS section next):

resource "azurerm_private_endpoint" "sql" {
  name                = "pe-sql"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  subnet_id           = azurerm_subnet.data.id

  private_service_connection {
    name                           = "psc-sql"
    private_connection_resource_id = azurerm_mssql_server.sql.id
    subresource_names              = ["sqlServer"]
    is_manual_connection           = false
  }

  private_dns_zone_group {
    name                 = "sql"
    private_dns_zone_ids = [azurerm_private_dns_zone.sql.id]
  }
}

Connection strings

You rarely hardcode a connection string, but you often output one (marked sensitive) or feed one to an app setting. The forms differ by auth:

Auth Connection string shape Sensitive?
SQL auth Server=tcp:<fqdn>,1433;Database=<db>;User ID=<login>;Password=<pw>;Encrypt=True; Yes (has the password)
Entra – Default Server=tcp:<fqdn>,1433;Database=<db>;Authentication=Active Directory Default;Encrypt=True; No secret
Entra – Managed Identity ...;Authentication=Active Directory Managed Identity;Encrypt=True; No secret

The recommended output uses Entra (no secret in the string), so it need not be sensitive — but if you ever emit the SQL-auth form, mark the output sensitive = true.

Azure DNS as code

Azure DNS hosts DNS zones on Azure’s global name-server fleet. Two flavors: public zones (internet-resolvable) and private zones (resolvable only inside linked VNets — the backbone of private-endpoint name resolution). Our demo publishes the app on a public zone.

The zone and its record resources:

Resource Record type Points at Notes
azurerm_dns_zone (the zone itself) Public; Azure assigns 4 name servers
azurerm_dns_a_record A IPv4 address(es) or an Azure resource (alias) records or target_resource_id
azurerm_dns_aaaa_record AAAA IPv6 Same alias option
azurerm_dns_cname_record CNAME Another hostname Single record value
azurerm_dns_txt_record TXT Text (SPF, verification) record { value = ... } blocks
azurerm_dns_mx_record MX Mail exchangers record { preference, exchange }
azurerm_dns_ns_record NS Delegation to child zone For subdomain delegation
resource "azurerm_dns_zone" "public" {
  name                = var.dns_zone_name        # e.g. "kv-demo.example"
  resource_group_name = azurerm_resource_group.rg.name
}

resource "azurerm_public_ip" "app" {
  name                = "pip-app"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  allocation_method   = "Static"
  sku                 = "Standard"
}

# Plain A record: static IP you manage
resource "azurerm_dns_a_record" "app" {
  name                = "app"                     # → app.kv-demo.example
  zone_name           = azurerm_dns_zone.public.name
  resource_group_name = azurerm_resource_group.rg.name
  ttl                 = 300
  records             = [azurerm_public_ip.app.ip_address]
}

Alias records vs plain records

A plain A record stores a literal IP. An alias record stores a target_resource_id and tracks the Azure resource — if the public IP changes, the record follows automatically. You set one or the other, never both:

Record style Argument Behavior on resource change Use when
Plain records = [ip] Stale until you re-apply IP is static/external
Alias target_resource_id = <id> Auto-updates to the resource’s current IP Pointing at an Azure Public IP / Traffic Manager / Front Door
# Alias A record: follows the Public IP automatically (omit `records`)
resource "azurerm_dns_a_record" "app_alias" {
  name                = "app"
  zone_name           = azurerm_dns_zone.public.name
  resource_group_name = azurerm_resource_group.rg.name
  ttl                 = 300
  target_resource_id  = azurerm_public_ip.app.id
}

The catch that trips everyone: a public Azure DNS zone only resolves once you delegate it — copy the four name servers Azure assigned (azurerm_dns_zone.public.name_servers) into your domain registrar’s NS records for that name. Terraform creates the zone; it cannot delegate a domain it doesn’t manage. Until you delegate, nslookup app.kv-demo.example returns nothing.

Public vs private DNS zones

For private endpoints, name resolution must return the private IP inside the VNet. That’s what a private DNS zone does — it overrides the public *.database.windows.net name with the endpoint’s private address:

Aspect Public zone (azurerm_dns_zone) Private zone (azurerm_private_dns_zone)
Resolvable from The internet Only linked VNets
Delegation NS records at registrar None — linked, not delegated
Endpoint link n/a azurerm_private_dns_zone_virtual_network_link
SQL private zone name n/a privatelink.database.windows.net (fixed)
Auto-registration n/a Optional (registration_enabled)
resource "azurerm_private_dns_zone" "sql" {
  name                = "privatelink.database.windows.net"   # fixed name for SQL
  resource_group_name = azurerm_resource_group.rg.name
}

resource "azurerm_private_dns_zone_virtual_network_link" "sql" {
  name                  = "sql-link"
  resource_group_name   = azurerm_resource_group.rg.name
  private_dns_zone_name  = azurerm_private_dns_zone.sql.name
  virtual_network_id     = azurerm_virtual_network.vnet.id
  registration_enabled   = false
}

Each Azure service has a specific privatelink zone name (SQL is privatelink.database.windows.net, blob is privatelink.blob.core.windows.net, Key Vault is privatelink.vaultcore.azure.net). Use the exact string, or resolution silently returns the public IP.

Hands-on: build it with Terraform

Now the centerpiece — a complete directory you can copy, apply, verify, and destroy. It builds the full stack from the diagram: random_password → Key Vault (RBAC secret) → SQL server + database (firewalled, using the secret) → DNS A record.

Left-to-right Terraform Azure architecture: a random_password resource and the tfstate blob in a Terraform zone; a Key Vault zone with RBAC auth, purge protection and the SQL secret; an Azure SQL zone with the logical server, the database and a firewall rule; and an Azure DNS zone with the public zone and an A record pointing at the app IP. Numbered badges mark the RBAC-vs-access-policy fork, purge protection, password-via-random+KV, the secret landing in state, the SQL firewall, and the DNS record.

Read it left→right: Terraform mints the password and (unavoidably) records it in state; Key Vault stores it under RBAC with purge protection; the SQL server and database consume it behind a firewall rule; and Azure DNS publishes the app’s hostname. The six badges are the six things that go wrong in production — we hit each one below.

The directory has five files:

File Contains
versions.tf required_version, required_providers, backend, provider (with the KV features)
variables.tf Inputs: subscription, location, names, admin identity, your IP, zone name
main.tf RG, random_password, Key Vault + role assignment + secret, SQL server + DB + firewall, public IP, DNS zone + A record
outputs.tf FQDNs, the app hostname, a (sensitive) connection string
terraform.tfvars Your actual values (⚠️ never commit real secrets)

versions.tf — providers pinned, remote state, and the KV feature that governs destroy:

terraform {
  required_version = ">= 1.6"

  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
    azuread = { source = "hashicorp/azuread", version = "~> 3.0" }
    random  = { source = "hashicorp/random",  version = "~> 3.6" }
  }

  # Remote state — keep the (secret-bearing) state encrypted & RBAC-locked
  backend "azurerm" {
    resource_group_name  = "rg-tfstate"
    storage_account_name = "sttfstateprod001"
    container_name       = "tfstate"
    key                  = "keyvault-sql-dns.tfstate"
  }
}

provider "azurerm" {
  subscription_id = var.subscription_id     # required by azurerm v4
  features {
    key_vault {
      purge_soft_delete_on_destroy    = true
      recover_soft_deleted_key_vaults = true
    }
  }
}

provider "azuread" {}
provider "random" {}

variables.tf:

variable "subscription_id" { type = string }

variable "location" {
  type    = string
  default = "centralindia"
}

variable "prefix" {
  type    = string
  default = "kvdemo"
}

variable "sql_admin_login" {
  type    = string
  default = "sqladminuser"
}

variable "entra_admin_upn"       { type = string }   # you@tenant.onmicrosoft.com
variable "entra_admin_object_id" { type = string }   # objectId of that user/group

variable "my_ip"      { type = string }               # "203.0.113.10"
variable "my_ip_cidr" { type = string }               # "203.0.113.10/32"

variable "dns_zone_name" {
  type    = string
  default = "kv-demo.example"
}

main.tf — the whole stack:

data "azurerm_client_config" "current" {}

locals {
  tags = {
    project   = "kv-sql-dns-demo"
    managedBy = "terraform"
    env       = "demo"
  }
}

resource "azurerm_resource_group" "rg" {
  name     = "rg-${var.prefix}-demo"
  location = var.location
  tags     = local.tags
}

# ---------- Terraform generates the credential ----------
resource "random_password" "sql" {
  length           = 24
  special          = true
  override_special = "!#$%*-_=+"
  min_upper        = 2
  min_lower        = 2
  min_numeric      = 2
}

# ---------- Key Vault (RBAC) ----------
resource "azurerm_key_vault" "kv" {
  name                       = "kv-${var.prefix}-001"
  location                   = azurerm_resource_group.rg.location
  resource_group_name        = azurerm_resource_group.rg.name
  tenant_id                  = data.azurerm_client_config.current.tenant_id
  sku_name                   = "standard"
  rbac_authorization_enabled = true
  soft_delete_retention_days = 7        # min 7; keep short for a demo
  purge_protection_enabled   = false    # ⚠️ demo only — set true in prod

  network_acls {
    default_action = "Allow"            # demo; use "Deny" + ip_rules in prod
    bypass         = "AzureServices"
  }
  tags = local.tags
}

resource "azurerm_role_assignment" "kv_secrets_officer" {
  scope                = azurerm_key_vault.kv.id
  role_definition_name = "Key Vault Secrets Officer"
  principal_id         = data.azurerm_client_config.current.object_id
}

resource "azurerm_key_vault_secret" "sql_admin_password" {
  name         = "sql-admin-password"
  value        = random_password.sql.result
  key_vault_id = azurerm_key_vault.kv.id
  content_type = "password"
  depends_on   = [azurerm_role_assignment.kv_secrets_officer]  # RBAC lands first
}

# ---------- Azure SQL ----------
resource "azurerm_mssql_server" "sql" {
  name                          = "sql-${var.prefix}-001"
  resource_group_name           = azurerm_resource_group.rg.name
  location                      = azurerm_resource_group.rg.location
  version                       = "12.0"
  administrator_login           = var.sql_admin_login
  administrator_login_password  = random_password.sql.result   # ⚠️ in state
  minimum_tls_version           = "1.2"
  public_network_access_enabled = true

  azuread_administrator {
    login_username              = var.entra_admin_upn
    object_id                   = var.entra_admin_object_id
    tenant_id                   = data.azurerm_client_config.current.tenant_id
    azuread_authentication_only = false
  }
  tags = local.tags
}

resource "azurerm_mssql_database" "db" {
  name        = "appdb"
  server_id   = azurerm_mssql_server.sql.id
  sku_name    = "S0"
  max_size_gb = 250
  collation   = "SQL_Latin1_General_CP1_CI_AS"
  tags        = local.tags
}

resource "azurerm_mssql_firewall_rule" "allow_azure" {
  name             = "AllowAzureServices"
  server_id        = azurerm_mssql_server.sql.id
  start_ip_address = "0.0.0.0"
  end_ip_address   = "0.0.0.0"
}

resource "azurerm_mssql_firewall_rule" "my_ip" {
  name             = "AdminWorkstation"
  server_id        = azurerm_mssql_server.sql.id
  start_ip_address = var.my_ip
  end_ip_address   = var.my_ip
}

# ---------- Azure DNS ----------
resource "azurerm_public_ip" "app" {
  name                = "pip-${var.prefix}-app"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  allocation_method   = "Static"
  sku                 = "Standard"
}

resource "azurerm_dns_zone" "public" {
  name                = var.dns_zone_name
  resource_group_name = azurerm_resource_group.rg.name
}

resource "azurerm_dns_a_record" "app" {
  name                = "app"
  zone_name           = azurerm_dns_zone.public.name
  resource_group_name = azurerm_resource_group.rg.name
  ttl                 = 300
  target_resource_id  = azurerm_public_ip.app.id   # alias → tracks the IP
}

outputs.tf:

output "key_vault_uri" {
  value = azurerm_key_vault.kv.vault_uri
}

output "sql_server_fqdn" {
  value = azurerm_mssql_server.sql.fully_qualified_domain_name
}

output "app_hostname" {
  value = "app.${azurerm_dns_zone.public.name}"
}

output "zone_name_servers" {
  description = "Delegate these NS records at your registrar."
  value       = azurerm_dns_zone.public.name_servers
}

output "sql_connection_string" {
  description = "Entra (passwordless) connection string."
  value       = "Server=tcp:${azurerm_mssql_server.sql.fully_qualified_domain_name},1433;Database=${azurerm_mssql_database.db.name};Authentication=Active Directory Default;Encrypt=True;"
}

Run it, step by step

Step 1 — terraform init. Downloads the three providers and wires the Azure backend. Expect:

Initializing the backend...
Initializing provider plugins...
- Installing hashicorp/azurerm v4.x.x...
- Installing hashicorp/azuread v3.x.x...
- Installing hashicorp/random v3.x.x...
Terraform has been successfully initialized!

Step 2 — terraform plan -out tfplan. Read the summary line — it should propose creating everything and nothing destructive:

Plan: 11 to add, 0 to change, 0 to destroy.

Notice the random_password and the two administrator_login_password occurrences are shown as (sensitive value) — Terraform redacts them in plan output, but they still land in state. That redaction is cosmetic; the state protection (encrypted backend) is what matters.

Step 3 — terraform apply tfplan. Watch the ordering: random_password is instant; the role assignment applies and Terraform waits on it (depends_on) before writing the secret; the SQL server (a minute or two) precedes the database and firewall rules. On success:

Apply complete! Resources: 11 added, 0 changed, 0 destroyed.

Outputs:
app_hostname      = "app.kv-demo.example"
key_vault_uri     = "https://kv-kvdemo-001.vault.azure.net/"
sql_server_fqdn   = "sql-kvdemo-001.database.windows.net"
zone_name_servers = tolist(["ns1-01.azure-dns.com.", ...])

Step 4 — verify. Confirm each service independently, not just “apply succeeded”:

Check Command Expected
Secret is in KV az keyvault secret show --vault-name kv-kvdemo-001 --name sql-admin-password --query value -o tsv The 24-char password
DB exists & tier az sql db show -g rg-kvdemo-demo -s sql-kvdemo-001 -n appdb -o table appdb, Online, Standard/S0
Firewall rules az sql server firewall-rule list -g rg-kvdemo-demo -s sql-kvdemo-001 -o table AllowAzureServices, AdminWorkstation
DNS record az network dns record-set a list -g rg-kvdemo-demo -z kv-demo.example -o table app with the IP
Connect (Entra) sqlcmd -S sql-kvdemo-001.database.windows.net -d appdb -G -Q "SELECT 1" 1

The -G flag on sqlcmd uses Entra auth (your logged-in identity, which is the azuread_administrator). If you’d rather test SQL auth, pull the password from Key Vault and pass -U sqladminuser -P "<pw>".

Step 5 — terraform destroy. ⚠️ This tears down real (billed) resources:

Plan: 0 to add, 0 to change, 11 to destroy.
...
Destroy complete! Resources: 11 destroyed.

Because we set purge_protection_enabled = false and soft_delete_retention_days = 7, the vault soft-deletes cleanly; its name is parked for 7 days. Had purge protection been true, destroy would remove the vault but you could not purge (or reuse the name) until the window elapsed — the deliberate trap we flagged. If you must reuse the name immediately in a demo, either change the vault name or manually recover/purge a non-protected soft-deleted vault with az keyvault purge --name <vault> (only works without purge protection).

Variables, outputs & making it reusable

The demo hardcodes structure but parameterizes the moving parts. To make it a reusable module, lift the resources into modules/data-tier/ and expose a tight input surface. A for_each over a map of databases turns “one DB” into “N DBs on the shared server”:

variable "databases" {
  type = map(object({
    sku_name    = string
    max_size_gb = number
  }))
  default = {
    appdb     = { sku_name = "S0", max_size_gb = 250 }
    reporting = { sku_name = "S1", max_size_gb = 500 }
  }
}

resource "azurerm_mssql_database" "db" {
  for_each    = var.databases
  name        = each.key
  server_id   = azurerm_mssql_server.sql.id
  sku_name    = each.value.sku_name
  max_size_gb = each.value.max_size_gb
  tags        = local.tags
}

A sensible module input surface:

Input Type Why expose it
prefix / location string Naming + region per environment
kv_sku / purge_protection string / bool Standard-vs-premium; prod turns protection on
databases map(object) Add DBs without touching the module
entra_admin_* string Environment-specific admin group
allowed_ips list(string) Firewall allow-list per env
use_private_endpoint bool Toggle public firewall vs private endpoint
dns_zone_name string Zone per environment

You don’t have to write the vault or role-assignment plumbing yourself — Microsoft’s Azure Verified Modules (AVM) publish maintained, tested equivalents:

Need Registry module Roll-your-own when
Key Vault Azure/avm-res-keyvault-vault/azurerm You need bespoke network/policy shapes
Role assignment Azure/avm-res-authorization-roleassignment/azurerm Trivial single grants (inline is fine)
Naming Azure/naming/azurerm You have a strict internal naming standard
SQL server/DB community azurerm SQL modules You want full control of HA/retention wiring

Use AVM for the vault (its RBAC/network handling is fiddly and worth not re-deriving); keep the SQL + DNS inline or in a thin local module where you want the retention/firewall logic visible in review. The trade-off is the usual one: registry modules buy you tested defaults and lose you some transparency. For how to author and version your own, see the module lessons in this course; the architecting-ladder lesson covers when a local module should graduate to a shared, versioned one.

Common mistakes and troubleshooting

Every row here is something that has cost a real engineer real time on exactly this stack:

# Symptom Cause Fix
1 apply fails writing the secret: 403 Forbidden / does not have secrets set permission RBAC vault, but the caller has no data-plane role yet (or it hasn’t propagated) Add azurerm_role_assignment (Secrets Officer) + depends_on on the secret; re-run if it’s a propagation lag
2 access_policy block “not allowed” / has no effect Vault has rbac_authorization_enabled = true Use azurerm_role_assignment, not azurerm_key_vault_access_policy — don’t mix modes
3 destroy leaves the vault; name can’t be reused purge_protection_enabled = true Expected — wait out soft_delete_retention_days, or don’t enable purge protection in dev
4 Recreating a vault: name already in use / conflict A prior soft-deleted vault holds the name recover_soft_deleted_key_vaults = true, or az keyvault purge (only if not purge-protected)
5 A password appears in terraform.tfstate in cleartext random_password.result / secret data source is referenced by a resource Accept it’s inherent; protect state (encrypted, RBAC-locked backend); prefer app-side KV references / MI
6 SQL connect fails: Cannot open server ‘…’ requested by the login No firewall rule for your client IP Add an azurerm_mssql_firewall_rule for your IP; 0.0.0.0/0.0.0.0 is Azure services, not you
7 Server “open to the world” flagged in review Someone added 0.0.0.0255.255.255.255 Remove it; use scoped IPs or a private endpoint; the PR diff is your control
8 azurerm_mssql_database apply fails on zone_redundant Set true on Basic/Standard/GP-classic Set false, or move to Premium/Business Critical
9 nslookup app.<zone> returns nothing after apply Zone created but not delegated at the registrar Copy zone_name_servers output into the registrar’s NS records; wait for TTL
10 Private-endpoint app resolves the public SQL IP Missing/mis-named privatelink.database.windows.net zone or VNet link Create the exact privatelink zone + virtual_network_link
11 Provider error: subscription_id is required azurerm v4 needs it explicitly Set subscription_id in the provider block or ARM_SUBSCRIPTION_ID
12 Deprecation warning on enable_rbac_authorization v4 renamed it Use rbac_authorization_enabled (old name removed in v5)

Four of these deserve a sentence of context. Row 1 (the 403) is the number-one first-run failure on RBAC vaults: management-plane creation gives you nothing on the data plane, so Terraform can create the vault but not write a secret into it until the role assignment lands — and role assignments can take a beat to propagate, so the depends_on matters as much as the grant. Row 5 (secret in state) is not a bug to fix but a property to manage: any value Terraform must know is written to state, redaction in plan output notwithstanding; the mitigation is a locked-down backend plus, wherever possible, letting the app read Key Vault itself so Terraform never touches the plaintext. Row 6 (firewall) catches everyone once because the 0.0.0.00.0.0.0 rule reads like “all IPs” but means “allow other Azure services” — your laptop still needs its own rule. Row 9 (DNS) is the reminder that Terraform’s job ends at the zone: resolution only works after you delegate the zone’s name servers at whoever holds the parent domain.

Cost, cleanup & production notes

Left running, this demo is cheap but not free. Approximate India-region monthly costs:

Resource Config Rough monthly cost Notes
Key Vault Standard, few secrets ~₹0–20 Priced per 10k operations; near-zero at rest
Azure SQL DB S0 (10 DTU) ~₹1,200–1,300 The dominant cost; serverless GP is cheaper if idle
Public IP Standard, static ~₹250–300 Billed even when unattached
Azure DNS zone 1 public zone ~₹40 + query fees ₹40/zone/mo + per-million-query
Total ~₹1,500–1,900/mo Well within a personal budget if destroyed

⚠️ The SQL database is the meter that spins. If you’re only learning, use a serverless General Purpose SKU (GP_S_Gen5_1) with auto_pause_delay_in_minutes = 60 so it pauses when idle, or simply terraform destroy between sessions. Nothing here needs to run overnight.

To clean up: terraform destroy removes all 11 resources. The only residue is the soft-deleted vault name (parked for the retention window) and, if you enabled purge protection, the inability to purge until it expires. Everything else is gone and billing stops.

Five production hardening notes for this exact tier:

Hardening What to change Why
Passwordless SQL azuread_authentication_only = true; drop administrator_login* No password to generate, store, or leak
Private endpoint public_network_access_enabled = false + azurerm_private_endpoint + private DNS Remove the public SQL surface entirely
Purge protection ON purge_protection_enabled = true on the real vault Compliance; prevents malicious/accidental purge
Locked-down state Azure blob backend, RBAC + private endpoint on the storage account State holds secrets — protect it like production data
Least-privilege grants App gets Secrets User (read), not Officer; Terraform identity scoped tightly Blast-radius control

Two of those tie back to earlier lessons: the SQL server (and the app in front of it) authenticate with managed identity rather than secrets — the same pattern the App Service lesson uses to consume Key Vault via a slot-safe reference, and the Application Gateway / WAF lesson fronts with a public IP that this very DNS record would point at.

Cheat-sheet

Resources and their load-bearing arguments:

Resource Must-set arguments Watch out
azurerm_key_vault tenant_id, sku_name, rbac_authorization_enabled, soft_delete_retention_days purge_protection_enabled is one-way
azurerm_role_assignment scope, role_definition_name, principal_id Propagation lag; depends_on from secrets
azurerm_key_vault_secret name, value, key_vault_id Value lands in state
azurerm_key_vault_key key_type, key_size/curve, key_opts Needs Crypto Officer role
azurerm_mssql_server version="12.0", administrator_login[_password] or Entra-only Password in state; minimum_tls_version="1.2"
azurerm_mssql_database server_id, sku_name zone_redundant tier limits
azurerm_mssql_firewall_rule server_id, start_ip_address, end_ip_address 0.0.0.0/0.0.0.0 = Azure services
azurerm_dns_zone name Must delegate NS at registrar
azurerm_dns_a_record records or target_resource_id Never both; alias auto-tracks
azurerm_private_dns_zone exact privatelink.* name Wrong name → resolves public IP

Command quick-reference:

Task Command
Init / plan / apply terraform init · terraform plan -out tfplan · terraform apply tfplan
Show a secret az keyvault secret show --vault-name <v> --name <n> --query value -o tsv
List firewall rules az sql server firewall-rule list -g <rg> -s <server> -o table
Show DB az sql db show -g <rg> -s <server> -n <db> -o table
List DNS A records az network dns record-set a list -g <rg> -z <zone> -o table
Get zone name servers az network dns zone show -g <rg> -n <zone> --query nameServers
Connect via Entra sqlcmd -S <server>.database.windows.net -d <db> -G -Q "SELECT 1"
Purge a soft-deleted vault az keyvault purge --name <vault> (only if not purge-protected)
Destroy terraform destroy

Interview and exam questions

1. What’s the difference between RBAC authorization and access policies on a Key Vault, and which do you pick? Access policies are a vault-local permission list (azurerm_key_vault_access_policy); RBAC uses standard Azure role assignments (azurerm_role_assignment) and is Microsoft’s recommendation. Set rbac_authorization_enabled = true and grant Key Vault Secrets Officer/User. Don’t mix — with RBAC on, access policies are ignored.

2. Why does your first apply get a 403 writing a secret to a brand-new RBAC vault? Creating the vault (management plane) grants no data-plane rights. You must add a role assignment (Secrets Officer) for the Terraform identity and depends_on it from the secret, since role propagation isn’t instant.

3. A colleague says “we use Key Vault, so no secrets are in Terraform state.” True? Not necessarily. random_password.result and the azurerm_key_vault_secret data source both write plaintext into state. Key Vault helps only if the app reads it at runtime (KV reference / managed identity). If Terraform must know the value, protect the state backend.

4. What does the SQL firewall rule 0.0.0.00.0.0.0 actually allow? “Allow Azure services and resources” — not the whole internet. The internet-open rule is 0.0.0.0255.255.255.255, which you should never use. Your own client needs a rule for its specific IP.

5. Explain purge protection and how it affects terraform destroy. With purge_protection_enabled = true, a deleted vault/secret can’t be hard-deleted before the soft-delete window elapses — even by an admin, even by the provider’s purge_soft_delete_on_destroy. Destroy removes the vault but the name is parked; it’s a one-way, compliance-grade control.

6. DTU vs vCore — how do you choose? DTU (Basic, S0S12, P1P15) bundles compute+IO+storage into one number: simple, less tunable. vCore (GP_, BC_, HS_, and serverless GP_S_) separates compute and storage: transparent, supports serverless auto-pause and reserved capacity. Default to GP vCore for production; serverless GP for cheap dev.

7. Your DNS A record resolves nowhere after a successful apply. Why? The public zone isn’t delegated. Azure gave you four name servers (name_servers output); you must set them as NS records at the domain’s registrar. Terraform can’t delegate a domain it doesn’t control.

8. Plain A record vs alias A record? Plain stores a literal IP in records; alias stores target_resource_id and auto-tracks the Azure resource’s current IP. Set one, never both. Use alias when pointing at an Azure Public IP, Traffic Manager, or Front Door.

9. How do you make this SQL stack passwordless? Set azuread_administrator { azuread_authentication_only = true } and drop administrator_login/administrator_login_password. The app connects with its managed identity, mapped to a DB user via CREATE USER [...] FROM EXTERNAL PROVIDER. No secret is generated, stored, or leaked.

10. (Associate-style) Why depends_on on the azurerm_key_vault_secret when it already references the vault by key_vault_id? The implicit dependency is on the vault, not on the role assignment that grants write permission. Without an explicit depends_on the role assignment, Terraform may try to write the secret before the grant lands → 403.

11. (Associate-style) You changed enable_rbac_authorization and got a deprecation warning. What now? azurerm v4 renamed it to rbac_authorization_enabled (old name removed in v5). Update the argument; the behavior is identical.

12. Where’s the right place to point this app’s public DNS record in production? Usually not a raw VM IP but a fronting layer — an Application Gateway/Load Balancer public IP or Front Door — via an alias record so it tracks the resource automatically. The DNS zone stays here; the target is whatever the ingress lesson provisions.

Key takeaways

TerraformazurermAzure Key VaultAzure SQLAzure DNSRBACprivate-endpointremote-statemanaged-identityIaCrandom_passwordsecrets
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments