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.
- 🐬 Creates one
azurerm_mysql_flexible_serverwith 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.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
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!
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;
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;
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. |
| 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_idandpoint_in_time_restore_time_in_utcare all force-new.locationandresource_group_nametake theirs from the provider's shared schema helpers, so they do not appear in a plain search of the resource source.- 🔴
administrator_loginis 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_gbDESTROYS THE SERVER. The field is force-new in one direction only, via the provider'sCustomizeDiff: growing the volume is an online update, any decrease replaces the server. NoForceNewmarker records it. Note the interaction withauto_grow_enabled(default true): the volume can grow on its own, after which a configuration still naming the originalsize_gbis asking for a shrink. - For the Default create mode,
administrator_login+administrator_passwordare required; for replica/restore modes they come from the source and must be null. Both directions are enforced by this module. private_dns_zone_idis required wheneverdelegated_subnet_idis set, and the zone name must end.mysql.database.azure.com. Microsoft documents both; the provider enforces neither, so this module does.storage.iopscannot be set whenstorage.io_scaling_enabledis 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_accessis a string (Enabled/Disabled), not a boolean; the 4.x-onlypublic_network_access_enabledis read-only. A VNet-integrated server has access set toDisabledby Azure regardless of what is requested.sku_nameis validated against a strict provider regex, mirrored here — a near miss such asGP_Standard_D2s_v4(missing thed) is refused. Burstable (B_) SKUs support no high availability, which this module also checks.administrator_loginis 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.
identityis user-assigned only; this resource has no system-assigned form.⚠️ Two fields drift by design andignore_changesis unavailable to callers:zoneis rewritten by Azure after a failover, andcreate_modeis never returned by the API so it always differs after an import.lifecycleis not valid inside amoduleblock, 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.
- 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.
- The
Microsoft.DBforMySQLresource provider registered on the subscription. - An existing resource group; for VNet integration, a delegated subnet and a
privatelink.mysql.database.azure.comprivate 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.
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
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.
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 |
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.
| 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 }))| 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_passwordis written to state in plain text. Marking itsensitiveredacts plan output and encrypts nothing; an encrypted, access-controlled remote backend is the control that actually protects it. The provider offers a write-onlyadministrator_password_wowhose value never enters state at all; this module does not expose it, andpassword_is_stored_in_statesays so.
- 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_gbgrows only. - Children keyed, never
count. Databases, configurations, and firewall rules arefor_eachmaps referencing the server byserver_name; the single Entra admin referencesserver_id. - Password out of band.
administrator_passwordis provider-sensitive and provisioned externally. features {}dependence. Noprovider {}block here; the caller configuresprovider "azurerm" { features {} }.
| 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 | — |
cd terraform-azurerm-mysql-flexible-server
terraform init -backend=false
terraform validate
terraform fmt -check
Remove-Item -Recurse -Force .terraform -ErrorAction SilentlyContinuePin the module by tag (
?ref=v1.0.0), never a branch. Plan-only during authoring; a human runsplan/applyfrom CI.
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.
$ 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" }| 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. |
- Provider resources:
azurerm_mysql_flexible_server,azurerm_mysql_flexible_database,azurerm_mysql_flexible_server_configuration,azurerm_mysql_flexible_server_firewall_rule,azurerm_mysql_flexible_server_active_directory_administrator - Sibling modules:
terraform-azurerm-virtual-network,terraform-azurerm-private-dns-zone,terraform-azurerm-key-vault,terraform-azurerm-resource-group - This module's cross-module contract:
SCOPE.md
💙 "Infrastructure as Code should be standardized, consistent, and secure."