Skip to content

microsoftexpert/terraform-google-cloud-sql-instance

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Google Cloud Cloud SQL Instance Terraform Module

Provisions a google_sql_database_instance (MySQL, PostgreSQL, or SQL Server) together with its google_sql_database and google_sql_user child records, targeting hashicorp/google ~> 7.0.

Terraform Provider Module Version Module Type Resources Posture


🧩 Overview

  • 🗄️ Provisions one google_sql_database_instance.this — MySQL, PostgreSQL, or SQL Server — as the keystone resource.
  • 📚 Manages any number of google_sql_database.databases via for_each over var.databases.
  • 👤 Manages any number of google_sql_user.users via for_each over var.users, with write-only, never-plaintext password handling.
  • 🔒 Public IP disabled, both deletion-protection guards enabled, and backups (with engine-appropriate point-in-time recovery) enabled — all by default.
  • 🔑 Never stores a user password in Terraform state — password_wo/password_wo_version only, no password argument exposed anywhere in this module.

💡 Why it matters: Cloud SQL instances are long-lived, stateful, and expensive to get wrong — a name-reuse cooldown of up to one week, two independent deletion guards, and an engine/edition interaction that fails only at apply time (not plan) are all real traps this module either closes at plan time or documents loudly rather than leaving the caller to discover them by reading an API error.


❤️ 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

This module has no confirmed downstream consumer module in the current catalog — it is consumed directly by application-layer compute (Cloud Run, GKE, Cloud SQL Proxy sidecars) that sit outside this Terraform library's scope. Two sibling modules feed into it optionally, and one real cross-module gap sits directly upstream of the private-IP path:

flowchart LR
 VPC["terraform-google-vpc-network\n(VPC network)"]:::sibling
 KMS["terraform-google-kms-keyring\n(CMEK crypto key)"]:::sibling
 SNC(("google_service_networking_connection\nNOT in this catalog — caller-owned")):::gap
 SQL["terraform-google-cloud-sql-instance"]:::this
 NONE["No confirmed downstream\nconsumer module yet"]:::sibling

 VPC -- "self_link (optional, private IP)" --> SQL
 KMS -- "crypto key id (optional CMEK)" --> SQL
 SNC -. "required prerequisite if private IP is used — out of scope, caller must create".-> SQL
 SQL -- "connection_name / self_link / id" --> NONE

 classDef this fill:#4285F4,color:#ffffff,stroke:#174EA6,stroke-width:2px;
 classDef sibling fill:#e8eaed,color:#202124,stroke:#9aa0a6,stroke-width:1px;
 classDef gap fill:#ffffff,color:#5f6368,stroke:#d93025,stroke-width:2px,stroke-dasharray: 5 5;
Loading

terraform-google-vpc-network's self_link output feeds settings.ip_configuration.private_network only if the caller opts out of the public-IP default. terraform-google-kms-keyring's crypto_key_ids output feeds encryption_key_name for CMEK. The google_service_networking_connection node is dashed because it is a real, required prerequisite for private IP that this library does not own or create — see SCOPE.md and the ⚠️ callouts in the Example Library below.


🧬 What this builds

Three resource types: one keystone, two independent for_each child collections.

flowchart TB
 INSTANCE["google_sql_database_instance.this\n(keystone)"]:::keystone
 DB["google_sql_database.databases\n(for_each over var.databases)"]:::this
 USR["google_sql_user.users\n(for_each over var.users)"]:::this

 INSTANCE -- "instance = google_sql_database_instance.this.name" --> DB
 INSTANCE -- "instance = google_sql_database_instance.this.name" --> USR

 classDef keystone fill:#174EA6,color:#ffffff,stroke:#174EA6,stroke-width:2px;
 classDef this fill:#4285F4,color:#ffffff,stroke:#174EA6,stroke-width:2px;
Loading
Resource Cardinality Notes
google_sql_database_instance.this 1 Keystone; MySQL, PostgreSQL, or SQL Server
google_sql_database.databases 0..n (for_each over var.databases) Independent lifecycle, never inline
google_sql_user.users 0..n (for_each over var.users) Write-only password only, never plaintext

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/google ~> 7.0 (resolved 7.39.0 at authoring time)
Provider block None — the caller's root module configures google (ADC / WIF / service-account key)

