IaC Azure

Terraform Module: Azure MySQL Flexible Server — production-ready managed MySQL with zone redundancy

Quick take — A reusable Terraform module for Azure MySQL Flexible Server with zone-redundant HA, automated backups, configurable server parameters, and private networking — built for hashicorp/azurerm ~> 4.0. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "azurerm" {
  features {}
}

module "mysql_flexible" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-mysql-flexible?ref=v1.0.0"

  name                   = "..."  # Server name; 3-63 chars, lowercase alphanumeric + hyphe…
  resource_group_name    = "..."  # Resource group for the server and databases.
  location               = "..."  # Azure region.
  administrator_login    = "..."  # Admin username; reserved names rejected.
  administrator_password = "..."  # Admin password (8-128 chars), sensitive.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure Database for MySQL — Flexible Server is the current-generation managed MySQL offering on Azure. Unlike the now-retired Single Server, Flexible Server gives you stop/start to pause compute billing, zone-resilient high availability, granular control over server parameters (innodb_buffer_pool_size, max_connections, slow_query_log), and the choice between public access with firewall rules or full VNet integration via delegated subnets and private DNS.

The catch is that a correct deployment touches a surprising number of moving parts: the server itself, its SKU and storage tier with auto-grow, the HA mode and standby zone, a maintenance window, backup retention with optional geo-redundancy, one or more databases with a sane charset/collation, server parameters, and either firewall rules or private DNS wiring. Hand-rolling all of that per project is where drift and copy-paste mistakes creep in — someone forgets to set require_secure_transport, or pins a database to utf8 instead of utf8mb4, or leaves HA off in production.

This module wraps azurerm_mysql_flexible_server and its satellite resources behind a small, validated variable surface. You pass a name, SKU, networking choice, and a map of databases; the module gives back a fully wired server with HA, backups, and parameters configured consistently across every environment.

When to use it

Reach for something else if you need MySQL-compatible serverless autoscaling or multi-region active-active writes — that is a different product (Azure Cosmos DB / a globally distributed store), not Flexible Server.

Module structure

terraform-module-azure-mysql-flexible/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # mysql_flexible_server + databases + parameters + firewall
├── variables.tf     # validated input surface
├── outputs.tf       # ids, FQDN, connection metadata
└── README.md
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}
# main.tf

locals {
  # Zone-redundant HA needs an explicit standby zone that differs from the primary.
  ha_enabled    = var.high_availability_mode != "Disabled"
  standby_zone  = var.standby_availability_zone != null ? var.standby_availability_zone : (var.zone == "1" ? "2" : "1")

  # VNet integration requires BOTH a delegated subnet and a private DNS zone.
  vnet_integrated = var.delegated_subnet_id != null && var.private_dns_zone_id != null
}

resource "azurerm_mysql_flexible_server" "this" {
  name                = var.name
  resource_group_name = var.resource_group_name
  location            = var.location

  administrator_login    = var.administrator_login
  administrator_password = var.administrator_password

  sku_name   = var.sku_name
  version    = var.mysql_version
  zone       = var.zone

  # Only set delegated networking when both inputs are present; otherwise the
  # server uses public access governed by firewall rules below.
  delegated_subnet_id = local.vnet_integrated ? var.delegated_subnet_id : null
  private_dns_zone_id = local.vnet_integrated ? var.private_dns_zone_id : null

  # When VNet-integrated, public access is implicitly disabled by Azure.
  public_network_access = local.vnet_integrated ? "Disabled" : var.public_network_access

  storage {
    size_gb           = var.storage_size_gb
    auto_grow_enabled = var.storage_auto_grow_enabled
    iops              = var.storage_iops
    io_scaling_enabled = var.storage_io_scaling_enabled
  }

  backup_retention_days        = var.backup_retention_days
  geo_redundant_backup_enabled = var.geo_redundant_backup_enabled

  dynamic "high_availability" {
    for_each = local.ha_enabled ? [1] : []
    content {
      mode                      = var.high_availability_mode
      standby_availability_zone = local.standby_zone
    }
  }

  dynamic "maintenance_window" {
    for_each = var.maintenance_window != null ? [var.maintenance_window] : []
    content {
      day_of_week  = maintenance_window.value.day_of_week
      start_hour   = maintenance_window.value.start_hour
      start_minute = maintenance_window.value.start_minute
    }
  }

  tags = var.tags

  lifecycle {
    # Storage can only grow; ignore Azure's reported value after auto-grow kicks in.
    ignore_changes = [zone, high_availability[0].standby_availability_zone]
  }
}

