Provisions a single Cloud Spanner instance (
google_spanner_instance) together with itsfor_each-managed databases (google_spanner_database) as one coherent unit. Targetshashicorp/google ~> 7.0, Terraform>= 1.12.0.
- 🗄️ Provisions one Cloud Spanner instance (
google_spanner_instance.this) — the isolated set of compute resources on which databases are hosted. - 🧬 Manages any number of databases (
google_spanner_database.databases) viafor_eachovervar.databases, keyed by database name. - ⚖️ Enforces Cloud Spanner's three-way capacity mutual exclusion — exactly one of
num_nodes,processing_units, orautoscaling_config(unlessinstance_type = "FREE_INSTANCE") — atplantime via a cross-variablevalidation {}block, not left to anapply-time API rejection. - 💰 Never defaults instance capacity. Compute cost is real and immediate the instant a Spanner instance exists — the caller must state a capacity choice explicitly.
- 🛡️
deletion_policy = "PREVENT"on the instance (the closest available guard — the instance has nodeletion_protectionboolean at all) and bothdeletion_protection = trueandenable_drop_protection = trueon every database by default. - 📝 Supports the Cloud Spanner
ddlfield's append-only update semantics with an explicit warning against in-place edits that would force database recreation. - 🔒 Per-database CMEK (
encryption_config) accepted as an optional variable, never defaulted to a specific key.
💡 Why it matters: Cloud Spanner is our horizontally-scalable, strongly-consistent relational data store for workloads that outgrow Cloud SQL's single-instance ceiling. Because compute capacity billing starts the moment the instance exists — there is no free-tier default this module can safely fall back to for production use — this module treats an explicit capacity choice as a mandatory input, not an optional one, the same way it treats CMEK as an explicit, never-defaulted opt-in.
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!
This module sits in the Data domain alongside tf-mod-gcp-cloud-sql-instance,
tf-mod-gcp-bigquery-dataset, tf-mod-gcp-pubsub-topic, and tf-mod-gcp-bigtable-instance. It
optionally consumes tf-mod-gcp-kms-keyring's crypto key id for per-database CMEK — no other
module in the current catalog has an identified dependency on this module's outputs.
flowchart LR
kms["tf-mod-gcp-kms-keyring"]:::upstream
kms -->|"optional CMEK key"| spanner
subgraph domain["Data domain"]
direction LR
spanner["tf-mod-gcp-spanner-instance"]:::self
sql["tf-mod-gcp-cloud-sql-instance"]:::sibling
bq["tf-mod-gcp-bigquery-dataset"]:::sibling
pubsub["tf-mod-gcp-pubsub-topic"]:::sibling
bigtable["tf-mod-gcp-bigtable-instance"]:::sibling
end
classDef self fill:#4285F4,color:#FFFFFF,stroke:#174EA6,stroke-width:2px;
classDef sibling fill:#ECEFF1,color:#263238,stroke:#90A4AE,stroke-width:1px;
classDef upstream fill:#174EA6,color:#FFFFFF,stroke:#0D47A1,stroke-width:2px;
Validated via the Mermaid Chart MCP (validate_and_render_mermaid_diagram) before embedding.
Unlike tf-mod-gcp-bigtable-instance's sibling module, Cloud Spanner has no separate
cluster-like nested resource between the instance and its databases — the instance itself is
the compute/replication unit, and databases attach to it directly. Neither resource in this module
exposes a self_link attribute (verified against the live schema).
flowchart TB
subgraph Module["tf-mod-gcp-spanner-instance"]
direction TB
inst["google_spanner_instance.this<br/>(keystone)"]:::keystone
asc["autoscaling_config (dynamic)<br/>mutually exclusive w/ num_nodes/processing_units"]:::nested
instto["timeouts (dynamic)<br/>create/update/delete"]:::nested
db["google_spanner_database.databases<br/>for_each var.databases"]:::child
enc["encryption_config (dynamic)<br/>optional per-database CMEK"]:::nested
dbto["timeouts (dynamic)<br/>create/update/delete"]:::nested
end
inst -->|"instance = google_spanner_instance.this.name"| db
inst --> asc
inst --> instto
db --> enc
db --> dbto
classDef keystone fill:#174EA6,color:#FFFFFF,stroke:#0D47A1,stroke-width:2px;
classDef child fill:#4285F4,color:#FFFFFF,stroke:#174EA6,stroke-width:2px;
classDef nested fill:#ECEFF1,color:#263238,stroke:#90A4AE,stroke-width:1px;
Validated via the Mermaid Chart MCP before embedding.
Resource inventory (2 resource types):
| Resource | Count | Role |
|---|---|---|
google_spanner_instance.this |
1 | Keystone — the isolated compute/replication unit databases attach to |
google_spanner_database.databases |
for_each over var.databases |
Databases hosted on the instance, each with its own DDL, CMEK, and deletion-guard configuration |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/google |
~> 7.0 |
| Provider block | None — the caller's root module configures google (ADC, WIF, or a service-account key per our authentication model) |
Schema notes that bite:
- Capacity is a three-way mutual exclusion, not two. Confirmed live Argument Reference:
"Exactly one of either
num_nodes,processing_unitsorautoscaling_configmust be present in terraform except wheninstance_type = FREE_INSTANCE." Enforced by this module's cross-variablevalidation {}onautoscaling_config. google_spanner_instancehas nodeletion_protectionboolean at all. The only destroy-related fields aredeletion_policy(provider default"DELETE") andforce_destroy.google_spanner_databasehas three independent deletion mechanisms —deletion_protection,enable_drop_protection, anddeletion_policy— not one. See 🧠 Architecture Notes for the difference between the first two; they are not redundant.ddlis append-only-safe, not general ALTER semantics. Newly appended statements can be applied in an update; editing or removing a prior statement forces database recreation. Terraform performs no drift detection on this field.- Neither resource exposes a
self_linkattribute. Confirmed absent from both Attributes References — this module's outputs deliberately omit aself_linkrow. - CMEK (
encryption_config) lives only on the database, never the instance — confirmed absent from the instance's Argument Reference entirely. - The database's
instanceargument wants the short instance name, not.id. Confirmed by the provider's own Example Usage (instance = google_spanner_instance.main.name) — this module'smain.tfdeliberately deviates from the library's general "prefer.id" guidance for this specific cross-reference. - Instance
nameis optional (unlike most GCP resources in this library) — a randomtf--prefixed name is generated if omitted. Force-new once set, per the house blanket rule.
roles/spanner.admin— full lifecycle management (create/update/delete) of Spanner instances and databases on the target project. This is the only role confirmed for this module.
(Sourced directly from SCOPE.md — not re-derived.)
spanner.googleapis.comAPI enabled on the target project (viatf-mod-gcp-project-services, applied before this module per the house recommended authoring order).- Instance
configis force-new and effectively permanent — there is no in-place "move" between configs. If the organization hasconstraints/gcp.resourceLocationsset, it restricts whichconfigvalues are permitted; a violation surfaces only atapply, never atplan. - Cloud Spanner enforces per-project, per-instance-config node/processing-unit quotas — invisible
to
terraform plan; verify current quota before a capacity request would exceed it. - Compute-capacity cost is real and immediate. Unlike most modules in this library, an "empty
call" cannot produce a genuinely free/inert Spanner instance except via the narrow
instance_type = "FREE_INSTANCE"tier (one per project/org, cannot combine withedition, and too limited for most production workloads). This module deliberately never defaultsnum_nodes/processing_units/autoscaling_config.
(Sourced directly from SCOPE.md — not re-derived.)
tf-mod-gcp-spanner-instance/
├── providers.tf # required_providers (hashicorp/google ~> 7.0) + required_version — no provider {} block
├── variables.tf # instance args, three-way capacity validation, databases map(object(...))
├── main.tf # google_spanner_instance.this + google_spanner_database.databases (for_each)
├── outputs.tf # id, name, database_ids, database_names — no self_link (see Architecture Notes)
├── README.md # this file
├── SCOPE.md # cross-module contract
└── examples/ # runnable example(s) matching the Quick Start below
# Caller's root module configures the google provider (ADC, WIF, or a service-account key) —
# this module never declares project/region/zone/credentials variables.
module "spanner_instance" {
source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"
config = "regional-us-central1"
display_name = "Primary Spanner Instance"
num_nodes = 1
databases = {
"app-primary-db" = {
ddl = [
"CREATE TABLE Users (UserId INT64 NOT NULL, Name STRING(MAX)) PRIMARY KEY (UserId)",
]
}
}
}Consumes
| Input | Type | Source module |
|---|---|---|
CMEK crypto key name (per-database encryption_config.kms_key_name) — optional |
string (full KMS crypto key resource path) |
tf-mod-gcp-kms-keyring |
(none required) — runs standalone with the default no-CMEK, GOOGLE_STANDARD_SQL configuration |
— | — |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Instance resource id, format {{project}}/{{name}} |
None identified yet in the current catalog |
name |
Instance name (as supplied, or the provider's random tf--prefixed name if unset) |
None identified yet |
database_ids |
Map of database key → google_spanner_database.id (format {{instance}}/{{name}}) |
None identified yet |
database_names |
Map of database key → database name | None identified yet |
ℹ️ No
self_linkrow — neithergoogle_spanner_instancenorgoogle_spanner_databaseexposes aself_linkattribute. This is a deliberate, documented deviation from the library's default "id then self_link" ordering, not an omission — same precedent astf-mod-gcp-kms-keyring.
1 · Minimal instance sized with num_nodes
module "spanner_instance" {
source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"
config = "regional-us-central1"
display_name = "Minimal Node-Sized Instance"
num_nodes = 1
}ℹ️
databasesdefaults to{}— an instance with no databases is a valid, safe starting point for a composition that adds databases in a later apply.
2 · Sizing by processing_units instead of num_nodes
module "spanner_instance" {
source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"
config = "regional-us-central1"
display_name = "Processing-Unit-Sized Instance"
processing_units = 200
}💡
processing_unitsandnum_nodesare two of the three mutually exclusive capacity mechanisms (1000 processing units == 1 node) — set exactly one, never both.
3 · Autoscaling by processing-unit limits
module "spanner_instance" {
source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"
config = "regional-us-central1"
display_name = "Autoscaled Instance"
autoscaling_config = {
autoscaling_limits = {
min_processing_units = 1000
max_processing_units = 4000
}
autoscaling_targets = {
high_priority_cpu_utilization_percent = 65
storage_utilization_percent = 75
}
}
}
⚠️ autoscaling_configis the third mutually exclusive capacity mechanism — setting it together withnum_nodesorprocessing_unitsfails this module'splan-timevalidation {}rather than surfacing only as anapply-time API error.
4 · Free-tier instance (no capacity variable required)
module "spanner_instance" {
source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"
config = "regional-us-central1"
display_name = "Free Tier Instance"
instance_type = "FREE_INSTANCE"
default_backup_schedule_type = "NONE"
}🔒
instance_type = "FREE_INSTANCE"is the sole exception to the three-way capacity mutual exclusion — none ofnum_nodes/processing_units/autoscaling_configneed be set.editionmust remain unset, anddefault_backup_schedule_typemust not be"AUTOMATIC"(free instances do not support backups) — both enforced by this module's validations.
5 · Single database with default settings
module "spanner_instance" {
source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"
config = "regional-us-central1"
display_name = "Instance With One Database"
num_nodes = 1
databases = {
"app-primary-db" = {}
}
}💡 An empty database entry still produces a complete, valid database:
database_dialect = "GOOGLE_STANDARD_SQL",deletion_protection = true, andenable_drop_protection = trueall apply by default.
6 · Database created with an initial DDL schema
module "spanner_instance" {
source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"
config = "regional-us-central1"
display_name = "Instance With Schema"
num_nodes = 1
databases = {
"orders-db" = {
ddl = [
"CREATE TABLE Orders (OrderId INT64 NOT NULL, CustomerId INT64 NOT NULL) PRIMARY KEY (OrderId)",
"CREATE INDEX OrdersByCustomer ON Orders(CustomerId)",
]
}
}
}ℹ️ These statements execute atomically with database creation — if any statement errors, the database is not created.
7 · ⚠️ Appending new DDL to an existing database (append-only pattern)
module "spanner_instance" {
source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"
config = "regional-us-central1"
display_name = "Instance With Schema"
num_nodes = 1
databases = {
"orders-db" = {
ddl = [
"CREATE TABLE Orders (OrderId INT64 NOT NULL, CustomerId INT64 NOT NULL) PRIMARY KEY (OrderId)",
"CREATE INDEX OrdersByCustomer ON Orders(CustomerId)",
# Appended in a later apply — safe, executes as an in-place update:
"CREATE TABLE OrderLines (OrderId INT64 NOT NULL, LineNo INT64 NOT NULL, Sku STRING(64)) PRIMARY KEY (OrderId, LineNo), INTERLEAVE IN PARENT Orders ON DELETE CASCADE",
]
}
}
}
⚠️ Only append. Editing or removing either of the first two statements above — instead of only adding the third — creates a plan that marks the database for recreation, not an in-placeALTER. Terraform performs no drift detection onddland trusts whatever is recorded in state. Withenable_drop_protection = true(this module's default), that recreation plan is additionally blocked outright atapplyuntil the guard is explicitly disabled.
8 · PostgreSQL-dialect database
module "spanner_instance" {
source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"
config = "regional-us-central1"
display_name = "PostgreSQL Dialect Instance"
num_nodes = 1
databases = {
"pg-app-db" = {
database_dialect = "POSTGRESQL"
ddl = [
"CREATE TABLE users (user_id BIGINT NOT NULL PRIMARY KEY, name TEXT)",
]
}
}
}ℹ️
database_dialectdefaults to"GOOGLE_STANDARD_SQL"— set"POSTGRESQL"explicitly to opt into PostgreSQL-compatible DDL/SQL syntax for this database only; other databases on the same instance may use either dialect independently.
9 · Per-database CMEK (single-region key)
module "spanner_instance" {
source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"
config = "regional-us-central1"
display_name = "CMEK-Encrypted Instance"
num_nodes = 1
databases = {
"regulated-data-db" = {
encryption_config = {
kms_key_name = "projects/casey-prod-security/locations/us-central1/keyRings/casey-prod-cmek/cryptoKeys/spanner-db-key"
}
}
}
}🔒 CMEK is database-level only —
google_spanner_instancehas no encryption argument at all. Never defaulted to a specific key by this module; the caller supplies a fully-qualified crypto key resource name, typically sourced fromtf-mod-gcp-kms-keyring'scrypto_key_idsoutput (see the end-to-end composition below).
10 · Multi-region instance with multi-region CMEK
module "spanner_instance" {
source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"
config = "nam-eur-asia1"
display_name = "Multi-Region Instance"
num_nodes = 2
databases = {
"global-catalog-db" = {
encryption_config = {
kms_key_names = [
"projects/casey-prod-security/locations/us-central1/keyRings/casey-prod-cmek/cryptoKeys/spanner-db-key",
"projects/casey-prod-security/locations/europe-west1/keyRings/casey-prod-cmek/cryptoKeys/spanner-db-key",
"projects/casey-prod-security/locations/asia-east1/keyRings/casey-prod-cmek/cryptoKeys/spanner-db-key",
]
}
}
}
}ℹ️
kms_key_names(plural) supplies one key per replica region for a multi-region instanceconfig;kms_key_name(singular, example 9) applies to a single-region instance. This module does not validate the two as mutually exclusive — an invalid combination surfaces atapply, notplan(see Schema notes).
11 · Custom version_retention_period for a longer stale-read window
module "spanner_instance" {
source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"
config = "regional-us-central1"
display_name = "Extended Retention Instance"
num_nodes = 1
databases = {
"audit-db" = {
version_retention_period = "3d"
}
}
}
⚠️ If set, avoid adding newddlstatements in a later apply that themselves changeversion_retention_period— the provider's own docs flag this as a self-referential trap. This module validates only the"<number><d|h|m|s>"format, not the confirmed 1-hour–7-day numeric range (which spans mixed units); an out-of-range value is caught atapply.
12 · Opting out of both deletion guards for a genuinely disposable dev database
module "spanner_instance" {
source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"
config = "regional-us-central1"
display_name = "Dev Sandbox Instance"
num_nodes = 1
databases = {
"scratch-db" = {
deletion_protection = false
enable_drop_protection = false
}
}
}
⚠️ Both guards default totrue. Setting both tofalserestores the ability toterraform destroythis database through Terraform AND through every other interface (console,gcloud, API) — only appropriate for a database that never holds data anyone needs to recover. Leaving either guard at its default blocks the destroy outright (see 🔍 Troubleshooting).
13 · Asymmetric autoscaling for a specific replica
module "spanner_instance" {
source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"
config = "nam-eur-asia1"
display_name = "Asymmetric Autoscaling Instance"
autoscaling_config = {
autoscaling_limits = {
min_nodes = 3
max_nodes = 9
}
asymmetric_autoscaling_options = [
{
replica_selection = { location = "us-central1" }
overrides = {
autoscaling_limits = { min_nodes = 2, max_nodes = 6 }
disable_total_cpu_autoscaling = false
}
}
]
}
}ℹ️
asymmetric_autoscaling_optionslets one replica scale within its own limits, independent of the top-levelautoscaling_limitsapplied to the rest of the instance's replicas — useful when one region carries disproportionate read traffic.
14 · Custom create/update/delete timeouts (instance and database)
module "spanner_instance" {
source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"
config = "regional-us-central1"
display_name = "Slow-Provisioning Instance"
num_nodes = 1
timeouts = {
create = "40m"
update = "40m"
delete = "40m"
}
databases = {
"large-import-db" = {
timeouts = {
create = "40m"
update = "40m"
delete = "40m"
}
}
}
}ℹ️ Both resources support all three timeout operations, each defaulting to 20 minutes — verified independently against the live schema for the instance and the database.
15 · 🏗️ End-to-end composition
Wires an optional tf-mod-gcp-kms-keyring crypto key into this module's per-database
encryption_config.kms_key_name, and this module's database_ids output toward a hypothetical
downstream consumer — no consumer is yet identified in the current catalog, consistent with
SCOPE.md's Emits table.
module "kms_keyring" {
source = "git::https://github.com/microsoftexpert/tf-mod-gcp-kms-keyring.git?ref=v1.0.0"
key_ring_name = "casey-prod-cmek"
location = "us-central1"
crypto_keys = {
"spanner-db-key" = {
purpose = "ENCRYPT_DECRYPT"
labels = {
team = "data-platform"
}
}
}
}
module "spanner_instance" {
source = "git::https://github.com/microsoftexpert/tf-mod-gcp-spanner-instance.git?ref=v1.0.0"
config = "regional-us-central1"
display_name = "Production Spanner Instance"
num_nodes = 3
labels = {
team = "data-platform"
environment = "prod"
}
databases = {
"app-primary-db" = {
ddl = [
"CREATE TABLE Users (UserId INT64 NOT NULL, Name STRING(MAX)) PRIMARY KEY (UserId)",
]
# Consumes tf-mod-gcp-kms-keyring's crypto_key_ids output (Emits table row:
# `crypto_key_ids`) — never a hardcoded key resource name.
encryption_config = {
kms_key_name = module.kms_keyring.crypto_key_ids["spanner-db-key"]
}
}
}
}
# Hypothetical downstream consumer — no such module exists in the current catalog yet.
# output "app_primary_database_id" {
# value = module.spanner_instance.database_ids["app-primary-db"]
# }
⚠️ Before this composition can succeed, the Cloud Spanner service agent for the target project must be grantedroles/cloudkms.cryptoKeyEncrypterDecrypterdirectly onmodule.kms_keyring.crypto_key_ids["spanner-db-key"]— a binding out of scope for both modules by design. Grant it via a dedicated IAM aggregation module or the composing root module, applied after the crypto key exists and before this module's database is created; allow for IAM propagation lag (up to ~60 seconds) between the grant and the database apply.
| Variable | Type | Default | Notes |
|---|---|---|---|
name |
string |
null |
Optional; force-new; random tf- name if unset |
config |
string |
— (required) | Force-new; not validated against a hardcoded list |
display_name |
string |
— (required) | 4-30 characters |
num_nodes |
number |
null |
One of three mutually exclusive capacity mechanisms |
processing_units |
number |
null |
One of three mutually exclusive capacity mechanisms |
autoscaling_config |
object({...}) |
null |
One of three mutually exclusive capacity mechanisms |
edition |
string |
null |
Must be unset when instance_type = "FREE_INSTANCE" |
instance_type |
string |
"PROVISIONED" |
"PROVISIONED" | "FREE_INSTANCE" |
default_backup_schedule_type |
string |
"AUTOMATIC" |
Secure-default extension; must be "NONE" for FREE_INSTANCE |
deletion_policy |
string |
"PREVENT" |
Secure-default extension (no deletion_protection field exists) |
force_destroy |
bool |
false |
Deletes all backups on instance destroy when true |
labels |
map(string) |
{} |
Instance-level only — database has no labels surface |
timeouts |
object({...}) |
null |
Instance create/update/delete, 20m provider default each |
databases |
map(object({...})) |
{} |
Keyed by database name — see full schema below |
Full autoscaling_config object schema
variable "autoscaling_config" {
type = object({
autoscaling_limits = optional(object({
min_processing_units = optional(number)
max_processing_units = optional(number)
min_nodes = optional(number)
max_nodes = optional(number)
}))
autoscaling_targets = optional(object({
high_priority_cpu_utilization_percent = optional(number)
storage_utilization_percent = optional(number)
total_cpu_utilization_percent = optional(number)
}))
asymmetric_autoscaling_options = optional(list(object({
replica_selection = object({
location = string
})
overrides = object({
autoscaling_limits = optional(object({
min_nodes = optional(number)
max_nodes = optional(number)
min_processing_units = optional(number)
max_processing_units = optional(number)
}))
autoscaling_target_high_priority_cpu_utilization_percent = optional(number)
autoscaling_target_total_cpu_utilization_percent = optional(number)
disable_high_priority_cpu_autoscaling = optional(bool)
disable_total_cpu_autoscaling = optional(bool)
})
})), [])
})
default = null
}Full databases object schema
variable "databases" {
type = map(object({
database_dialect = optional(string, "GOOGLE_STANDARD_SQL")
ddl = optional(list(string), [])
encryption_config = optional(object({
kms_key_name = optional(string)
kms_key_names = optional(list(string))
}))
version_retention_period = optional(string)
default_time_zone = optional(string)
deletion_protection = optional(bool, true)
enable_drop_protection = optional(bool, true)
deletion_policy = optional(string, "DELETE")
timeouts = optional(object({
create = optional(string)
update = optional(string)
delete = optional(string)
}))
}))
default = {}
}| Output | Description | Sensitive |
|---|---|---|
id |
Instance Terraform-internal resource id ({{project}}/{{name}}) |
No |
name |
Instance name | No |
database_ids |
Map of database key → database id ({{instance}}/{{name}}) |
No |
database_names |
Map of database key → database name | No |
ℹ️ No
self_linkoutput — see 🔌 Cross-Module Contract and 🧠 Architecture Notes.
- The instance has no
deletion_protectionboolean. Confirmed absent from the live schema — the only destroy-related fields ongoogle_spanner_instancearedeletion_policy(provider default"DELETE") andforce_destroy. This module compensates by defaultingdeletion_policy = "PREVENT", the closest available guard, rather than leaving the provider's own"DELETE"default in place. enable_drop_protectionanddeletion_protectionon the database are NOT redundant.deletion_protection(also defaultedtruehere) is a Terraform-state-only guard — it blocksterraform destroy/applyfrom deleting the database, but nothing stops deletion through the console,gcloud, or the API directly.enable_drop_protection(defaultedtruehere — the provider's own default isfalse) is the materially stronger, API-level guard: it blocks deletion through every interface, and it additionally blocks deletion of the parent instance while the database exists. A caller who wants to destroy an instance with databases still attached must disableenable_drop_protectionon every database first — disabling onlydeletion_protectionis not sufficient.- Instance capacity cost-safety rationale. Given Spanner compute capacity is real, ongoing
cost the instant the instance exists, this module's "empty call produces the safe resource"
principle cannot mean "produces a free resource" the way it does for most modules in this
library — the closest analog is "produces no resource until the caller states a capacity choice
explicitly," enforced via the cross-variable
validation {}onautoscaling_configrather than a baked-in default for any of the three capacity fields. ddlis append-only-safe, not general ALTER semantics. Terraform performs no drift detection on this field and trusts whatever is recorded in state; editing or removing a prior statement (rather than only appending) marks the database for recreation — see Example 7.- The database's
instanceargument takes the short instance name, not.id. Confirmed by the provider's own Example Usage — a deliberate deviation from this library's general "cross-reference via.id" guidance for this specific argument. for_eachkey stability. Database map keys become the GCP resourcenameand the key used indatabase_ids/database_names. Renaming a key is a destroy/recreate (Terraform sees a removed + an added resource), not an in-place rename.- Implicit dependency ordering.
google_spanner_database.databasesreferencesgoogle_spanner_instance.this.namedirectly — no explicitdepends_onbetween the two. - IAM propagation delay. This module grants no IAM itself, but a composition that grants
roles/spanner.adminor a CMEK crypto-key-level role immediately before this module's apply may see a transient permission-denied error for up to ~60 seconds after the grant.
| Concern | Secure default | Opt-out (explicit) |
|---|---|---|
Instance destroy guard (no deletion_protection field exists) |
deletion_policy = "PREVENT" |
Caller sets "DELETE" or "ABANDON" |
| Database Terraform-only destroy guard | deletion_protection = true |
Caller sets false |
| Database API-level, all-interfaces destroy guard (also blocks parent instance deletion) | enable_drop_protection = true |
Caller sets false |
| Database destroy policy | Left at provider default "DELETE" — the two booleans above already block deletion by default |
Caller sets "ABANDON" or "PREVENT" explicitly |
| Instance capacity (cost-safety, not a security control) | Never defaulted — caller must set exactly one of num_nodes / processing_units / autoscaling_config (or none, for FREE_INSTANCE) |
N/A — a mandatory explicit choice by design |
| Default backup behavior for new databases | default_backup_schedule_type = "AUTOMATIC" (extends the house Cloud SQL backups precedent to Spanner) |
Caller sets "NONE"; required for FREE_INSTANCE |
| Per-database CMEK | Accepted as an optional variable, never defaulted to a specific key | Caller supplies encryption_config.kms_key_name/kms_key_names explicitly |
| Empty call | databases defaults to {} — an instance with no databases, the safe, inert result |
Caller supplies databases entries explicitly |
cd C:\GitHubCode\newgooglecloudmodules\tf-mod-gcp-spanner-instance
terraform init -backend=false
terraform validate
terraform fmt -checkPin ?ref=v1.0.0 when consuming this module — never a branch. This library is plan-only; a human
applies from CI with valid Workload Identity Federation or ADC credentials.
terraform init -backend=false,terraform validate, andterraform fmt -checkare the entire offline proof gate for this module — all three pass cleanly as of this authoring session, against the actually-resolvedhashicorp/googleprovider version 7.39.0.validate/fmtconfirm internal type/reference consistency and canonical formatting only. Neither can catch GCP API-level rejections (quota, org policy, an invalidconfigorversion_retention_periodrange, IAM propagation) — those surface only atapplytime, against a real project, from a human-run CI pipeline with valid credentials.- The
examples/directory exists so a consuming GitHub Actions workflow can run a realterraform planagainst a real project as part of that pipeline's own review gate; this library only guarantees the example is syntactically and structurally sound in isolation.
$ terraform output
id = "casey-prod-data/casey-prod-spanner"
name = "casey-prod-spanner"
database_ids = {
"app-primary-db" = "casey-prod-spanner/app-primary-db"
}
database_names = {
"app-primary-db" = "app-primary-db"
}
| Symptom | Cause | Fix |
|---|---|---|
terraform apply shows a database recreation plan you did not expect after editing ddl |
A prior ddl list entry was edited or removed instead of only appending new statements — the provider marks the resource for recreation on any change to an existing statement |
Revert the edited/removed statement; add new schema changes as new, appended ddl list entries only |
terraform destroy/apply fails with a drop-protection or deletion-protection error on a database |
enable_drop_protection and/or deletion_protection is still true (this module's default) |
Set the relevant guard(s) to false explicitly in a prior apply, then retry the destroy |
terraform destroy on the instance fails even though the instance's own deletion_policy is not "PREVENT" |
A database attached to the instance still has enable_drop_protection = true — this guard also blocks parent-instance deletion |
Set enable_drop_protection = false on every database attached to the instance first, apply, then destroy the instance |
Error: Exactly one of num_nodes, processing_units, or autoscaling_config must be set... at plan time |
More than one (or none) of the three capacity variables was supplied, and instance_type is not "FREE_INSTANCE" |
Set exactly one of the three; leave the other two at their default null |
Error: edition must not be set when instance_type = "FREE_INSTANCE" |
edition and instance_type = "FREE_INSTANCE" were set together |
Remove edition for a free-tier instance |
terraform plan shows no error but apply fails with an invalid config (instance configuration) value |
This library is plan-only — config is deliberately not validated against a hardcoded allow-list, and org policy (constraints/gcp.resourceLocations) is invisible to plan |
Confirm the config value against the current Cloud Spanner configurations reference and the org's constraints/gcp.resourceLocations policy before apply |
apply fails with a quota-exceeded error despite a clean plan |
Cloud Spanner enforces per-project, per-instance-config node/processing-unit quotas invisible to terraform plan |
Verify current quota in the target project/instance config before increasing num_nodes/processing_units/autoscaling_config limits |
google_spanner_instanceprovider referencegoogle_spanner_databaseprovider reference- Sibling modules:
tf-mod-gcp-kms-keyring,tf-mod-gcp-cloud-sql-instance,tf-mod-gcp-bigquery-dataset,tf-mod-gcp-pubsub-topic,tf-mod-gcp-bigtable-instance - This module's
SCOPE.md
💙 "Infrastructure as Code should be standardized, consistent, and secure."