Schema notes that bite (all confirmed against the live, resolved 7.39.0 schema):

  • Instance name-reuse cooldown is up to one week. Confirmed schema text: "after a name is used, it cannot be reused for up to one week." A destroy immediately followed by an apply reusing the same instance_name can fail at the API level for up to a week even though terraform plan shows no conflict.
  • Three deletion-related mechanisms, not two. deletion_protection (top-level, Terraform-only — blocks terraform destroy/apply inside Terraform only) and settings.deletion_protection_enabled (API-level — blocks deletion across gcloud, Console, and the API too) are the two guards mandated by this module suite's house rule, both true by default here. A confirmed THIRD field, top-level deletion_policy (enum DELETE/ABANDON/PREVENT, default "DELETE", also present on google_sql_database and google_sql_user), governs a different axis — what terraform destroy actually does — and is left at its provider default rather than stacked as a third blocker. See Architecture Notes.
  • ssl_mode has superseded require_ssl. require_ssl does not exist anywhere in this provider version's schema. settings.ip_configuration.ssl_mode is a closed three-value enum: ALLOW_UNENCRYPTED_AND_ENCRYPTED, ENCRYPTED_ONLY (this module's default), and TRUSTED_CLIENT_CERTIFICATE_REQUIRED (not supported for SQL Server).
  • point_in_time_recovery_enabled is engine-gated and restarts the database. Confirmed schema text: "Will restart database if enabled after instance creation. Valid only for PostgreSQL and SQL Server instances." MySQL uses binary_log_enabled instead — this module gates which one it sends to the API off database_version's engine-family prefix, never both.
  • encryption_key_name (CMEK) requires manual, non-Terraform setup. Confirmed schema text: "a special kind of service account must be created and granted permission on this key" — this module accepts the variable but cannot automate that grant.
  • google_sql_user passwords are stored in Terraform state as plaintext — if you use password. This module never exposes a password argument at all; only the write-only password_wo/password_wo_version pair is modeled, so there is no plaintext-password-in-state path through this module for any user it manages.
  • google_sql_user.host/.name/.instance are force-new. host applies only to BUILT_IN users on MySQL instances — never set it for PostgreSQL or SQL Server.
  • Timeouts are asymmetric across all three resources. Instance: create/update/delete, 90 minutes default, no read timeout. Database: create/update/delete, 20 minutes default. User: create/update/delete, 10 minutes default. Each is its own timeouts/per-entry-timeouts surface — never one shared block.
  • PostgreSQL 16+ auto-defaults to ENTERPRISE_PLUS edition, which rejects shared-core/custom tiers. Confirmed schema text: "instances with database_version POSTGRES_16 or later default to ENTERPRISE_PLUS... shared-core and custom tiers such as db-g1-small, db-f1-micro, and db-custom-* require edition = "ENTERPRISE"." This module adds a plan-time validation on settings that catches this combination before apply — see Architecture Notes.
  • private_network is documented two ways; this module follows the executable example. The schema's prose example is relative-id-shaped, but the resource's own worked "Private IP Instance" example sets private_network = google_compute_network.private_network.self_link. This module's README and variable description point callers at .self_link (from terraform-google-vpc-network).

🔑 Required IAM Roles

  • roles/cloudsql.admin — full lifecycle management (create/update/delete) of Cloud SQL instances, databases, and users on the target project. This is the only role confirmed for this module (see SCOPE.md).

☁️ GCP Prerequisites

  • sqladmin.googleapis.com API enabled on the target project (via terraform-google-project-services).
  • If you opt into private IP (settings.ip_configuration.ipv4_enabled = false with private_network set): a google_service_networking_connection (private services access peering) must already exist in the target VPC. This module does not create or verify itterraform plan cannot catch a missing peering; only apply will fail with an API-level error. The resource's own docs additionally note that google_sql_database_instance does not auto-interpolate a dependency on this peering — your composition must add an explicit depends_on (see the ⚠️ callout in Example 8 below).
  • Quota: Cloud SQL instance count quota per project/region, and disk size/IOPS quota appropriate to settings.tier/settings.edition — enforced only at apply.
  • No specific org-policy constraint blocks this module's default configuration.

📁 Module Structure

terraform-google-cloud-sql-instance/
├── providers.tf # required_providers + required_version — no provider {} block
├── variables.tf # instance/settings/databases/users object schemas, secure defaults
├── main.tf # google_sql_database_instance.this + 2 for_each child resources
├── outputs.tf # id, self_link, connection_name, name, region, IPs, child name maps
├── README.md # this file
├── SCOPE.md # cross-module contract
└── examples/
 └── basic/ # smallest real call, matching Quick Start below