# Databases (utf8mb4 by default — the only sane choice for modern MySQL).
resource "azurerm_mysql_flexible_database" "this" {
  for_each = var.databases

  name                = each.key
  resource_group_name = var.resource_group_name
  server_name         = azurerm_mysql_flexible_server.this.name
  charset             = each.value.charset
  collation           = each.value.collation
}

# Server parameters (e.g. require_secure_transport, slow_query_log, max_connections).
resource "azurerm_mysql_flexible_server_configuration" "this" {
  for_each = var.server_parameters

  name                = each.key
  resource_group_name = var.resource_group_name
  server_name         = azurerm_mysql_flexible_server.this.name
  value               = each.value
}

# Firewall rules only apply to public-access servers.
resource "azurerm_mysql_flexible_server_firewall_rule" "this" {
  for_each = local.vnet_integrated ? {} : var.firewall_rules

  name                = each.key
  resource_group_name = var.resource_group_name
  server_name         = azurerm_mysql_flexible_server.this.name
  start_ip_address    = each.value.start_ip_address
  end_ip_address      = each.value.end_ip_address
}
# variables.tf

variable "name" {
  type        = string
  description = "Name of the MySQL Flexible Server. Must be globally unique, 3-63 chars, lowercase letters/numbers/hyphens."

  validation {
    condition     = can(regex("^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$", var.name))
    error_message = "name must be 3-63 chars, lowercase alphanumeric and hyphens, not starting/ending with a hyphen."
  }
}

variable "resource_group_name" {
  type        = string
  description = "Resource group in which to create the server and its databases."
}

variable "location" {
  type        = string
  description = "Azure region (e.g. centralindia, eastus2)."
}

variable "administrator_login" {
  type        = string
  description = "Administrator username. Cannot be 'azure_superuser', 'admin', 'root', 'guest', or 'public'."

  validation {
    condition     = !contains(["azure_superuser", "admin", "root", "guest", "public"], lower(var.administrator_login))
    error_message = "administrator_login uses a reserved name not permitted by Azure MySQL."
  }
}

variable "administrator_password" {
  type        = string
  sensitive   = true
  description = "Administrator password (8-128 chars). Source this from Key Vault, never hardcode."

  validation {
    condition     = length(var.administrator_password) >= 8 && length(var.administrator_password) <= 128
    error_message = "administrator_password must be between 8 and 128 characters."
  }
}

variable "sku_name" {
  type        = string
  default     = "GP_Standard_D2ds_v4"
  description = "Compute SKU. Format: <tier>_<series>, e.g. B_Standard_B1ms, GP_Standard_D2ds_v4, MO_Standard_E4ds_v4."

  validation {
    condition     = can(regex("^(B|GP|MO)_Standard_", var.sku_name))
    error_message = "sku_name must start with a valid tier prefix: B_ (Burstable), GP_ (General Purpose), or MO_ (Memory Optimized)."
  }
}

variable "mysql_version" {
  type        = string
  default     = "8.0.21"
  description = "MySQL engine version."

  validation {
    condition     = contains(["5.7", "8.0.21"], var.mysql_version)
    error_message = "mysql_version must be one of: 5.7, 8.0.21."
  }
}

variable "zone" {
  type        = string
  default     = "1"
  description = "Primary availability zone for the server (1, 2, or 3)."

  validation {
    condition     = contains(["1", "2", "3"], var.zone)
    error_message = "zone must be 1, 2, or 3."
  }
}

variable "storage_size_gb" {
  type        = number
  default     = 32
  description = "Provisioned storage in GB (20-16384). Storage can only grow, never shrink."

  validation {
    condition     = var.storage_size_gb >= 20 && var.storage_size_gb <= 16384
    error_message = "storage_size_gb must be between 20 and 16384."
  }
}

variable "storage_auto_grow_enabled" {
  type        = bool
  default     = true
  description = "Automatically grow storage as it fills to avoid out-of-space outages."
}

variable "storage_iops" {
  type        = number
  default     = null
  description = "Provisioned IOPS. Leave null to use the SKU/storage default; set explicitly to pre-provision."
}

variable "storage_io_scaling_enabled" {
  type        = bool
  default     = false
  description = "Enable automatic IO scaling. Mutually exclusive with explicitly setting storage_iops."
}

variable "backup_retention_days" {
  type        = number
  default     = 7
  description = "Backup retention window in days (1-35)."

  validation {
    condition     = var.backup_retention_days >= 1 && var.backup_retention_days <= 35
    error_message = "backup_retention_days must be between 1 and 35."
  }
}

