Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Azure MySQL Flexible Server Terraform Module

Manage an Azure Database for MySQL Flexible Server with its databases, parameters, firewall rules, and optional Entra administrator — private by default — on hashicorp/azurerm ~> 4.0.

Terraform azurerm module type resources

🧩 Overview

  • 🐬 Creates one azurerm_mysql_flexible_server with public network access disabled and storage auto-grow on by default.
  • 🗄️ Manages databases, server parameters, firewall rules, and an optional Entra administrator as keyed maps (for_each).
  • 🔒 The administrator password is provisioned out of band and marked sensitive.
  • 🧱 Optional VNet integration, high availability, maintenance window, managed identity, and customer-managed keys.

💡 Why it matters: managed MySQL for a regulated workload should default to a private endpoint with resilient storage. This module makes the private, auto-growing posture the empty-call default and treats public access as an explicit opt-out.

❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!

🗺️ Where this fits in the family

flowchart TD
  RG["terraform-azurerm-resource-group"]
  VNET["terraform-azurerm-virtual-network (delegated subnet)"]
  PDZ["terraform-azurerm-private-dns-zone"]
  KV["terraform-azurerm-key-vault (CMK)"]
  MY["terraform-azurerm-mysql-flexible-server"]
  AMY["azurerm_mysql_flexible_server"]
  CHILD["databases / configurations / firewall_rules / entra_admin (for_each)"]

  RG -->|"resource_group_name + location"| MY
  VNET -->|"delegated_subnet_id"| MY
  PDZ -->|"private_dns_zone_id"| MY
  KV -->|"customer_managed_key"| MY
  MY --> AMY
  AMY --> CHILD

  classDef this fill:#0078D4,color:#ffffff,stroke:#004578,stroke-width:2px;
  classDef key fill:#004578,color:#ffffff,stroke:#004578;
  class MY this;
  class AMY key;
Loading

🧬 What this module builds

flowchart LR
  I1["name / location / resource_group_name / version"]
  I2["administrator_login + password (out of band)"]
  I3["public_network_access Disabled, storage auto-grow on"]
  I4["databases / configurations / firewall_rules / entra_administrator"]

  MY["azurerm_mysql_flexible_server.this"]
  DB["..._database.this (for_each)"]
  CFG["..._configuration.this (for_each)"]
  FW["..._firewall_rule.this (for_each)"]
  AAD["..._active_directory_administrator.this (0..1)"]

  O1["id / name / fqdn"]
  O2["database_ids / firewall_rule_ids"]

  I1 --> MY
  I2 --> MY
  I3 --> MY
  I4 --> DB
  I4 --> CFG
  I4 --> FW
  I4 --> AAD
  MY --> DB
  MY --> CFG
  MY --> FW
  MY --> AAD
  MY --> O1
  DB --> O2

  classDef this fill:#0078D4,color:#ffffff,stroke:#004578,stroke-width:2px;
  classDef key fill:#004578,color:#ffffff,stroke:#004578;
  class MY key;
  class DB this;
Loading

Resource inventory

Resource Cardinality Role
azurerm_mysql_flexible_server.this 1 (keystone) The Flexible Server.
azurerm_mysql_flexible_database.this 0..N (for_each) Databases.
azurerm_mysql_flexible_server_configuration.this 0..N (for_each) Server parameters.
azurerm_mysql_flexible_server_firewall_rule.this 0..N (for_each) Firewall rules.
azurerm_mysql_flexible_server_active_directory_administrator.this 0..1 Entra administrator.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
Provider hashicorp/azurerm ~> 4.0
Provider block None in this module — the caller configures provider "azurerm" { features {} }, auth, and subscription.

