Quick take — A reusable hashicorp/google ~> 5.0 module for google_sql_database_instance: private IP only, automated backups with point-in-time recovery, optional read replicas, databases, and IAM/built-in users. 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 "google" {
project = "my-project"
region = "us-central1"
}
module "cloud_sql" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-sql?ref=v1.0.0"
project_id = "..." # GCP project ID hosting the instance.
name = "..." # Instance name; RFC1035, lowercase, <= 80 chars.
region = "..." # Region for the primary, e.g. `asia-south1`.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Cloud SQL is GCP’s managed relational database service for PostgreSQL, MySQL, and SQL Server. You declare an engine, a version, a machine tier, and storage, and Google runs the database for you: provisioning, patching, failover to a standby in another zone, automated backups, and binary-log/WAL archiving for point-in-time recovery. The control surface is google_sql_database_instance, with companion resources for databases (google_sql_database), users (google_sql_user), and replicas (another google_sql_database_instance with a master_instance_name).
The catch is that a safe Cloud SQL instance is never just an engine and a tier. The defaults will happily give you a database with a public IPv4 address, no SSL enforcement, no deletion protection, backups you forgot to enable, and a root password living in plan output. Production-correct means: a private IP allocated from a Service Networking peering range (no public surface at all), SSL/TLS required, automated backups with PITR and a sensible retention window, a high-availability regional instance for prod, deletion protection, and credentials that come from Secret Manager rather than literals in HCL. Every team re-deriving that by hand will get at least one of those wrong.
This module wraps google_sql_database_instance into a single opinionated, variable-driven block. It defaults to private-IP-only with SSL required and PITR on, optionally creates a cross-region read replica, provisions the databases and users you list (built-in or Cloud SQL IAM users), and emits the connection name, private IP, and instance self-link as outputs so a Cloud Run service, a GKE workload, or a Secret Manager secret can consume them.
When to use it
- You need a managed PostgreSQL, MySQL, or SQL Server database and want one reviewed, hardened shape instead of bespoke
google_sql_database_instanceblocks per team. - Security baselines require no public IP — the instance must be reachable only over a VPC via Private Service Access (Service Networking peering), with SSL enforced.
- You want automated backups with point-in-time recovery and regional high availability (synchronous standby) to be the default for production, not an afterthought toggled on later.
- You need to provision databases and users (including Cloud SQL IAM database authentication) declaratively alongside the instance, and surface the connection name for the Cloud SQL Auth Proxy or a private-IP connection string.
- You front the database with a cross-region read replica for read scaling or DR and want it created from the same module call.
Reach for AlloyDB or a self-managed VM instead when you need Postgres extensions Cloud SQL doesn’t support, Spanner when you need horizontal write scaling beyond a single primary, or BigQuery when the workload is analytical rather than transactional.
Module structure
terraform-module-gcp-cloud-sql/
├── versions.tf # provider + required_version pins
├── main.tf # instance, replica, databases, users
├── variables.tf # var-driven inputs with validation
└── outputs.tf # connection name, private IP, self-link, replica
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.5"
}
}
}
main.tf
locals {
# Engines that support a synchronous regional standby (HA).
ha_capable = !startswith(var.database_version, "SQLSERVER")
availability = var.high_availability && local.ha_capable ? "REGIONAL" : "ZONAL"
# Point-in-time recovery uses transaction logs; the flag differs per engine.
is_postgres = startswith(var.database_version, "POSTGRES")
# Built-in users get a generated password unless the caller supplies one.
builtin_users = { for u in var.users : u.name => u if u.type == "BUILT_IN" }
iam_users = { for u in var.users : u.name => u if u.type != "BUILT_IN" }
}
# Strong random passwords for built-in users that did not bring their own.
resource "random_password" "user" {
for_each = { for k, u in local.builtin_users : k => u if u.password == null }
length = 24
special = true
min_special = 2
min_upper = 2
min_lower = 2
min_numeric = 2
# Avoid characters that complicate URLs/shell quoting in connection strings.
override_special = "!#%*-_=+"
}
resource "google_sql_database_instance" "this" {
project = var.project_id
name = var.name
region = var.region
database_version = var.database_version
deletion_protection = var.deletion_protection
settings {
tier = var.tier
edition = var.edition
availability_type = local.availability
disk_type = var.disk_type
disk_size = var.disk_size_gb
disk_autoresize = var.disk_autoresize
# Cap autogrowth so a runaway workload cannot balloon the bill unbounded.
disk_autoresize_limit = var.disk_autoresize_limit_gb
# GCE zone the primary runs in; the standby lands in another zone when REGIONAL.
location_preference {
zone = var.zone
}
ip_configuration {
# Private IP only: no public IPv4 surface at all.
ipv4_enabled = var.public_ip_enabled
private_network = var.private_network
enable_private_path_for_google_cloud_services = var.private_path_for_google_services
ssl_mode = var.ssl_mode
dynamic "authorized_networks" {
for_each = var.public_ip_enabled ? var.authorized_networks : {}
content {
name = authorized_networks.key
value = authorized_networks.value
}
}
}
backup_configuration {
enabled = var.backup_enabled
start_time = var.backup_start_time
location = var.backup_location
point_in_time_recovery_enabled = var.backup_enabled && local.is_postgres ? true : var.backup_enabled
transaction_log_retention_days = var.transaction_log_retention_days
binary_log_enabled = var.backup_enabled && !local.is_postgres
backup_retention_settings {
retained_backups = var.retained_backups
retention_unit = "COUNT"
}
}
maintenance_window {
day = var.maintenance_day
hour = var.maintenance_hour
update_track = var.maintenance_update_track
}
# Engine-level flags, e.g. { "log_min_duration_statement" = "1000" }.
dynamic "database_flags" {
for_each = var.database_flags
content {
name = database_flags.key
value = database_flags.value
}
}
# Query Insights for application-level performance troubleshooting.
insights_config {
query_insights_enabled = var.query_insights_enabled
query_string_length = var.query_insights_string_length
record_application_tags = var.query_insights_record_application_tags
record_client_address = var.query_insights_record_client_address
}
user_labels = var.labels
}
lifecycle {
# Generated/managed-elsewhere root password should not force diffs.
ignore_changes = [settings[0].disk_size]
}
}
# Optional cross-region read replica for read scaling or DR.
resource "google_sql_database_instance" "replica" {
for_each = var.read_replicas
project = var.project_id
name = each.key
region = each.value.region
database_version = var.database_version
master_instance_name = google_sql_database_instance.this.name
deletion_protection = var.deletion_protection
settings {
tier = coalesce(each.value.tier, var.tier)
edition = var.edition
availability_type = "ZONAL" # replicas are single-zone by design
disk_type = var.disk_type
disk_autoresize = var.disk_autoresize
ip_configuration {
ipv4_enabled = false
private_network = var.private_network
ssl_mode = var.ssl_mode
}
insights_config {
query_insights_enabled = var.query_insights_enabled
}
user_labels = var.labels
}
# A replica cannot exist without its primary.
depends_on = [google_sql_database_instance.this]
}
# Application databases on the primary.
resource "google_sql_database" "this" {
for_each = toset(var.databases)
project = var.project_id
name = each.value
instance = google_sql_database_instance.this.name
charset = var.database_charset
collation = var.database_collation
# Dropping a DB is destructive; require an explicit intent to remove it.
deletion_policy = var.database_deletion_policy
}
# Built-in (password) users. Passwords come from the caller or are generated.
resource "google_sql_user" "builtin" {
for_each = local.builtin_users
project = var.project_id
name = each.value.name
instance = google_sql_database_instance.this.name
password = coalesce(each.value.password, try(random_password.user[each.key].result, null))
type = "BUILT_IN"
deletion_policy = var.user_deletion_policy
}
# Cloud SQL IAM database users (service accounts or IAM users). No password.
resource "google_sql_user" "iam" {
for_each = local.iam_users
project = var.project_id
# IAM service-account users are named without the ".gserviceaccount.com" suffix.
name = each.value.type == "CLOUD_IAM_SERVICE_ACCOUNT" ? trimsuffix(each.value.name, ".gserviceaccount.com") : each.value.name
instance = google_sql_database_instance.this.name
type = each.value.type
deletion_policy = var.user_deletion_policy
}
variables.tf
variable "project_id" {
description = "GCP project ID that hosts the Cloud SQL instance."
type = string
}
variable "name" {
description = "Instance name (lowercase, RFC1035, <= 80 chars). Cannot be reused for ~1 week after deletion."
type = string
validation {
condition = can(regex("^[a-z]([-a-z0-9]*[a-z0-9])?$", var.name)) && length(var.name) <= 80
error_message = "name must be lowercase RFC1035 (start with a letter, hyphens allowed) and <= 80 chars."
}
}
variable "region" {
description = "Region for the primary instance, e.g. asia-south1."
type = string
}
variable "zone" {
description = "Preferred zone for the primary, e.g. asia-south1-a. The HA standby lands in another zone automatically."
type = string
default = null
}
variable "database_version" {
description = "Engine and version, e.g. POSTGRES_16, MYSQL_8_0, SQLSERVER_2022_STANDARD."
type = string
default = "POSTGRES_16"
validation {
condition = can(regex("^(POSTGRES|MYSQL|SQLSERVER)_", var.database_version))
error_message = "database_version must start with POSTGRES_, MYSQL_, or SQLSERVER_."
}
}
variable "tier" {
description = "Machine tier, e.g. db-custom-2-7680 (2 vCPU / 7.5GB) or a shared db-g1-small for non-prod."
type = string
default = "db-custom-2-7680"
}
variable "edition" {
description = "Cloud SQL edition: ENTERPRISE or ENTERPRISE_PLUS (PITR-to-the-second, higher limits)."
type = string
default = "ENTERPRISE"
validation {
condition = contains(["ENTERPRISE", "ENTERPRISE_PLUS"], var.edition)
error_message = "edition must be ENTERPRISE or ENTERPRISE_PLUS."
}
}
variable "high_availability" {
description = "Run a REGIONAL HA instance with a synchronous standby in another zone (ignored for SQL Server)."
type = bool
default = true
}
variable "disk_type" {
description = "Storage type: PD_SSD or PD_HDD."
type = string
default = "PD_SSD"
validation {
condition = contains(["PD_SSD", "PD_HDD"], var.disk_type)
error_message = "disk_type must be PD_SSD or PD_HDD."
}
}
variable "disk_size_gb" {
description = "Initial data disk size in GB (>= 10)."
type = number
default = 20
validation {
condition = var.disk_size_gb >= 10
error_message = "disk_size_gb must be at least 10 GB."
}
}
variable "disk_autoresize" {
description = "Automatically grow storage when it nears full. Storage can grow but never shrink."
type = bool
default = true
}
variable "disk_autoresize_limit_gb" {
description = "Hard ceiling for autoresize in GB. 0 means no limit (not recommended for cost control)."
type = number
default = 500
}
variable "public_ip_enabled" {
description = "Assign a public IPv4 address. Keep false: private-IP-only is the secure default."
type = bool
default = false
}
variable "private_network" {
description = "Self-link of the VPC for Private Service Access (e.g. projects/<host>/global/networks/<vpc>). Required for private IP."
type = string
default = null
validation {
condition = var.public_ip_enabled || var.private_network != null
error_message = "private_network is required when public_ip_enabled is false (private IP only)."
}
}
variable "private_path_for_google_services" {
description = "Allow Google-managed services (e.g. BigQuery external connections) to reach the instance over its private IP."
type = bool
default = false
}
variable "ssl_mode" {
description = "TLS enforcement: ENCRYPTED_ONLY (require TLS) or TRUSTED_CLIENT_CERTIFICATE_REQUIRED (also require a client cert)."
type = string
default = "ENCRYPTED_ONLY"
validation {
condition = contains([
"ENCRYPTED_ONLY",
"TRUSTED_CLIENT_CERTIFICATE_REQUIRED",
], var.ssl_mode)
error_message = "ssl_mode must be ENCRYPTED_ONLY or TRUSTED_CLIENT_CERTIFICATE_REQUIRED (plaintext is not allowed)."
}
}
variable "authorized_networks" {
description = "Map of name => CIDR allowed to reach the public IP. Only used when public_ip_enabled is true."
type = map(string)
default = {}
}
variable "backup_enabled" {
description = "Enable automated daily backups (and PITR transaction-log/binary-log retention)."
type = bool
default = true
}
variable "backup_start_time" {
description = "Daily backup start time in UTC, HH:MM (24h). Choose a low-traffic window."
type = string
default = "18:00" # ~23:30 IST
validation {
condition = can(regex("^([01][0-9]|2[0-3]):[0-5][0-9]$", var.backup_start_time))
error_message = "backup_start_time must be HH:MM in 24-hour UTC."
}
}
variable "backup_location" {
description = "Backup storage location (region or multi-region, e.g. asia). Null uses the default for the instance region."
type = string
default = null
}
variable "retained_backups" {
description = "Number of automated backups to retain."
type = number
default = 14
validation {
condition = var.retained_backups >= 1 && var.retained_backups <= 365
error_message = "retained_backups must be between 1 and 365."
}
}
variable "transaction_log_retention_days" {
description = "Days of transaction logs kept for point-in-time recovery (1-7 on ENTERPRISE; up to 35 on ENTERPRISE_PLUS)."
type = number
default = 7
validation {
condition = var.transaction_log_retention_days >= 1 && var.transaction_log_retention_days <= 35
error_message = "transaction_log_retention_days must be between 1 and 35."
}
}
variable "maintenance_day" {
description = "Day of week (1=Mon .. 7=Sun) for the maintenance window."
type = number
default = 7
validation {
condition = var.maintenance_day >= 1 && var.maintenance_day <= 7
error_message = "maintenance_day must be between 1 (Mon) and 7 (Sun)."
}
}
variable "maintenance_hour" {
description = "Hour (0-23, UTC) for the maintenance window."
type = number
default = 19
validation {
condition = var.maintenance_hour >= 0 && var.maintenance_hour <= 23
error_message = "maintenance_hour must be between 0 and 23."
}
}
variable "maintenance_update_track" {
description = "Maintenance track: stable (default), canary (early), or week5 (later)."
type = string
default = "stable"
validation {
condition = contains(["stable", "canary", "week5"], var.maintenance_update_track)
error_message = "maintenance_update_track must be stable, canary, or week5."
}
}
variable "database_flags" {
description = "Engine flags as a name => value map, e.g. { log_min_duration_statement = \"1000\" }."
type = map(string)
default = {}
}
variable "query_insights_enabled" {
description = "Enable Cloud SQL Query Insights (per-query latency and plans in the console)."
type = bool
default = true
}
variable "query_insights_string_length" {
description = "Max captured query string length for Query Insights (256-4500)."
type = number
default = 1024
}
variable "query_insights_record_application_tags" {
description = "Record application tags (sqlcommenter) in Query Insights."
type = bool
default = true
}
variable "query_insights_record_client_address" {
description = "Record client IP addresses in Query Insights."
type = bool
default = false
}
variable "databases" {
description = "List of database names to create on the instance."
type = list(string)
default = []
}
variable "database_charset" {
description = "Charset for created databases (e.g. UTF8 for Postgres, utf8mb4 for MySQL)."
type = string
default = "UTF8"
}
variable "database_collation" {
description = "Collation for created databases (e.g. en_US.UTF8 for Postgres, utf8mb4_unicode_ci for MySQL)."
type = string
default = "en_US.UTF8"
}
variable "database_deletion_policy" {
description = "What happens to a database on destroy: DELETE or ABANDON (leave it in place)."
type = string
default = "DELETE"
validation {
condition = contains(["DELETE", "ABANDON"], var.database_deletion_policy)
error_message = "database_deletion_policy must be DELETE or ABANDON."
}
}
variable "users" {
description = <<-EOT
Users to create. Each: { name, type, password }.
type = BUILT_IN -> password user; supply password or one is generated.
type = CLOUD_IAM_USER -> IAM user (human), no password.
type = CLOUD_IAM_SERVICE_ACCOUNT -> IAM service account, no password (name = SA email).
EOT
type = list(object({
name = string
type = optional(string, "BUILT_IN")
password = optional(string)
}))
default = []
validation {
condition = alltrue([
for u in var.users : contains(["BUILT_IN", "CLOUD_IAM_USER", "CLOUD_IAM_SERVICE_ACCOUNT"], u.type)
])
error_message = "Each user type must be BUILT_IN, CLOUD_IAM_USER, or CLOUD_IAM_SERVICE_ACCOUNT."
}
}
variable "user_deletion_policy" {
description = "What happens to a user on destroy: ABANDON (default, safe) or DELETE."
type = string
default = "ABANDON"
validation {
condition = contains(["ABANDON", "DELETE"], var.user_deletion_policy)
error_message = "user_deletion_policy must be ABANDON or DELETE."
}
}
variable "read_replicas" {
description = "Read replicas keyed by replica instance name. Each: { region, tier }. tier defaults to the primary's tier."
type = map(object({
region = string
tier = optional(string)
}))
default = {}
}
variable "deletion_protection" {
description = "Block deletion of the instance via the API/Terraform."
type = bool
default = true
}
variable "labels" {
description = "Labels applied to the instance and replicas (user_labels)."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "Fully qualified Cloud SQL instance ID."
value = google_sql_database_instance.this.id
}
output "name" {
description = "Name of the primary instance."
value = google_sql_database_instance.this.name
}
output "connection_name" {
description = "Connection name (project:region:instance) for the Cloud SQL Auth Proxy / connectors."
value = google_sql_database_instance.this.connection_name
}
output "self_link" {
description = "URI (self-link) of the primary instance."
value = google_sql_database_instance.this.self_link
}
output "private_ip_address" {
description = "Private IP address of the primary instance (null if private IP is disabled)."
value = google_sql_database_instance.this.private_ip_address
}
output "public_ip_address" {
description = "Public IP address of the primary instance (null when public IP is disabled)."
value = google_sql_database_instance.this.public_ip_address
}
output "service_account_email_address" {
description = "Service account the instance uses (grant it KMS/CMEK or GCS access for imports/exports)."
value = google_sql_database_instance.this.service_account_email_address
}
output "database_names" {
description = "Names of the databases created on the instance."
value = [for db in google_sql_database.this : db.name]
}
output "builtin_user_passwords" {
description = "Generated passwords for built-in users without a supplied password, keyed by user name."
value = { for k, p in random_password.user : k => p.result }
sensitive = true
}
output "replica_connection_names" {
description = "Map of read replica name to its connection name."
value = { for k, r in google_sql_database_instance.replica : k => r.connection_name }
}
output "replica_private_ip_addresses" {
description = "Map of read replica name to its private IP address."
value = { for k, r in google_sql_database_instance.replica : k => r.private_ip_address }
}
How to use it
# Private Service Access must exist before a private-IP instance can be created:
# an allocated range peered to servicenetworking.googleapis.com on the VPC.
resource "google_compute_global_address" "psa_range" {
project = var.project_id
name = "cloudsql-psa-range"
purpose = "VPC_PEERING"
address_type = "INTERNAL"
prefix_length = 16
network = var.vpc_self_link
}
resource "google_service_networking_connection" "psa" {
network = var.vpc_self_link
service = "servicenetworking.googleapis.com"
reserved_peering_ranges = [google_compute_global_address.psa_range.name]
}
module "cloud_sql" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-sql?ref=v1.0.0"
project_id = "kv-platform-prod"
name = "kv-orders-pg-prod"
region = "asia-south1"
zone = "asia-south1-a"
database_version = "POSTGRES_16"
edition = "ENTERPRISE_PLUS"
tier = "db-perf-optimized-N-4" # 4 vCPU ENTERPRISE_PLUS perf tier
# Private IP only, over the Shared VPC, with TLS required.
high_availability = true
private_network = var.vpc_self_link
ssl_mode = "ENCRYPTED_ONLY"
# 14 daily backups + 7 days of WAL for point-in-time recovery.
backup_enabled = true
backup_start_time = "18:30" # 00:00 IST
retained_backups = 14
transaction_log_retention_days = 7
database_flags = {
log_min_duration_statement = "1000" # log queries slower than 1s
"cloudsql.iam_authentication" = "on"
}
databases = ["orders", "orders_outbox"]
users = [
# The orders service authenticates with its IAM service account (no password).
{
name = google_service_account.orders_api.email
type = "CLOUD_IAM_SERVICE_ACCOUNT"
},
# A break-glass built-in admin; password is generated and stored in Secret Manager below.
{
name = "orders_admin"
type = "BUILT_IN"
},
]
# Cross-region read replica for DR / read scaling.
read_replicas = {
"kv-orders-pg-prod-replica-sea" = {
region = "asia-southeast1"
tier = "db-perf-optimized-N-2"
}
}
deletion_protection = true
labels = {
team = "payments"
environment = "prod"
data_class = "restricted"
}
depends_on = [google_service_networking_connection.psa]
}
# Downstream: stash the generated admin password and the connection name in
# Secret Manager so apps and runbooks never see them in HCL or state output.
resource "google_secret_manager_secret" "orders_admin_pw" {
project = "kv-platform-prod"
secret_id = "orders-admin-db-password"
replication {
auto {}
}
}
resource "google_secret_manager_secret_version" "orders_admin_pw" {
secret = google_secret_manager_secret.orders_admin_pw.id
secret_data = module.cloud_sql.builtin_user_passwords["orders_admin"] # <- module output
}
# A Cloud Run service reaching the DB privately via the Auth Proxy/connector.
resource "google_cloud_run_v2_service" "orders_api" {
project = "kv-platform-prod"
name = "orders-api"
location = "asia-south1"
template {
service_account = google_service_account.orders_api.email
containers {
image = "asia-south1-docker.pkg.dev/kv-platform-prod/services/orders-api:1.8.2"
env {
name = "INSTANCE_CONNECTION_NAME"
value = module.cloud_sql.connection_name # <- module output wires the app to the DB
}
}
}
}
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 = "gcs"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...gcs state bucket/container + key per path...
}
}
2. Module config — live/prod/cloud_sql/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-sql?ref=v1.0.0"
}
inputs = {
project_id = "..."
name = "..."
region = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/cloud_sql && 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 |
|---|---|---|---|---|
| project_id | string | — | yes | GCP project ID hosting the instance. |
| name | string | — | yes | Instance name; RFC1035, lowercase, <= 80 chars. |
| region | string | — | yes | Region for the primary, e.g. asia-south1. |
| zone | string | null |
no | Preferred zone for the primary; standby auto-placed elsewhere. |
| database_version | string | POSTGRES_16 |
no | Engine/version (POSTGRES_*, MYSQL_*, SQLSERVER_*). |
| tier | string | db-custom-2-7680 |
no | Machine tier (vCPU/RAM). |
| edition | string | ENTERPRISE |
no | ENTERPRISE or ENTERPRISE_PLUS. |
| high_availability | bool | true |
no | REGIONAL HA with a synchronous standby (ignored for SQL Server). |
| disk_type | string | PD_SSD |
no | PD_SSD or PD_HDD. |
| disk_size_gb | number | 20 |
no | Initial storage size in GB (min 10). |
| disk_autoresize | bool | true |
no | Auto-grow storage when nearly full. |
| disk_autoresize_limit_gb | number | 500 |
no | Ceiling for autoresize; 0 = unlimited. |
| public_ip_enabled | bool | false |
no | Assign a public IPv4 (keep false). |
| private_network | string | null |
no | VPC self-link for Private Service Access (required when private). |
| private_path_for_google_services | bool | false |
no | Allow Google services to reach the private IP. |
| ssl_mode | string | ENCRYPTED_ONLY |
no | TLS enforcement mode (plaintext disallowed). |
| authorized_networks | map(string) | {} |
no | name => CIDR for public IP (only if public enabled). |
| backup_enabled | bool | true |
no | Daily automated backups + PITR log retention. |
| backup_start_time | string | 18:00 |
no | Daily backup window start, UTC HH:MM. |
| backup_location | string | null |
no | Backup storage location (region/multi-region). |
| retained_backups | number | 14 |
no | Number of automated backups retained (1-365). |
| transaction_log_retention_days | number | 7 |
no | PITR log retention in days (1-35). |
| maintenance_day | number | 7 |
no | Maintenance day (1=Mon…7=Sun). |
| maintenance_hour | number | 19 |
no | Maintenance hour (0-23 UTC). |
| maintenance_update_track | string | stable |
no | stable, canary, or week5. |
| database_flags | map(string) | {} |
no | Engine flags as name => value. |
| query_insights_enabled | bool | true |
no | Enable Query Insights. |
| query_insights_string_length | number | 1024 |
no | Captured query length (256-4500). |
| query_insights_record_application_tags | bool | true |
no | Record sqlcommenter app tags. |
| query_insights_record_client_address | bool | false |
no | Record client IPs. |
| databases | list(string) | [] |
no | Databases to create on the instance. |
| database_charset | string | UTF8 |
no | Charset for created databases. |
| database_collation | string | en_US.UTF8 |
no | Collation for created databases. |
| database_deletion_policy | string | DELETE |
no | DELETE or ABANDON databases on destroy. |
| users | list(object) | [] |
no | Users {name, type, password} (built-in or IAM). |
| user_deletion_policy | string | ABANDON |
no | ABANDON or DELETE users on destroy. |
| read_replicas | map(object) | {} |
no | Read replicas {region, tier} keyed by name. |
| deletion_protection | bool | true |
no | Block deletion via API/Terraform. |
| labels | map(string) | {} |
no | Labels on the instance and replicas. |
Outputs
| Name | Description |
|---|---|
| id | Fully qualified Cloud SQL instance ID. |
| name | Name of the primary instance. |
| connection_name | project:region:instance for the Auth Proxy / connectors. |
| self_link | URI (self-link) of the primary instance. |
| private_ip_address | Private IP of the primary (null if disabled). |
| public_ip_address | Public IP of the primary (null when disabled). |
| service_account_email_address | Instance service account (for CMEK/GCS grants). |
| database_names | Names of databases created on the instance. |
| builtin_user_passwords | Generated built-in user passwords (sensitive), keyed by name. |
| replica_connection_names | Map of replica name to connection name. |
| replica_private_ip_addresses | Map of replica name to private IP. |
Enterprise scenario
A payments platform runs its orders and ledger services on regional HA POSTGRES_16 instances in asia-south1, each provisioned by one call to this module. Every instance is private-IP-only over the Shared VPC with ssl_mode = "ENCRYPTED_ONLY", applications authenticate using Cloud SQL IAM database users tied to their workload service accounts (so there are no long-lived passwords to rotate), and a cross-region read replica in asia-southeast1 provides DR plus offloads heavy reporting reads. With ENTERPRISE_PLUS, 14 retained backups, and seven days of WAL, the team can restore any database to a chosen second within the window — and because deletion protection and the destructive DELETE database policy are governed by the module, a stray terraform destroy in a PR is blocked at plan time rather than wiping a production ledger.
Best practices
- Stay private-IP-only and force TLS. Keep
public_ip_enabled = falsewith aprivate_networkover Private Service Access, and leavessl_mode = "ENCRYPTED_ONLY"(or require client certs for the most sensitive instances). A public IP withauthorized_networksis a deliberate, reviewed exception — never the default. - Prefer IAM database authentication over passwords. Use
CLOUD_IAM_SERVICE_ACCOUNTusers for app workloads and set thecloudsql.iam_authenticationflag, so access is short-lived IAM tokens instead of credentials you must store and rotate. Reserve built-in users for break-glass admins, and push their generated passwords straight into Secret Manager from the module output. - Make HA, backups, and PITR non-negotiable for prod. Keep
high_availability = truefor a synchronous standby,backup_enabled = truewith a sensibleretained_backups, andtransaction_log_retention_daysset so point-in-time recovery covers your RPO — then actually rehearse a restore into a fresh instance so the RTO is real. - Right-size the tier and cap storage growth. Start from a
db-custom-<vcpu>-<mem_mb>(ordb-perf-optimized-N-*on ENTERPRISE_PLUS) sized from observed CPU/connections, keepdisk_autoresize = truebut always setdisk_autoresize_limit_gbso a runaway table can’t silently inflate the bill — storage grows but never shrinks, so over-provisioning is permanent. - Tune the maintenance and backup windows, and watch slow queries. Put
maintenance_day/maintenance_hourandbackup_start_timein your true off-peak (remember the values are UTC), enable Query Insights, and setlog_min_duration_statementviadatabase_flagsto surface slow queries before they page someone. - Name and label for governance. Use the RFC1035
namewith an<app>-<engine>-<env>convention (kv-orders-pg-prod), label every instance withteam/environment/data_class, and keepdeletion_protection = trueplus a conservativedatabase_deletion_policyso production data outlives any single Terraform run.