⚙️ Quick Start

module "cloud_sql" {
  source = "git::https://github.com/microsoftexpert/terraform-google-cloud-sql-instance.git?ref=v1.0.0"

  instance_name    = "billing-primary"
  database_version = "POSTGRES_15"

  settings = {
    tier = "db-custom-2-7680"
  }

  databases = {
    "billing" = {}
  }

  users = {
    "app_svc" = {
      password_wo         = var.app_svc_password
      password_wo_version = 1
    }
  }
}

The caller's root module configures the google provider (ADC, Workload Identity Federation, or a service-account key) — this module never declares project/region/zone/credentials.


🔌 Cross-Module Contract

Consumes

Input Type Source module
settings.ip_configuration.private_network (optional) string (.self_link) terraform-google-vpc-network
encryption_key_name (optional) string (full KMS crypto key resource path) terraform-google-kms-keyring
(none required) — runs standalone with public-IP-disabled, no-CMEK defaults

Emits

Output Description Consumed by
id Instance resource id None confirmed yet in the catalog
self_link Instance self_link (full API URL) None confirmed yet
connection_name {project}:{region}:{name}the form Cloud SQL Proxy/Cloud Run/GKE sidecars actually want App-layer compute outside this library's scope
name Instance name
region Effective region
public_ip_address / private_ip_address Convenience computed IPs (null if not applicable) Firewall/connection-string composition
database_names Map of database key → name
user_names Map of user key → name (never the password)

📚 Example Library

1 · Minimal MySQL instance
module "cloud_sql" {
  source = "git::https://github.com/microsoftexpert/terraform-google-cloud-sql-instance.git?ref=v1.0.0"

  instance_name    = "app-mysql"
  database_version = "MYSQL_8_0"

  settings = {
    tier = "db-f1-micro"
  }
}

ℹ️ No databases/users supplied — a valid, empty-child-collection instance. Public IP stays disabled (ipv4_enabled = false default) with no authorized_networks, so nothing can connect until you add a private network or opt into public IP explicitly (Example 3).

2 · PostgreSQL with databases and an IAM-authenticated user
module "cloud_sql" {
  source = "git::https://github.com/microsoftexpert/terraform-google-cloud-sql-instance.git?ref=v1.0.0"

  instance_name    = "reporting-postgres"
  database_version = "POSTGRES_15"

  settings = {
    tier = "db-custom-4-15360"
  }

  databases = {
    "reporting" = { charset = "UTF8", collation = "en_US.UTF8" }
  }

  users = {
    "svc-reporting@my-project.iam" = {
      type = "CLOUD_IAM_SERVICE_ACCOUNT"
    }
  }
}