Schema notes that bite

  • name, resource_group_name, location, geo_redundant_backup_enabled, delegated_subnet_id, private_dns_zone_id, create_mode, source_server_id and point_in_time_restore_time_in_utc are all force-new. location and resource_group_name take theirs from the provider's shared schema helpers, so they do not appear in a plain search of the resource source.
  • 🔴 administrator_login is force-new too. Renaming the administrator destroys the server and every database in it. The password is not — rotating it is an ordinary in-place update. Two adjacent arguments, one a rebuild and one a routine change, with nothing in their names to tell them apart.
  • 🔴 Shrinking storage.size_gb DESTROYS THE SERVER. The field is force-new in one direction only, via the provider's CustomizeDiff: growing the volume is an online update, any decrease replaces the server. No ForceNew marker records it. Note the interaction with auto_grow_enabled (default true): the volume can grow on its own, after which a configuration still naming the original size_gb is asking for a shrink.
  • For the Default create mode, administrator_login + administrator_password are required; for replica/restore modes they come from the source and must be null. Both directions are enforced by this module.
  • private_dns_zone_id is required whenever delegated_subnet_id is set, and the zone name must end .mysql.database.azure.com. Microsoft documents both; the provider enforces neither, so this module does.
  • storage.iops cannot be set when storage.io_scaling_enabled is true — documented, not schema-enforced. Note also that the provider's own range is 360–48000 while the registry documents 20000; the module enforces the provider's range and documents that Azure's effective ceiling is lower and varies by SKU.
  • public_network_access is a string (Enabled/Disabled), not a boolean; the 4.x-only public_network_access_enabled is read-only. A VNet-integrated server has access set to Disabled by Azure regardless of what is requested.
  • sku_name is validated against a strict provider regex, mirrored here — a near miss such as GP_Standard_D2s_v4 (missing the d) is refused. Burstable (B_) SKUs support no high availability, which this module also checks.
  • administrator_login is refused if it is any of six reserved names: azure_superuser, admin, administrator, root, guest, public.
  • Version upgrades are one-way — Azure offers no downgrade, and the only route back is restoring a pre-upgrade backup to a new server.
  • Only one Entra administrator per server, it requires a user-assigned identity for directory lookups, and it does not disable native MySQL authentication — the admin password keeps working alongside it.
  • identity is user-assigned only; this resource has no system-assigned form.
  • ⚠️ Two fields drift by design and ignore_changes is unavailable to callers: zone is rewritten by Azure after a failover, and create_mode is never returned by the API so it always differs after an import. lifecycle is not valid inside a module block, so leave both null where that matters.
  • Timeouts are long for a reason: the provider allows 2 hours for create and update, 1 hour for delete.