variable "geo_redundant_backup_enabled" {
  type        = bool
  default     = false
  description = "Store backups in the paired region for geo-restore. Cannot be changed after creation."
}

variable "high_availability_mode" {
  type        = string
  default     = "Disabled"
  description = "HA mode: Disabled, ZoneRedundant (standby in another zone), or SameZone."

  validation {
    condition     = contains(["Disabled", "ZoneRedundant", "SameZone"], var.high_availability_mode)
    error_message = "high_availability_mode must be Disabled, ZoneRedundant, or SameZone."
  }
}

variable "standby_availability_zone" {
  type        = string
  default     = null
  description = "Standby zone for ZoneRedundant HA. If null, the module picks a zone different from var.zone."
}

variable "public_network_access" {
  type        = string
  default     = "Enabled"
  description = "Public access for non-VNet servers: Enabled or Disabled. Ignored when VNet-integrated."

  validation {
    condition     = contains(["Enabled", "Disabled"], var.public_network_access)
    error_message = "public_network_access must be Enabled or Disabled."
  }
}

variable "delegated_subnet_id" {
  type        = string
  default     = null
  description = "ID of a subnet delegated to Microsoft.DBforMySQL/flexibleServers. Set with private_dns_zone_id for VNet integration."
}

variable "private_dns_zone_id" {
  type        = string
  default     = null
  description = "ID of a private DNS zone ending in .mysql.database.azure.com. Required for VNet integration."
}

variable "maintenance_window" {
  type = object({
    day_of_week  = number
    start_hour   = number
    start_minute = number
  })
  default     = null
  description = "Custom maintenance window. day_of_week 0=Sunday..6=Saturday. Null uses the Azure-managed window."

  validation {
    condition = var.maintenance_window == null || (
      var.maintenance_window.day_of_week >= 0 && var.maintenance_window.day_of_week <= 6 &&
      var.maintenance_window.start_hour >= 0 && var.maintenance_window.start_hour <= 23 &&
      var.maintenance_window.start_minute >= 0 && var.maintenance_window.start_minute <= 59
    )
    error_message = "maintenance_window: day_of_week 0-6, start_hour 0-23, start_minute 0-59."
  }
}

variable "databases" {
  type = map(object({
    charset   = optional(string, "utf8mb4")
    collation = optional(string, "utf8mb4_0900_ai_ci")
  }))
  default     = {}
  description = "Map of database name => charset/collation. Defaults to utf8mb4 / utf8mb4_0900_ai_ci."
}

variable "server_parameters" {
  type        = map(string)
  default     = {}
  description = "Map of MySQL server parameter name => value, e.g. { require_secure_transport = \"ON\", slow_query_log = \"ON\" }."
}

variable "firewall_rules" {
  type = map(object({
    start_ip_address = string
    end_ip_address   = string
  }))
  default     = {}
  description = "Map of firewall rule name => IP range. Only applied to public-access (non-VNet) servers."
}

variable "tags" {
  type        = map(string)
  default     = {}
  description = "Tags applied to the server."
}
# outputs.tf

output "id" {
  description = "Resource ID of the MySQL Flexible Server."
  value       = azurerm_mysql_flexible_server.this.id
}

output "name" {
  description = "Name of the MySQL Flexible Server."
  value       = azurerm_mysql_flexible_server.this.name
}

output "fqdn" {
  description = "Fully qualified domain name used to connect to the server."
  value       = azurerm_mysql_flexible_server.this.fqdn
}

output "administrator_login" {
  description = "Administrator username for the server."
  value       = azurerm_mysql_flexible_server.this.administrator_login
}

output "database_names" {
  description = "List of database names created on the server."
  value       = [for db in azurerm_mysql_flexible_database.this : db.name]
}

output "high_availability_enabled" {
  description = "Whether zone-redundant or same-zone HA is active."
  value       = local.ha_enabled
}

output "public_network_access_enabled" {
  description = "True when the server is reachable over public network access."
  value       = azurerm_mysql_flexible_server.this.public_network_access == "Enabled"
}

How to use it

A production deployment with VNet integration, zone-redundant HA, and enforced TLS:

