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
- You are standing up MySQL for an application (WordPress, a Laravel/PHP API, a Java service on MySQL) and want managed patching, backups, and HA instead of running MySQL on a VM.
- You run multiple environments (dev/test/prod) and need the same topology with different SKUs and HA toggles, driven from
tfvars. - You need VNet-integrated MySQL with a private endpoint-style setup (delegated subnet + private DNS zone) so the database is never exposed to the public internet.
- You want server parameters and database charset/collation enforced as code so no one can drift the instance from the portal.
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 config — live/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 config — live/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
- Force TLS and disable public access in prod. Set
require_secure_transport = "ON"viaserver_parametersand prefer VNet integration (delegated_subnet_id+private_dns_zone_id) so the server never has a public endpoint. If you must use public access, scopefirewall_rulesto known CIDRs — never0.0.0.0. - Always pick
utf8mb4. MySQL’s legacyutf8is a 3-byte subset that silently breaks emoji and many Unicode characters. The module defaults databases toutf8mb4/utf8mb4_0900_ai_ci; keep it that way unless an application demands otherwise. - Match HA mode to your SLA and budget.
ZoneRedundantHA roughly doubles compute cost because it runs a hot standby in another zone — worth it for prod, wasteful for dev. UseDisabledplus stop/start in lower environments to pause compute billing. - Leave
storage_auto_grow_enabled = true. Flexible Server storage can grow but never shrink, and a full disk takes the database read-only. Auto-grow trades a small ongoing cost for protection against out-of-space outages. - Source the admin password from Key Vault. The
administrator_passwordis marked sensitive, but it still lands in state — keep state in an encrypted, access-controlled backend and feed the password fromazurerm_key_vault_secret, never a literal intfvars. - Name and tag for cost attribution. Use a consistent
<org>-<workload>-mysql-<env>convention and tagenvironment/workloadso the zone-redundant prod server is unmistakable on the bill and HA cost is easy to trace.