🔑 Required Azure RBAC Roles / Permissions

  • Contributor on the target resource group (or a custom role with Microsoft.DBforMySQL/flexibleServers/*).
  • Directory Reader (Entra) for the deploying identity to set an Entra administrator.

Azure Prerequisites

  • The Microsoft.DBforMySQL resource provider registered on the subscription.
  • An existing resource group; for VNet integration, a delegated subnet and a privatelink.mysql.database.azure.com private DNS zone.
  • For CMK: a Key Vault key and a user-assigned identity with crypto access.
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription; the module declares none of these.

📁 Module Structure

terraform-azurerm-mysql-flexible-server/
├── providers.tf     # required_version + azurerm ~> 4.0; no provider block
├── variables.tf     # name, rg, location, version, sku, admin, network, storage, HA, CMK, children
├── main.tf          # azurerm_mysql_flexible_server.this + for_each databases/configs/firewall + AD admin
├── outputs.tf       # id, name, fqdn, database_ids, firewall_rule_ids
├── README.md        # this document
├── SCOPE.md         # cross-module contract
├── LICENSE          # MIT
└── .gitignore       # canonical Terraform ignore set

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "mysql" {
  source                 = "git::https://github.com/microsoftexpert/terraform-azurerm-mysql-flexible-server.git?ref=v1.0.0"
  name                   = "mysql-platform-prod-eastus2"
  resource_group_name    = "rg-data-prod-eastus2"
  location               = "eastus2"
  administrator_login    = "mysqladmin"
  administrator_password = var.mysql_admin_password # out of band

  databases = { app = {} }
}

ℹ️ Public access disabled, storage auto-grow on. Pin the module by tag (?ref=v1.0.0), never a branch.

🔌 Cross-Module Contract

Consumes

Input Type From
resource_group_name string terraform-azurerm-resource-group (name)
location string caller / resource group (location)
delegated_subnet_id string terraform-azurerm-virtual-network (subnet_ids[key])
private_dns_zone_id string terraform-azurerm-private-dns-zone (id)
customer_managed_key.key_vault_key_id string terraform-azurerm-key-vault key id

Emits

Output Description Consumed by
id Server Resource ID diagnostics, references
name Server name reference
location / resource_group_name Server region and resource group backup instances and any sibling needing the SERVER's own placement
fqdn Connection FQDN app configuration
database_ids / firewall_rule_ids child maps audit

📚 Example Library

1 · Minimal (private)
module "mysql" {
  source                 = "git::https://github.com/microsoftexpert/terraform-azurerm-mysql-flexible-server.git?ref=v1.0.0"
  name                   = "mysql-min-eastus2"
  resource_group_name    = "rg-data-eastus2"
  location               = "eastus2"
  administrator_login    = "mysqladmin"
  administrator_password = var.mysql_admin_password
}

🔒 public_network_access = "Disabled" by default.

2 · Databases (utf8mb4)
databases = {
  app     = {}
  reports = { charset = "utf8mb4", collation = "utf8mb4_unicode_ci" }
}
3 · Server parameters
configurations = {
  "max_connections"     = { value = "512" }
  "slow_query_log"      = { value = "ON" }
  "long_query_time"     = { value = "2" }
}
4 · Firewall (Allow Azure services)
public_network_access = "Enabled"
firewall_rules = { allow-azure = { start_ip_address = "0.0.0.0", end_ip_address = "0.0.0.0" } }
5 · VNet-integrated (private)
module "mysql" {
  # ...
  delegated_subnet_id = var.mysql_subnet_id
  private_dns_zone_id = var.mysql_private_dns_zone_id
}
6 · Custom storage
storage = {
  size_gb            = 128
  auto_grow_enabled  = true
  io_scaling_enabled = true
}
7 · Zone-redundant HA
high_availability = { mode = "ZoneRedundant", standby_availability_zone = "2" }
zone              = "1"
8 · Maintenance window
maintenance_window = { day_of_week = 0, start_hour = 3, start_minute = 0 }
9 · Entra administrator
module "mysql" {
  # ...
  identity            = { type = "UserAssigned", identity_ids = [var.aad_identity_id] }
  entra_administrator = {
    login       = "dba-team"
    object_id   = var.dba_group_id
    tenant_id   = var.tenant_id
    identity_id = var.aad_identity_id
  }
}
10 · Customer-managed key
module "mysql" {
  # ...
  identity             = { type = "UserAssigned", identity_ids = [var.cmk_identity_id] }
  customer_managed_key = {
    key_vault_key_id                  = var.kv_key_id
    primary_user_assigned_identity_id = var.cmk_identity_id
  }
}
11 · Geo-redundant backups
module "mysql" {
  # ...
  geo_redundant_backup_enabled = true
  backup_retention_days        = 35
}
12 · Burstable SKU (dev)
module "mysql" {
  # ...
  sku_name = "B_Standard_B1ms"
  storage  = { size_gb = 20 }
}
13 · 🏗️ End-to-end composition

A resource group, a VNet with a delegated subnet, a private DNS zone, and a private MySQL server with a database.

provider "azurerm" {
  features {}
}

module "rg" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
  name     = "rg-data-prod-eastus2"
  location = "eastus2"
}

module "vnet" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network.git?ref=v1.0.0"
  name                = "vnet-data-prod-eastus2"
  resource_group_name = module.rg.name
  location            = module.rg.location
  address_space       = ["10.41.0.0/16"]
  subnets = {
    mysql = {
      address_prefixes = ["10.41.1.0/24"]
      delegations = [{ name = "mysql", service_delegation = { name = "Microsoft.DBforMySQL/flexibleServers" } }]
    }
  }
}

module "pdns" {
  source                = "git::https://github.com/microsoftexpert/terraform-azurerm-private-dns-zone.git?ref=v1.0.0"
  name                  = "prod.mysql.database.azure.com"
  resource_group_name   = module.rg.name
  virtual_network_links = { prod = { virtual_network_id = module.vnet.id } }
}

module "mysql" {
  source                 = "git::https://github.com/microsoftexpert/terraform-azurerm-mysql-flexible-server.git?ref=v1.0.0"
  name                   = "mysql-app-prod-eastus2"
  resource_group_name    = module.rg.name
  location               = module.rg.location
  administrator_login    = "mysqladmin"
  administrator_password = var.mysql_admin_password
  delegated_subnet_id    = module.vnet.subnet_ids["mysql"]
  private_dns_zone_id    = module.pdns.id
  databases              = { app = {} }
}

💡 The server integrates into the delegated subnet and resolves through the private zone — no public path.

📥 Inputs

Name Type Required Default Description
name string ✅ — Server name. Immutable.
resource_group_name string ✅ — Containing resource group. Immutable.
location string ✅ — Azure region. Immutable.
administrator_login / administrator_password string — null Admin (required for Default create).
version_number string — "8.0.21" MySQL version.
sku_name string — "GP_Standard_D2ds_v4" Tier + size.
public_network_access string — "Disabled" Enabled/Disabled.
storage object — auto-grow on Storage config.
delegated_subnet_id / private_dns_zone_id string — null VNet integration.
high_availability / maintenance_window / identity / customer_managed_key / entra_administrator object — null Optional blocks.
databases / configurations / firewall_rules map(object) — {} Children.
tags map(string) — {} Tags.
timeouts object — null Optional timeouts.
Full variable schemas (selected)
storage        = object({ size_gb = optional(number), auto_grow_enabled = optional(true), io_scaling_enabled = optional(bool), iops = optional(number), log_on_disk_enabled = optional(bool) })
entra_administrator = object({ login = string, object_id = string, tenant_id = string, identity_id = string })
databases      = map(object({ name = optional(string), charset = optional("utf8mb4"), collation = optional("utf8mb4_0900_ai_ci") }))
configurations = map(object({ name = optional(string), value = string }))
firewall_rules = map(object({ name = optional(string), start_ip_address = string, end_ip_address = string }))

🧾 Outputs

Output Description Notes
id Server Resource ID Emitted first.
name Server name —
location Server region Normalized by the provider. Pass this to a module that needs the server's region rather than typing one.
resource_group_name Server resource group —
fqdn Connection FQDN —
public_network_access Public endpoint state, read from the server Azure forces Disabled for a VNet-integrated server
replica_capacity Maximum read replicas this server supports Varies by SKU; Burstable supports far fewer
database_ids / database_names Child database maps Name defaults to the key but can be overridden
firewall_rule_ids / configuration_ids Child rule and parameter maps —
entra_administrator_id Entra administrator record, or null At most one per server
allows_all_azure_services A 0.0.0.0–0.0.0.0 rule exists 🔴 Not limited to your subscription or tenant
opens_server_to_entire_internet A 0.0.0.0–255.255.255.255 rule exists Only the admin password stands in the way
server_is_reachable_from_the_internet Public access and at least one rule Either alone is harmless — this is the combination
uses_vnet_integration Delegated subnet + private DNS zone Fixed at creation; both are force-new
uses_customer_managed_key CMK rather than a Microsoft-managed key false is still encrypted at rest
geo_backup_key_pairing_is_incomplete Only one half of the geo-backup CMK pair is set Silently unusable; nothing else reports it
high_availability_mode ZoneRedundant, SameZone or null The standby doubles the compute bill
is_burstable_tier Running on a B_ SKU Throttled sustained CPU; no HA; fewer replicas
is_replica_or_restore Created from another server Inherits its administrator from the source
shrinking_storage_replaces_this_server Always true Growing is online; shrinking destroys
changing_admin_login_replaces_this_server Always true The password is not force-new
password_is_stored_in_state Always true sensitive redacts plan output, encrypts nothing
entra_admin_does_not_disable_native_auth Always true Additive, not a replacement
geo_restore_requires_a_decision_made_on_the_source Always true Force-new on the source; cannot be revisited
children_are_deleted_with_the_server Always true A replacement plan is total data loss
engine_upgrades_are_one_way Always true No downgrade path exists
lifecycle_ignore_changes_is_unavailable_to_callers Always true Matters for zone and create_mode drift

No password is emitted — but administrator_password is written to state in plain text. Marking it sensitive redacts plan output and encrypts nothing; an encrypted, access-controlled remote backend is the control that actually protects it. The provider offers a write-only administrator_password_wo whose value never enters state at all; this module does not expose it, and password_is_stored_in_state says so.

🧠 Architecture Notes

  • Private by default. public_network_access = "Disabled"; use a delegated subnet + private DNS zone, or add firewall rules for public topologies.
  • Resilient storage. Auto-grow is on by default; size_gb grows only.
  • Children keyed, never count. Databases, configurations, and firewall rules are for_each maps referencing the server by server_name; the single Entra admin references server_id.
  • Password out of band. administrator_password is provider-sensitive and provisioned externally.
  • features {} dependence. No provider {} block here; the caller configures provider "azurerm" { features {} }.

🧱 Design Principles

Concern Secure default (empty call) Opt-out
Public network access Disabled set Enabled + firewall rules
Storage auto-grow true set false
Backup retention 7 days raise to 35
Encryption Service-managed supply a CMK
Admin password Sensitive, out of band —

🚀 Runbook

cd terraform-azurerm-mysql-flexible-server
terraform init -backend=false
terraform validate
terraform fmt -check
Remove-Item -Recurse -Force .terraform -ErrorAction SilentlyContinue

Pin the module by tag (?ref=v1.0.0), never a branch. Plan-only during authoring; a human runs plan/apply from CI.

🧪 Testing

The offline proof gate — terraform init -backend=false, terraform validate, terraform fmt -check — proves the configuration is type-correct against the pinned azurerm ~> 4.0 schema (including the version, public-access, and backup-retention validations) and canonically formatted, with no cloud calls. What it does not exercise: subnet delegation validity, SKU/region availability, and Entra admin resolution — those surface only under terraform plan/apply against real credentials from CI.

💬 Example Output

$ terraform output
fqdn = "mysql-app-prod-eastus2.mysql.database.azure.com"
id   = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data-prod-eastus2/providers/Microsoft.DBforMySQL/flexibleServers/mysql-app-prod-eastus2"
name = "mysql-app-prod-eastus2"
database_ids = { "app" = ".../flexibleServers/mysql-app-prod-eastus2/databases/app" }

🔍 Troubleshooting

Symptom Cause Fix
Create fails: admin required Default create without login/password Provide administrator_login + administrator_password.
Cannot connect from the internet public_network_access = "Disabled" Use the delegated subnet/private DNS, or set Enabled + firewall.
Storage shrink rejected size_gb only grows Provision larger; shrinking is not supported.
Geo-backup change forces replacement geo_redundant_backup_enabled is force-new Set it at creation.
Delegated subnet error Subnet lacks the MySQL delegation Delegate to Microsoft.DBforMySQL/flexibleServers.
Entra admin apply fails Missing identity_id / Directory Reader Provide a user-assigned identity and grant Directory Reader.

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."