Azure SQL Database (Logical Server)
Azure SQL Database is the fully-managed PaaS database engine. It is deployed as two resources: a logical server (azurerm_mssql_server) that acts as the administrative and connection endpoint (<name>.database.windows.net), and one or more databases (azurerm_mssql_database) hosted on it. This repo splits them into two modules — mssql_server and mssql_database — that mirror that relationship: databases are nested under each server in the mssql_server variable (the same pattern as subnets nested in networks), and the database module flattens them into composite <server>.<database> keys.
Note: The logical server has no compute cost — you are billed per database (or per elastic pool).
Logical server vs. Managed Instance: A logical server hosts isolated, database-scoped PaaS databases (no SQL Agent, cross-database queries, or instance-level features) and is not injected into a VNet. If you need near-100% SQL Server instance compatibility inside your VNet, use the mssql_managed_instance module instead.
Modules
| Module | Resource | Purpose |
|---|---|---|
mssql_server |
azurerm_mssql_server |
The logical server — endpoint, authentication, identity |
mssql_database |
azurerm_mssql_database |
Databases nested under each server (the billable unit); fed the mssql_server variable and flattened to <server>.<database> keys |
Basic Configuration
A server with its databases nested underneath:
resource_group is a key from the resource-group variable (not an ID/name); the module resolves the actual ID internally. Each database is keyed within its server's databases map and flattened to a composite <server>.<database> key (e.g. app.appdb).
Authentication
The SQL administrator password is never stored in tfvars or in Terraform state. administrator_login_password_secret names a key in the repo's local.secrets map (aggregated in src/locals.tf from TF_VAR_* environment variables); the resolved value feeds the provider's write-only administrator_login_password_wo argument, which Terraform does not persist to state:
Because a write-only value isn't tracked in state, rotate the password by changing the secret and bumping administrator_login_password_wo_version (default 1) — Terraform uses that version number to detect when to push an update. This path requires Terraform ≥ 1.11 (enforced by required_version in providers.tf).
Plan-time guard: if SQL auth is in use but the password resolves to null — the administrator_login_password_secret key is unset, or its TF_VAR_* env var isn't exported — the module fails during plan with a clear message rather than waiting for an apply-time API error. Set the env var, or enable Entra-only auth.
For Entra-only (Azure AD-only) authentication, omit the SQL administrator fields and set azuread_administrator with azuread_authentication_only = true:
azuread_administrator = {
login_username = "SQL Admins"
object_id = "00000000-0000-0000-0000-000000000000"
azuread_authentication_only = true
}
The azuread_administrator can be a user, group, or service principal: set object_id to that principal's object ID and login_username to a display label (for a group, its name). object_id is the immutable directory binding (ARM sid); login_username is the admin's display/login name (ARM login). A group is the recommended choice so admin membership is managed in Entra rather than in tfvars.
Directory Readers (Entra-side, outside this repo). Assigning the Entra admin on a logical server does not itself require Directory Readers. However, the server's identity needs the Directory Readers role — or the Microsoft Graph permissions User.Read.All, GroupMember.Read.All, Application.Read.All — to expand group membership (so members of a group admin actually get permissions) and to create Entra users/logins in the database. Without it, those operations fail. This is an Entra RBAC grant managed outside Terraform (per issue #111). See Directory Readers role in Microsoft Entra ID for Azure SQL.
Networking
A logical server is not injected into a subnet. This module defaults public_network_access_enabled to false (private), so reach it via a private endpoint — the private_endpoint module supports mssql_server as a private_connection_resource_type with the sqlServer sub-resource:
private_endpoints = {
epic_sql = {
resource_group = "hsw"
subnet = "hsw.hsw"
private_service_connection = {
private_connection_resource_type = "mssql_server"
private_connection_resource = "epic_sql" # mssql_server key
subresource_names = ["sqlServer"]
}
private_dns_zone_group = {
private_dns_zones = ["privatelink.database.windows.net"]
}
}
}
Set public_network_access_enabled = true to expose the public endpoint instead.
mssql_server Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
resource_group |
string | Yes | - | Key of the resource group |
name |
string | No | Auto-generated | Server name (globally unique, lowercase). If omitted, uses the naming convention |
location |
string | No | Global location | Azure region |
existing |
bool | No | false |
Reference an existing server via a data source instead of creating one |
version |
string | No | 12.0 |
12.0 (v12) or 2.0 (legacy v11) |
administrator_login |
string | No* | - | SQL administrator login. *Required unless Entra-only auth is enabled |
administrator_login_password_secret |
string | No* | - | Key into the secrets map for the admin password. *Required unless Entra-only auth is enabled |
connection_policy |
string | No | Default |
Default, Proxy, or Redirect |
minimum_tls_version |
string | No | 1.2 |
1.0, 1.1, 1.2, or Disabled |
public_network_access_enabled |
bool | No | false |
Whether the public endpoint is enabled. Defaults to private — opt in to public explicitly |
outbound_network_restriction_enabled |
bool | No | false |
Restrict outbound traffic from the server |
express_vulnerability_assessment_enabled |
bool | No | false |
Enable Express Vulnerability Assessment |
primary_user_assigned_identity |
string | No | first user_assigned_identities entry |
user_assigned_identity key for the server's primary identity. When identity.type includes UserAssigned, this is auto-derived from the first user_assigned_identities entry if omitted (the provider requires a primary then); set it explicitly to choose a different one |
transparent_data_encryption_key_vault_key_id |
string | No | - | Versioned Key Vault key URL for CMK TDE |
identity |
object | No | omitted | Managed identity (see Identity) |
azuread_administrator |
object | No | omitted | Entra administrator (see Authentication) |
tags |
map(string) | No | {} |
Tags merged with default tags |
timeouts |
object | No | omitted | Custom operation timeouts |
Identity
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
type |
list(string) | Yes | - | Any combination of SystemAssigned and UserAssigned |
user_assigned_identities |
list(string) | No | [] |
Keys from the user_assigned_identity variable |
Note: When type includes UserAssigned, the server's primary user-assigned identity auto-derives from the first user_assigned_identities entry (the provider requires a primary then). Set primary_user_assigned_identity to choose a different one.
Database Parameters
Each entry in a server's databases map supports the following. The database key is combined with its server key into a composite <server>.<database> key internally (so a database is identified by its server, not a server field).
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string | No | Auto-generated (key) | Database name. If omitted, uses the naming convention |
existing |
bool | No | false |
Reference an existing database via a data source |
sku_name |
string | No | Provider default | e.g. Basic, S0, GP_S_Gen5_1, BC_Gen5_2 |
collation |
string | No | SQL_Latin1_General_CP1_CI_AS |
Database collation. Forces replacement if changed |
license_type |
string | No | - | LicenseIncluded or BasePrice. Not supported on serverless (GP_S_*) SKUs — omit it there |
max_size_gb |
number | No | - | Max size in GB (0.1, 0.5, or integers ≥ 1) |
min_capacity |
number | No | - | Minimum vCores (serverless only) |
auto_pause_delay_in_minutes |
number | No | - | Auto-pause delay (serverless only); -1 disables |
read_scale |
bool | No | - | Route read-intent connections to a replica (Premium/BC) |
read_replica_count |
number | No | - | Read replicas (Hyperscale only) |
zone_redundant |
bool | No | - | Spread replicas across zones (Premium/BC) |
storage_account_type |
string | No | Zone |
Backup redundancy: Geo, GeoZone, Local, Zone |
enclave_type |
string | No | unset | Default or VBS (Always Encrypted with secure enclaves) |
geo_backup_enabled |
bool | No | true |
Geo backup (DataWarehouse SKUs only) |
ledger_enabled |
bool | No | false |
Ledger database. Forces replacement if changed |
maintenance_configuration_name |
string | No | SQL_Default |
Public maintenance window |
create_mode |
string | No | Default |
Copy, PointInTimeRestore, Secondary, etc. |
creation_source_database_id |
string | No | - | Source database ID for copy/restore/secondary modes |
elastic_pool_id |
string | No | - | Elastic pool ID to place the database in |
sample_name |
string | No | - | AdventureWorksLT |
secondary_type |
string | No | Geo |
Geo, Named, or Standby |
transparent_data_encryption_enabled |
bool | No | true |
Enable TDE |
transparent_data_encryption_key_vault_key_id |
string | No | - | CMK key URL for TDE |
transparent_data_encryption_key_automatic_rotation_enabled |
bool | No | false |
Auto-rotate the TDE CMK |
short_term_retention_policy |
object | No | omitted | PITR retention (see below) |
long_term_retention_policy |
object | No | omitted | LTR retention (see below) |
threat_detection_policy |
object | No | omitted | Advanced Threat Protection settings |
identity |
object | No | omitted | User-assigned identity (see below) |
tags |
map(string) | No | {} |
Tags merged with default tags |
timeouts |
object | No | omitted | Custom operation timeouts |
short_term_retention_policy
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
retention_days |
number | Yes | - | PITR retention, 1–35 |
backup_interval_in_hours |
number | No | 12 |
12 or 24 |
long_term_retention_policy
long_term_retention_policy = {
weekly_retention = "P4W"
monthly_retention = "P12M"
yearly_retention = "P5Y"
week_of_year = 1
}
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
weekly_retention |
string | No | PT0S |
ISO 8601 duration, up to 520 weeks |
monthly_retention |
string | No | PT0S |
ISO 8601 duration, up to 120 months |
yearly_retention |
string | No | PT0S |
ISO 8601 duration, up to 10 years |
week_of_year |
number | No | - | Week (1–52) to take the yearly backup |
immutable_backups_enabled |
bool | No | false |
Make LTR backups immutable |
identity
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
type |
string | No | UserAssigned |
Only UserAssigned is supported on a database |
user_assigned_identities |
list(string) | Yes | - | Keys from the user_assigned_identity variable |
Data Loss Protection
The azurerm provider recommends a prevent_destroy lifecycle guard on databases. This module does not set it, because prevent_destroy cannot be driven from a variable and would conflict with this repo's nightly dev-destroy automation. For production databases, add the guard explicitly (or protect the resource through a state lock / policy) outside the module.
Referencing Existing Resources
Set existing = true on a server or database to reference it through a data source instead of creating it. For a server, only name and resource_group are needed; for a nested database, name plus existing = true (the server is implied by where the database is nested). The modules merge resource and data outputs, so downstream references work the same regardless of which path created the object.
Naming Convention
Names are generated as {name_prefix}<type>{key}{name_suffix}<type>:
name_prefixes = {
mssql_server = "prod-"
mssql_database = ""
}
name_suffixes = {
mssql_server = "-eastus2-sql"
mssql_database = ""
}
With a server keyed app and a database keyed appdb, the resulting names are prod-app-eastus2-sql and appdb.
Note: The server name must be globally unique, lowercase, 1–63 characters, alphanumerics and hyphens only, and cannot start or end with a hyphen. Database names are scoped to the server and are typically set explicitly via name.