💡 CLOUD_IAM_SERVICE_ACCOUNT/CLOUD_IAM_USER types need no password_wo — leave it null (this module's users validation rejects setting it for those two types).

3 · Explicit public IP with authorized networks
module "cloud_sql" {
  source = "git::https://github.com/microsoftexpert/terraform-google-cloud-sql-instance.git?ref=v1.0.0"

  instance_name    = "vendor-integration-mysql"
  database_version = "MYSQL_8_0"

  settings = {
    tier = "db-f1-micro"
    ip_configuration = {
      ipv4_enabled = true
      authorized_networks = {
        "vendor-office" = { cidr_range = "203.0.113.0/24" }
        "vendor-vpn"    = { cidr_range = "198.51.100.4", expiration_time = "2027-01-01T00:00:00Z" }
      }
    }
  }
}

⚠️ ipv4_enabled = true is an explicit opt-out of this module's secure default. Every authorized_networks entry is an open inbound path from that CIDR — keep the list as narrow as possible and set expiration_time for anything temporary.

4 · SQL Server with a root write-only password
module "cloud_sql" {
  source = "git::https://github.com/microsoftexpert/terraform-google-cloud-sql-instance.git?ref=v1.0.0"

  instance_name    = "legacy-app-sqlserver"
  database_version = "SQLSERVER_2022_STANDARD"

  root_password_wo         = var.sqlserver_root_password
  root_password_wo_version = 1

  settings = {
    tier    = "db-custom-4-15360"
    edition = "ENTERPRISE"
    ip_configuration = {
      ssl_mode = "ENCRYPTED_ONLY"
    }
  }
}

🔒 root_password_wo is required for SQL Server and, like every password in this module, is write-only — never persisted to Terraform state. Increment root_password_wo_version whenever the value changes.

5 · Custom database flags (Cloud SQL IAM database authentication)
module "cloud_sql" {
  source = "git::https://github.com/microsoftexpert/terraform-google-cloud-sql-instance.git?ref=v1.0.0"

  instance_name    = "iam-auth-postgres"
  database_version = "POSTGRES_15"

  settings = {
    tier = "db-custom-2-7680"
    database_flags = {
      "cloudsql.iam_authentication" = "on"
    }
  }

  users = {
    "engineer@example.com" = { type = "CLOUD_IAM_USER" }
  }
}

ℹ️ database_flags is a map(string) (flag name → value) rather than a list — flag names are already unique identities, so a map gives stable for_each-style behavior without a list-index diff when a flag is added or removed.

6 · High-availability PostgreSQL (REGIONAL) with maintenance window
module "cloud_sql" {
  source = "git::https://github.com/microsoftexpert/terraform-google-cloud-sql-instance.git?ref=v1.0.0"

  instance_name    = "core-ledger-postgres"
  database_version = "POSTGRES_15"

  settings = {
    tier              = "db-custom-8-30720"
    availability_type = "REGIONAL"
    maintenance_window = {
      day          = 7
      hour         = 3
      update_track = "stable"
    }
  }
}

💡 availability_type = "REGIONAL" requires settings.backup_configuration.enabled = true (this module's default) — for PostgreSQL, also ensure PITR stays enabled (also this module's default for non-MySQL engines).

7 · PostgreSQL 16+ with a shared-core tier (edition trap, avoided)
module "cloud_sql" {
  source = "git::https://github.com/microsoftexpert/terraform-google-cloud-sql-instance.git?ref=v1.0.0"

  instance_name    = "budget-postgres16"
  database_version = "POSTGRES_16"

  settings = {
    tier    = "db-f1-micro"
    edition = "ENTERPRISE" # required — see ⚠️ below
  }
}

⚠️ PostgreSQL 16+ auto-defaults to edition = "ENTERPRISE_PLUS" when edition is omitted, and ENTERPRISE_PLUS only accepts db-perf-optimized-N-*/db-perf-optimized-C4A-* tiers. Omitting edition here with tier = "db-f1-micro" fails this module's plan-time validation — set edition = "ENTERPRISE" explicitly whenever you want a shared-core or custom tier on PostgreSQL 16 or later.

8 · Private IP (with the required peering prerequisite)
# In the SAME composition — NOT owned by terraform-google-cloud-sql-instance:
resource "google_compute_global_address" "private_ip_range" {
  name          = "cloud-sql-psa-range"
  purpose       = "VPC_PEERING"
  address_type  = "INTERNAL"
  prefix_length = 16
  network       = module.vpc_network.id
}

resource "google_service_networking_connection" "private_vpc_connection" {
  network                 = module.vpc_network.id
  service                 = "servicenetworking.googleapis.com"
  reserved_peering_ranges = [google_compute_global_address.private_ip_range.name]
}

module "cloud_sql" {
  source = "git::https://github.com/microsoftexpert/terraform-google-cloud-sql-instance.git?ref=v1.0.0"

  instance_name    = "private-only-mysql"
  database_version = "MYSQL_8_0"

  settings = {
    tier = "db-custom-2-7680"
    ip_configuration = {
      ipv4_enabled                                  = false
      private_network                               = module.vpc_network.self_link
      enable_private_path_for_google_cloud_services = true
    }
  }

  depends_on = [google_service_networking_connection.private_vpc_connection]
}

⚠️ google_service_networking_connection is out of scope for this module and is not in the current catalog. You must create it yourself in the consuming composition, exactly as shown. The google_sql_database_instance resource does not auto-interpolate a dependency on this peering — the explicit depends_on shown above is required, per the resource's own documented caveat, or the instance creation can race the peering and fail.

9 · Multiple databases and users in one instance
module "cloud_sql" {
  source = "git::https://github.com/microsoftexpert/terraform-google-cloud-sql-instance.git?ref=v1.0.0"

  instance_name    = "shared-mysql"
  database_version = "MYSQL_8_0"

  settings = {
    tier = "db-custom-4-15360"
  }

  databases = {
    "orders"   = {}
    "invoices" = {}
    "audit"    = {}
  }

  users = {
    "orders_svc"   = { host = "10.0.%.%", password_wo = var.orders_pw, password_wo_version = 1 }
    "invoices_svc" = { host = "10.0.%.%", password_wo = var.invoices_pw, password_wo_version = 1 }
    "readonly_bi"  = { host = "%", password_wo = var.bi_pw, password_wo_version = 1 }
  }
}

ℹ️ host is only valid for BUILT_IN users on MySQL — each map key is independently force-new, so adding or removing one database/user entry never touches the others.

10 · Rotating a user's write-only password
module "cloud_sql" {
  source = "git::https://github.com/microsoftexpert/terraform-google-cloud-sql-instance.git?ref=v1.0.0"

  instance_name    = "app-mysql"
  database_version = "MYSQL_8_0"
  settings         = { tier = "db-f1-micro" }

  users = {
    "app_svc" = {
      password_wo         = var.app_svc_password_v2 # new secret value
      password_wo_version = 2                       # incremented from 1 — this triggers the update
    }
  }
}

🔒 Write-only arguments carry no value in state, so Terraform only detects a password rotation via the incremented password_wo_version counter — changing password_wo's value alone, without bumping the version, produces no diff.

11 · Password validation policy for BUILT_IN users
module "cloud_sql" {
  source = "git::https://github.com/microsoftexpert/terraform-google-cloud-sql-instance.git?ref=v1.0.0"

  instance_name    = "hardened-postgres"
  database_version = "POSTGRES_15"

  settings = {
    tier = "db-custom-2-7680"
    password_validation_policy = {
      enable_password_policy      = true
      min_length                  = 14
      disallow_username_substring = true
      reuse_interval              = 5
      complexity                  = "COMPLEXITY_DEFAULT"
    }
  }

  users = {
    "dba_admin" = {
      password_wo         = var.dba_admin_password
      password_wo_version = 1
      password_policy = {
        enable_failed_attempts_check = true
        allowed_failed_attempts      = 5
      }
    }
  }
}

ℹ️ password_validation_policy (instance-level) and password_policy (per-user) are both only supported for MySQL and PostgreSQL — not SQL Server.

12 · Decommissioning: the two-step deletion-protection apply
# Step 1 — flip both guards to false and apply (writes the change to state,
# does NOT delete anything yet):
module "cloud_sql" {
  source = "git::https://github.com/microsoftexpert/terraform-google-cloud-sql-instance.git?ref=v1.0.0"

  instance_name    = "retiring-instance"
  database_version = "MYSQL_8_0"

  deletion_protection = false # was true (default)
  settings = {
    tier                        = "db-f1-micro"
    deletion_protection_enabled = false # was true (default)
  }
}

# Step 2 — after that apply succeeds, remove the module block (or run
# terraform destroy) to actually delete the instance.

⚠️ Skipping step 1 and going straight to terraform destroy fails: deletion_protection blocks the destroy inside Terraform, and even if that were bypassed, settings.deletion_protection_enabled blocks it at the API level too. See Troubleshooting.

13 · Abandoning a user Terraform cannot delete (Postgres SQL roles)
module "cloud_sql" {
  source = "git::https://github.com/microsoftexpert/terraform-google-cloud-sql-instance.git?ref=v1.0.0"

  instance_name    = "roles-postgres"
  database_version = "POSTGRES_15"
  settings         = { tier = "db-custom-2-7680" }

  users = {
    "role_holder" = {
      password_wo         = var.role_holder_password
      password_wo_version = 1
      deletion_policy     = "ABANDON" # this user has been granted SQL roles; the API refuses DELETE
    }
  }
}

ℹ️ Confirmed schema note on google_sql_user.deletion_policy: "ABANDON" is "useful for Postgres, where users cannot be deleted from the API if they have been granted SQL roles." This removes the record from Terraform state without calling the delete API — the database role itself must still be revoked out-of-band if it needs to go away.

14 · CMEK-encrypted instance
module "kms" {
  source = "git::https://github.com/microsoftexpert/terraform-google-kms-keyring.git?ref=v1.0.0"

  key_ring_name = "cloud-sql-cmek"
  location      = "us-east1"
  crypto_keys = {
    "cloud-sql-key" = { purpose = "ENCRYPT_DECRYPT" }
  }
}

module "cloud_sql" {
  source = "git::https://github.com/microsoftexpert/terraform-google-cloud-sql-instance.git?ref=v1.0.0"

  instance_name    = "cmek-encrypted-mysql"
  database_version = "MYSQL_8_0"
  region           = "us-east1" # must match the KMS key's location

  encryption_key_name = module.kms.crypto_key_ids["cloud-sql-key"]

  settings = {
    tier = "db-custom-2-7680"
  }
}

🔒 The CMEK key must be in the same region as the instance. Manual, non-Terraform steps are still required (see ☁️ GCP Prerequisites) — this module accepts encryption_key_name but cannot grant the Cloud SQL service account access to the key on your behalf.

15 · 🏗️ End-to-end composition — VPC, CMEK, private IP, databases, and users
module "vpc_network" {
  source = "git::https://github.com/microsoftexpert/terraform-google-vpc-network.git?ref=v1.0.0"

  network_name = "data-tier-vpc"
  subnetworks = {
    "data-tier-use1" = { ip_cidr_range = "10.20.0.0/24", region = "us-east1" }
  }
}

module "kms" {
  source = "git::https://github.com/microsoftexpert/terraform-google-kms-keyring.git?ref=v1.0.0"

  key_ring_name = "data-tier-cmek"
  location      = "us-east1"
  crypto_keys = {
    "cloud-sql-key" = { purpose = "ENCRYPT_DECRYPT" }
  }
}

# google_service_networking_connection is a real, required prerequisite this
# library does not own — see ⚠️ Example 8 and SCOPE.md.
resource "google_compute_global_address" "private_ip_range" {
  name          = "data-tier-psa-range"
  purpose       = "VPC_PEERING"
  address_type  = "INTERNAL"
  prefix_length = 16
  network       = module.vpc_network.id
}

resource "google_service_networking_connection" "private_vpc_connection" {
  network                 = module.vpc_network.id
  service                 = "servicenetworking.googleapis.com"
  reserved_peering_ranges = [google_compute_global_address.private_ip_range.name]
}

module "cloud_sql" {
  source = "git::https://github.com/microsoftexpert/terraform-google-cloud-sql-instance.git?ref=v1.0.0"

  instance_name    = "data-tier-postgres"
  database_version = "POSTGRES_16"
  region           = "us-east1"

  encryption_key_name = module.kms.crypto_key_ids["cloud-sql-key"]

  settings = {
    tier              = "db-custom-4-15360"
    edition           = "ENTERPRISE" # required for a non-perf-optimized tier on PG16+, see Example 7
    availability_type = "REGIONAL"

    ip_configuration = {
      ipv4_enabled    = false
      private_network = module.vpc_network.self_link
    }

    backup_configuration = {
      enabled = true # point_in_time_recovery_enabled defaults true for PostgreSQL
    }
  }

  databases = {
    "app_primary" = { charset = "UTF8", collation = "en_US.UTF8" }
  }

  users = {
    "app_svc" = {
      password_wo         = var.app_svc_password
      password_wo_version = 1
    }
    "svc-account@my-project.iam" = {
      type = "CLOUD_IAM_SERVICE_ACCOUNT"
    }
  }

  labels = {
    environment = "prod"
    data_tier   = "postgres"
  }

  depends_on = [google_service_networking_connection.private_vpc_connection]
}

output "cloud_sql_connection_name" {
  value = module.cloud_sql.connection_name
}

💡 This wires terraform-google-vpc-network's self_link output into private_network, terraform-google-kms-keyring's crypto_key_ids output into encryption_key_name, satisfies the google_service_networking_connection prerequisite explicitly (with the required depends_on), and avoids the PostgreSQL 16+ edition trap from Example 7 — the full shape a real production composition needs, not just the module call in isolation.


📥 Inputs

Top-level: instance_name (required) · database_version (required, engine-family validated) · region (optional) · deletion_protection (default true) · deletion_policy (default "DELETE") · encryption_key_name (optional CMEK) · final_backup_description (optional) · root_password_wo/root_password_wo_version (SQL Server only) · labels (default {}) · timeouts (instance-scoped only)

settings (required — only tier is mandatory): engine/machine configuration, ip_configuration (secure default ipv4_enabled = false), backup_configuration (secure default enabled = true, engine-gated PITR/binary-log), final_backup_config, database_flags (map(string)), maintenance_window, insights_config (secure-ish default query_insights_enabled = true), password_validation_policy, advanced_machine_features, location_preference, data_cache_config.

databases (map(object(...)), default {}): charset, collation, deletion_policy, timeouts.

users (map(object(...)), default {}): type (default "BUILT_IN"), host (MySQL BUILT_IN only, force-new), password_wo/password_wo_version (write-only, never plaintext), database_roles, deletion_policy, password_policy, timeouts.

Full object schemas

See variables.tf in full — every field is documented inline with its default, validation, and (where relevant) force-new/engine-gating behavior. The file-level comment block at the top of variables.tf documents six cross-cutting design decisions (the labelssettings.user_labels mapping, the three deletion guards, the private_network self_link finding, the PostgreSQL 16+/edition validation, the full excluded-by-design block list, and the asymmetric timeouts) before any individual variable.


🧾 Outputs

Output Description
id Instance Terraform-internal resource id
self_link Instance self_link (full API URL)
connection_name The form most consumers actually want — Cloud SQL Proxy/Cloud Run/GKE sidecar connection string
name Instance name
region Effective region
public_ip_address First public IPv4, null if ipv4_enabled = false
private_ip_address First private IPv4, null unless private IP is configured and the peering prerequisite exists
maintenance_version Currently-applied maintenance version (computed)
database_names Map of database key → name
user_names Map of user key → name — never the password

No output ever surfaces a password, write-only or otherwise — there is nothing to surface, since this module never reads one back.


🧠 Architecture Notes

  • connection_name is elevated to a first-class identity output alongside id/self_link specifically for this module: Cloud SQL Proxy, Cloud Run, and GKE sidecar integrations consume {project}:{region}:{name} far more often than either the Terraform-internal id or the API self_link. Every other module in this library orders outputs id, self_link, then name/child-maps; this module inserts connection_name immediately after self_link as a documented, deliberate exception.
  • deletion_protection vs. settings.deletion_protection_enabled — different scopes, same default. deletion_protection only stops Terraform itself (a bypass is trivial: remove it from state or flip it in config). settings.deletion_protection_enabled is enforced by the Cloud SQL API and blocks deletion from gcloud, the Console, and direct API calls too. Both default to true here; destroying an instance requires flipping both to false and applying that change before the destroy — a single-step terraform destroy never works against this module's defaults. Neither guard is the same as lifecycle { prevent_destroy = true } (a Terraform-only meta-argument bypassed by removing the block) nor the third deletion_policy field (see below).
  • deletion_policy is a third, independent mechanism governing what terraform destroy does (DELETE calls the API normally, PREVENT fails the operation, ABANDON drops Terraform management without an API call) rather than whether deletion is blocked. Left at its provider default ("DELETE") on the instance and both child resources — see variables.tf's file header comment #2 for the full reasoning against defaulting it to "PREVENT" here.
  • Engine-family gating is real, not cosmetic. local.is_mysql in main.tf (derived from database_version's prefix) determines whether binary_log_enabled or point_in_time_recovery_enabled is sent to the API — sending the wrong one for the engine is rejected by Cloud SQL, and terraform validate cannot catch this (it depends on a runtime engine concept the type system doesn't model).
  • The PostgreSQL 16+/edition validation is a plan-time save, not a schema requirement. No provider-level validation enforces this — it is this module's own addition, added specifically because the interaction is confirmed in the live docs' prose but not enforced anywhere before apply.
  • for_each key stability: databases/users map keys become each resource's name argument — renaming a key destroys and recreates that specific entry (a force-new name), never the others. authorized_networks and database_flags are likewise keyed maps for the same reason.
  • IAM propagation lag applies if a composition grants roles/cloudsql.admin and immediately applies this module in the same run — GCP IAM changes can take up to ~60 seconds to propagate.

🧱 Design Principles

Concern Secure default Opt-out (explicit)
Public IP settings.ip_configuration.ipv4_enabled = false Caller sets true and supplies authorized_networks
Deletion (Terraform) deletion_protection = true Caller sets false
Deletion (API, all surfaces) settings.deletion_protection_enabled = true Caller sets false — both must flip for a destroy to succeed
Backups settings.backup_configuration.enabled = true Caller sets false
Point-in-time recovery (PostgreSQL/SQL Server) point_in_time_recovery_enabled = true (engine-gated) Caller sets false in backup_configuration
Binary logging (MySQL, PITR-equivalent) binary_log_enabled = true (engine-gated) Caller sets false in backup_configuration
Encryption at rest (CMEK) Accepted via encryption_key_name, never defaulted to a specific key Caller supplies a key from terraform-google-kms-keyring
TLS enforcement settings.ip_configuration.ssl_mode = "ENCRYPTED_ONLY" (module extension — not a house-table row verbatim) Caller sets "ALLOW_UNENCRYPTED_AND_ENCRYPTED" or "TRUSTED_CLIENT_CERTIFICATE_REQUIRED"
ExecuteSql (Data API) access settings.data_api_access = "DISALLOW_DATA_API" (module extension) Caller sets "ALLOW_DATA_API"
Query Insights (observability) settings.insights_config.query_insights_enabled = true (module extension of this module suite's audit-logging posture) Caller sets false
Query Insights client-address capture record_client_address = false (left off — data-handling caution) Caller sets true
User passwords Write-only password_wo/password_wo_version only — no plaintext password argument exists in this module N/A — by design

🚀 Runbook

cd terraform-google-cloud-sql-instance
terraform init -backend=false
terraform validate
terraform fmt -check

Pin ?ref=v1.0.0 in every source = line — never a branch. This library is plan-only; a human applies from CI with real Workload Identity Federation credentials against a specific project.


🧪 Testing

init -backend=false / validate / fmt -check confirm internal type/reference consistency and canonical formatting only. Given this resource's schema is enforced heavily server-side (quota, region/edition availability, the PostgreSQL 16+/edition interaction this module catches at plan time notwithstanding, org policy, and the private-IP peering prerequisite this module cannot see), a clean validate is not a guarantee that apply will succeed against a real project. The examples/basic/ directory exists so a consuming GitHub Actions workflow can run a real terraform plan with valid WIF credentials as part of its own review gate.


💬 Example Output

$ terraform output
connection_name = "my-project:us-east1:data-tier-postgres"
database_names = {
 "app_primary" = "app_primary"
}
id = "projects/my-project/instances/data-tier-postgres"
name = "data-tier-postgres"
private_ip_address = "10.20.0.3"
public_ip_address = null
region = "us-east1"
self_link = "https://sqladmin.googleapis.com/sql/v1beta4/projects/my-project/instances/data-tier-postgres"
user_names = {
 "app_svc" = "app_svc"
 "svc-account@my-project.iam" = "svc-account@my-project.iam"
}

🔍 Troubleshooting

Symptom Cause Fix
apply fails re-creating an instance with a name that was just destroyed Cloud SQL enforces a name-reuse cooldown of up to one week after deletion — confirmed schema text, not a vague "some delay" Wait out the cooldown, or choose a different instance_name
terraform destroy fails even after setting deletion_protection = false settings.deletion_protection_enabled (API-level) is still true — it is a separate guard from deletion_protection Set both deletion_protection = false AND settings.deletion_protection_enabled = false, apply that change first, then destroy
apply fails with a private-IP-related API error even though plan showed no problem settings.ip_configuration.private_network is set but the required google_service_networking_connection peering does not exist (or the instance was created before the peering finished, due to a missing depends_on) Create the peering in the consuming composition (Example 8) and add the explicit depends_onterraform plan cannot detect this gap
apply fails with an "Invalid Tier... for (ENTERPRISE_PLUS) Edition" error database_version is PostgreSQL 16+, settings.tier is a shared-core/custom tier, and settings.edition was left unset — PG16+ auto-defaults to ENTERPRISE_PLUS This module's own validation on settings should catch this at plan time (Example 7) — if you see it at apply regardless, confirm you are on a provider version where this auto-default behavior still applies
apply rejects binary_log_enabled/point_in_time_recovery_enabled Sent the wrong one for the engine family — binary_log_enabled is MySQL-only, point_in_time_recovery_enabled is PostgreSQL/SQL-Server-only Let this module's engine-gating handle it automatically; only override the field matching your actual database_version
A google_sql_user won't delete The user has been granted PostgreSQL SQL roles, which the API refuses to delete out from under Set that user's deletion_policy = "ABANDON" (Example 13) and revoke the role separately if needed

🔗 Related Docs

Releases

Packages

Contributors

Languages