module "mysql_flexible_server" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-mysql-flexible?ref=v1.0.0"

  name                = "kv-app-mysql-prod"
  resource_group_name = azurerm_resource_group.data.name
  location            = "centralindia"

  administrator_login    = "kvdbadmin"
  administrator_password = data.azurerm_key_vault_secret.mysql_admin.value

  sku_name        = "GP_Standard_D2ds_v4"
  mysql_version   = "8.0.21"
  zone            = "1"
  storage_size_gb = 128

  high_availability_mode = "ZoneRedundant"

  backup_retention_days        = 14
  geo_redundant_backup_enabled = true

  # VNet integration — no public exposure.
  delegated_subnet_id = azurerm_subnet.mysql.id
  private_dns_zone_id  = azurerm_private_dns_zone.mysql.id

  databases = {
    appdb = {} # uses utf8mb4 / utf8mb4_0900_ai_ci defaults
  }

  server_parameters = {
    require_secure_transport = "ON"
    slow_query_log           = "ON"
    long_query_time          = "2"
    max_connections          = "512"
  }

  maintenance_window = {
    day_of_week  = 0 # Sunday
    start_hour   = 3
    start_minute = 0
  }

  tags = {
    environment = "prod"
    workload    = "kloudvin-app"
  }
}

A downstream consumer — wiring the server FQDN and database name into an App Service connection string:

resource "azurerm_linux_web_app" "app" {
  name                = "kv-app-prod"
  resource_group_name = azurerm_resource_group.app.name
  location            = "centralindia"
  service_plan_id     = azurerm_service_plan.app.id

  site_config {}

  app_settings = {
    "DB_HOST"     = module.mysql_flexible_server.fqdn
    "DB_NAME"     = element(module.mysql_flexible_server.database_names, 0)
    "DB_USER"     = module.mysql_flexible_server.administrator_login
    "DB_SSL_MODE" = "REQUIRED"
  }
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/mysql_flexible/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-mysql-flexible?ref=v1.0.0"
}

inputs = {
  name = "..."
  resource_group_name = "..."
  location = "..."
  administrator_login = "..."
  administrator_password = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/mysql_flexible && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string yes Server name; 3-63 chars, lowercase alphanumeric + hyphens, globally unique.
resource_group_name string yes Resource group for the server and databases.
location string yes Azure region.
administrator_login string yes Admin username; reserved names rejected.
administrator_password string yes Admin password (8-128 chars), sensitive.
sku_name string GP_Standard_D2ds_v4 no Compute SKU; must use B_/GP_/MO_ tier prefix.
mysql_version string 8.0.21 no Engine version: 5.7 or 8.0.21.
zone string 1 no Primary availability zone (1/2/3).
storage_size_gb number 32 no Provisioned storage (20-16384); grow-only.
storage_auto_grow_enabled bool true no Auto-grow storage to avoid out-of-space outages.
storage_iops number null no Explicit provisioned IOPS; null uses default.
storage_io_scaling_enabled bool false no Auto IO scaling; exclusive with storage_iops.
backup_retention_days number 7 no Backup retention window (1-35 days).
geo_redundant_backup_enabled bool false no Geo-redundant backups; immutable after create.
high_availability_mode string Disabled no Disabled, ZoneRedundant, or SameZone.
standby_availability_zone string null no Standby zone for ZoneRedundant HA; auto-picked if null.
public_network_access string Enabled no Enabled/Disabled for non-VNet servers.
delegated_subnet_id string null no Delegated subnet ID for VNet integration.
private_dns_zone_id string null no Private DNS zone ID for VNet integration.
maintenance_window object null no Custom maintenance window (day/hour/minute).
databases map(object) {} no Map of db name => charset/collation.
server_parameters map(string) {} no Map of MySQL server parameter => value.
firewall_rules map(object) {} no Map of firewall rule => IP range (public servers only).
tags map(string) {} no Tags applied to the server.

Outputs

Name Description
id Resource ID of the MySQL Flexible Server.
name Name of the server.
fqdn FQDN used to connect to the server.
administrator_login Administrator username.
database_names List of database names created on the server.
high_availability_enabled Whether HA (zone-redundant or same-zone) is active.
public_network_access_enabled True when the server is reachable over public access.

Enterprise scenario

A retail company runs its order-management platform as a containerized PHP app on Azure App Service across two regions. The data team consumes this module once per environment from a shared terraform-modules repo: dev gets a B_Standard_B1ms burstable server with HA disabled and public access locked to the office IP range, while prod gets GP_Standard_D4ds_v4 with ZoneRedundant HA, geo-redundant backups, VNet integration through a delegated subnet, and require_secure_transport = "ON". Because the topology and server parameters are identical across environments and only the SKU and HA toggle differ in tfvars, an engineer can promote a schema change through dev and prod with confidence that the engine version, charset, and TLS posture match exactly.

Best practices

TerraformAzureMySQL Flexible ServerModuleIaC
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

Keep Reading