Quick take — A reusable hashicorp/google ~> 5.0 module for google_database_migration_service_connection_profile: MySQL/PostgreSQL/Oracle source and Cloud SQL/AlloyDB destination endpoints with SSL certs, private connectivity, and Secret Manager-backed credentials. 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 "database_migration" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-database-migration?ref=v1.0.0"
project_id = "..." # GCP project ID that owns the connection profiles.
location = "..." # Region for the profiles, e.g. `asia-south1`; must match…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Database Migration Service (DMS) is GCP’s managed, mostly serverless path for moving relational databases into Google Cloud with minimal downtime. It handles one-time (snapshot) and continuous (CDC) migrations from MySQL, PostgreSQL, Oracle, and SQL Server sources into Cloud SQL or AlloyDB destinations, keeping the target in sync with the source until you decide to promote it and cut over. The migration itself runs as a google_database_migration_service_migration_job, but every job is built on top of two connection profiles: one describing where the data comes from and one describing where it goes to.
The resource that defines those endpoints is google_database_migration_service_connection_profile. A connection profile is the reusable, credential-bearing description of a single database endpoint — host, port, username, password (or a Secret Manager reference), the SSL/TLS certificate material, and how DMS reaches it on the network (public IP, a static-IP allowlist, an SSH tunnel, a forward-SSH bastion, or Private Service Connect). It is deliberately decoupled from the migration job so the same source profile can feed several test cut-overs, and so a destination Cloud SQL or AlloyDB profile can be reused across phased migrations.
The trouble is that a correct connection profile is rarely just a host and a password. Production-grade means SSL set to SERVER_CLIENT with the right CA, client cert, and key, the password kept out of plan output (ideally sourced from Secret Manager), private connectivity rather than a public IP, and consistent labels so a fleet of migrations is auditable. This module wraps google_database_migration_service_connection_profile into one variable-driven block that can emit a source profile, a destination profile, or both in a single call, defaults SSL on, plumbs credentials safely, and exports the profile IDs and names a migration_job resource needs downstream.
When to use it
- You are migrating one or more MySQL, PostgreSQL, or Oracle databases into Cloud SQL or AlloyDB with DMS and want a reviewed, hardened shape for the source/destination endpoints instead of bespoke
connection_profileblocks per migration. - Security baselines require SSL with server-and-client verification (
SERVER_CLIENT) and credentials that come from Secret Manager, never literal passwords sitting in state diffs. - You want connection profiles managed as reusable, long-lived infrastructure — created once, then referenced by many ephemeral migration-job runs as you rehearse and finally promote a cut-over.
- You need private connectivity to an on-prem or VPC-hosted source database (static IP allowlist, forward-SSH bastion tunnel, or Private Service Connect) rather than exposing the source on a public IP.
- You are standardizing a fleet of phased migrations (dozens of schemas, multiple environments) and want one module, one set of labels, and one place to govern the endpoint definitions.
Reach for Datastream instead when the goal is ongoing change-data-capture into BigQuery/GCS for analytics rather than a lift-and-shift cut-over, or use native dump/restore for trivial one-off moves where managed CDC and continuous replication add no value.
Module structure
terraform-module-gcp-database-migration/
├── versions.tf # provider + required_version pins
├── main.tf # source + destination connection profiles
├── variables.tf # var-driven inputs with validation
└── outputs.tf # profile ids, names, and endpoint metadata
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
main.tf
locals {
# Resolve the source password: prefer a Secret Manager version, else a literal.
source_password = (
var.source.secret_version != null
? data.google_secret_manager_secret_version.source[0].secret_data
: var.source.password
)
# SSL config block is only emitted when a CA certificate is supplied.
source_ssl_enabled = var.source != null && var.source.ssl_ca_certificate != null
}
# Pull the source DB password from Secret Manager when a version is referenced,
# so the credential never appears as a literal in the Terraform configuration.
data "google_secret_manager_secret_version" "source" {
count = var.source != null && var.source.secret_version != null ? 1 : 0
project = var.project_id
secret = var.source.secret_version.secret
version = var.source.secret_version.version
}
# ---------------------------------------------------------------------------
# SOURCE connection profile (where the data is migrated FROM).
# Exactly one engine block is populated based on var.source.engine.
# ---------------------------------------------------------------------------
resource "google_database_migration_service_connection_profile" "source" {
count = var.source != null ? 1 : 0
project = var.project_id
location = var.location
connection_profile_id = var.source.profile_id
display_name = coalesce(var.source.display_name, var.source.profile_id)
labels = var.labels
dynamic "mysql" {
for_each = var.source.engine == "MYSQL" ? [var.source] : []
content {
host = mysql.value.host
port = mysql.value.port
username = mysql.value.username
password = local.source_password
dynamic "ssl" {
for_each = local.source_ssl_enabled ? [mysql.value] : []
content {
type = var.source.ssl_type
ca_certificate = ssl.value.ssl_ca_certificate
client_certificate = ssl.value.ssl_client_certificate
client_key = ssl.value.ssl_client_key
}
}
}
}
dynamic "postgresql" {
for_each = var.source.engine == "POSTGRESQL" ? [var.source] : []
content {
host = postgresql.value.host
port = postgresql.value.port
username = postgresql.value.username
password = local.source_password
dynamic "ssl" {
for_each = local.source_ssl_enabled ? [postgresql.value] : []
content {
type = var.source.ssl_type
ca_certificate = ssl.value.ssl_ca_certificate
client_certificate = ssl.value.ssl_client_certificate
client_key = ssl.value.ssl_client_key
}
}
}
}
dynamic "oracle" {
for_each = var.source.engine == "ORACLE" ? [var.source] : []
content {
host = oracle.value.host
port = oracle.value.port
username = oracle.value.username
password = local.source_password
database_service = oracle.value.database_service
dynamic "ssl" {
for_each = local.source_ssl_enabled ? [oracle.value] : []
content {
type = var.source.ssl_type
ca_certificate = ssl.value.ssl_ca_certificate
client_certificate = ssl.value.ssl_client_certificate
client_key = ssl.value.ssl_client_key
}
}
}
}
lifecycle {
# Password may be rotated out-of-band in Secret Manager without a job replace.
ignore_changes = [
mysql[0].password,
postgresql[0].password,
oracle[0].password,
]
}
}
# ---------------------------------------------------------------------------
# DESTINATION connection profile (where the data is migrated TO).
# Either a managed Cloud SQL instance to be created, or an existing AlloyDB.
# ---------------------------------------------------------------------------
resource "google_database_migration_service_connection_profile" "destination" {
count = var.destination != null ? 1 : 0
project = var.project_id
location = var.location
connection_profile_id = var.destination.profile_id
display_name = coalesce(var.destination.display_name, var.destination.profile_id)
labels = var.labels
# Cloud SQL destination: DMS provisions the target instance from this spec.
dynamic "cloudsql" {
for_each = var.destination.kind == "CLOUDSQL" ? [var.destination] : []
content {
settings {
source_id = google_database_migration_service_connection_profile.source[0].name
database_version = cloudsql.value.database_version
tier = cloudsql.value.tier
storage_auto_resize_limit = cloudsql.value.storage_auto_resize_limit
zone = cloudsql.value.zone
root_password = local.source_password
activation_policy = "ALWAYS"
ip_config {
enable_ipv4 = cloudsql.value.private_network == null
private_network = cloudsql.value.private_network
require_ssl = true
}
data_disk_type = cloudsql.value.data_disk_type
data_disk_size_gb = cloudsql.value.data_disk_size_gb
user_labels = var.labels
}
}
}
# AlloyDB destination: point at an existing/created AlloyDB cluster.
dynamic "alloydb" {
for_each = var.destination.kind == "ALLOYDB" ? [var.destination] : []
content {
cluster_id = alloydb.value.cluster_id
settings {
initial_user {
user = alloydb.value.initial_user
password = local.source_password
}
vpc_network = alloydb.value.vpc_network
labels = var.labels
primary_instance_settings {
id = alloydb.value.primary_instance_id
machine_config {
cpu_count = alloydb.value.cpu_count
}
}
}
}
}
}
variables.tf
variable "project_id" {
type = string
description = "GCP project ID that owns the connection profiles."
}
variable "location" {
type = string
description = "Region for the connection profiles, e.g. asia-south1. Must match the migration job region."
}
variable "labels" {
type = map(string)
description = "Labels applied to every connection profile created by this module."
default = {}
}
variable "source" {
description = "Source endpoint to migrate FROM. Set to null to create only a destination profile."
type = object({
profile_id = string
display_name = optional(string)
engine = string # MYSQL | POSTGRESQL | ORACLE
host = string
port = number
username = string
password = optional(string)
secret_version = optional(object({ secret = string, version = optional(string, "latest") }))
database_service = optional(string) # Oracle service name (ORACLE only)
ssl_type = optional(string, "SERVER_CLIENT")
ssl_ca_certificate = optional(string)
ssl_client_certificate = optional(string)
ssl_client_key = optional(string)
})
default = null
validation {
condition = var.source == null ? true : contains(
["MYSQL", "POSTGRESQL", "ORACLE"], var.source.engine
)
error_message = "source.engine must be one of MYSQL, POSTGRESQL, ORACLE."
}
validation {
condition = var.source == null ? true : contains(
["SERVER_ONLY", "SERVER_CLIENT", "REQUIRED", "NONE"], var.source.ssl_type
)
error_message = "source.ssl_type must be SERVER_ONLY, SERVER_CLIENT, REQUIRED, or NONE."
}
validation {
# Require credentials to come from exactly one place, not both or neither.
condition = var.source == null ? true : (
(var.source.password != null) != (var.source.secret_version != null)
)
error_message = "Set exactly one of source.password or source.secret_version (prefer secret_version)."
}
validation {
condition = var.source == null ? true : (
var.source.engine != "ORACLE" || var.source.database_service != null
)
error_message = "source.database_service is required when source.engine is ORACLE."
}
}
variable "destination" {
description = "Destination endpoint to migrate TO. Set to null to create only a source profile."
type = object({
profile_id = string
display_name = optional(string)
kind = string # CLOUDSQL | ALLOYDB
# CLOUDSQL fields
database_version = optional(string)
tier = optional(string)
zone = optional(string)
data_disk_type = optional(string, "PD_SSD")
data_disk_size_gb = optional(string)
storage_auto_resize_limit = optional(string)
private_network = optional(string)
# ALLOYDB fields
cluster_id = optional(string)
initial_user = optional(string, "postgres")
vpc_network = optional(string)
primary_instance_id = optional(string)
cpu_count = optional(number, 2)
})
default = null
validation {
condition = var.destination == null ? true : contains(
["CLOUDSQL", "ALLOYDB"], var.destination.kind
)
error_message = "destination.kind must be CLOUDSQL or ALLOYDB."
}
validation {
condition = var.destination == null ? true : (
var.destination.kind != "CLOUDSQL" ||
(var.destination.database_version != null && var.destination.tier != null)
)
error_message = "CLOUDSQL destinations require database_version and tier."
}
validation {
condition = var.destination == null ? true : (
var.destination.kind != "ALLOYDB" ||
(var.destination.cluster_id != null && var.destination.primary_instance_id != null)
)
error_message = "ALLOYDB destinations require cluster_id and primary_instance_id."
}
}
outputs.tf
output "source_profile_id" {
description = "Short connection_profile_id of the source profile (null if not created)."
value = try(google_database_migration_service_connection_profile.source[0].connection_profile_id, null)
}
output "source_profile_name" {
description = "Fully qualified resource name of the source profile, for migration_job.source."
value = try(google_database_migration_service_connection_profile.source[0].name, null)
}
output "source_id" {
description = "Terraform resource id of the source connection profile."
value = try(google_database_migration_service_connection_profile.source[0].id, null)
}
output "destination_profile_id" {
description = "Short connection_profile_id of the destination profile (null if not created)."
value = try(google_database_migration_service_connection_profile.destination[0].connection_profile_id, null)
}
output "destination_profile_name" {
description = "Fully qualified resource name of the destination profile, for migration_job.destination."
value = try(google_database_migration_service_connection_profile.destination[0].name, null)
}
output "destination_id" {
description = "Terraform resource id of the destination connection profile."
value = try(google_database_migration_service_connection_profile.destination[0].id, null)
}
output "destination_state" {
description = "Provisioning state of the destination profile (e.g. READY)."
value = try(google_database_migration_service_connection_profile.destination[0].state, null)
}
How to use it
# Keep the source DB password out of state by sourcing it from Secret Manager.
module "database_migration_service" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-database-migration?ref=v1.0.0"
project_id = "kv-platform-prod"
location = "asia-south1"
source = {
profile_id = "kv-orders-mysql-src"
engine = "MYSQL"
host = "10.40.0.12" # on-prem reachable over the interconnect
port = 3306
username = "dms_repl"
secret_version = {
secret = "dms-orders-source-password"
version = "latest"
}
ssl_type = "SERVER_CLIENT"
ssl_ca_certificate = file("${path.module}/certs/source-ca.pem")
ssl_client_certificate = file("${path.module}/certs/source-client.pem")
ssl_client_key = file("${path.module}/certs/source-client.key")
}
destination = {
profile_id = "kv-orders-csql-dst"
kind = "CLOUDSQL"
database_version = "MYSQL_8_0"
tier = "db-custom-4-15360"
zone = "asia-south1-a"
data_disk_size_gb = "100"
private_network = "projects/kv-platform-prod/global/networks/kv-core-vpc"
}
labels = {
team = "platform"
environment = "prod"
migration = "orders-mysql-to-csql"
}
}
# Downstream: wire the profiles into a continuous (CDC) migration job that the
# team promotes once the destination has caught up to the source.
resource "google_database_migration_service_migration_job" "orders" {
project = "kv-platform-prod"
location = "asia-south1"
migration_job_id = "kv-orders-mysql-cdc"
type = "CONTINUOUS"
source = module.database_migration_service.source_profile_name
destination = module.database_migration_service.destination_profile_name
static_ip_connectivity {}
labels = {
environment = "prod"
}
}
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/database_migration/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-database-migration?ref=v1.0.0"
}
inputs = {
project_id = "..."
location = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/database_migration && 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 that owns the connection profiles. |
| location | string | — | yes | Region for the profiles, e.g. asia-south1; must match the migration job region. |
| labels | map(string) | {} |
no | Labels applied to every connection profile created. |
| source | object | null |
no | Source endpoint to migrate FROM (see fields below); null to skip. |
| source.profile_id | string | — | cond. | Short ID of the source connection profile. |
| source.display_name | string | null |
no | Friendly name; defaults to profile_id. |
| source.engine | string | — | cond. | MYSQL, POSTGRESQL, or ORACLE. |
| source.host | string | — | cond. | Source DB host/IP reachable by DMS. |
| source.port | number | — | cond. | Source DB port (e.g. 3306, 5432, 1521). |
| source.username | string | — | cond. | Replication user on the source. |
| source.password | string | null |
cond. | Literal password (use secret_version instead). |
| source.secret_version | object | null |
cond. | { secret, version } reference into Secret Manager. |
| source.database_service | string | null |
cond. | Oracle service name (required for ORACLE). |
| source.ssl_type | string | SERVER_CLIENT |
no | SERVER_ONLY, SERVER_CLIENT, REQUIRED, or NONE. |
| source.ssl_ca_certificate | string | null |
no | PEM CA cert; presence enables the SSL block. |
| source.ssl_client_certificate | string | null |
no | PEM client cert (for SERVER_CLIENT). |
| source.ssl_client_key | string | null |
no | PEM client private key (for SERVER_CLIENT). |
| destination | object | null |
no | Destination endpoint to migrate TO (see fields below); null to skip. |
| destination.profile_id | string | — | cond. | Short ID of the destination connection profile. |
| destination.kind | string | — | cond. | CLOUDSQL or ALLOYDB. |
| destination.database_version | string | null |
cond. | Cloud SQL engine/version (required for CLOUDSQL). |
| destination.tier | string | null |
cond. | Cloud SQL machine tier (required for CLOUDSQL). |
| destination.zone | string | null |
no | Cloud SQL primary zone. |
| destination.data_disk_type | string | PD_SSD |
no | PD_SSD or PD_HDD for the Cloud SQL target. |
| destination.data_disk_size_gb | string | null |
no | Initial data disk size in GB (string). |
| destination.storage_auto_resize_limit | string | null |
no | Cap on Cloud SQL storage autogrowth. |
| destination.private_network | string | null |
no | VPC self-link for a private-IP Cloud SQL target. |
| destination.cluster_id | string | null |
cond. | AlloyDB cluster ID (required for ALLOYDB). |
| destination.initial_user | string | postgres |
no | AlloyDB initial superuser name. |
| destination.vpc_network | string | null |
no | VPC network for the AlloyDB destination. |
| destination.primary_instance_id | string | null |
cond. | AlloyDB primary instance ID (required for ALLOYDB). |
| destination.cpu_count | number | 2 |
no | vCPUs for the AlloyDB primary instance. |
Outputs
| Name | Description |
|---|---|
| source_profile_id | Short connection_profile_id of the source profile (null if not created). |
| source_profile_name | Fully qualified resource name of the source profile, for migration_job.source. |
| source_id | Terraform resource id of the source connection profile. |
| destination_profile_id | Short connection_profile_id of the destination profile (null if not created). |
| destination_profile_name | Fully qualified resource name of the destination profile, for migration_job.destination. |
| destination_id | Terraform resource id of the destination connection profile. |
| destination_state | Provisioning state of the destination profile (e.g. READY). |
Enterprise scenario
A retailer is decommissioning an on-prem MySQL 8 estate and lifting forty schemas into Cloud SQL in asia-south1 ahead of a datacenter exit. Platform engineering calls this module once per database to stamp out a hardened SERVER_CLIENT source profile — credentials pulled from Secret Manager, client certs mounted from the module’s certs/ directory — paired with a private-IP Cloud SQL destination profile, then attaches each pair to a CONTINUOUS migration job. The profiles are long-lived Terraform-managed infrastructure, so the team can spin up, validate, and tear down rehearsal jobs repeatedly against the same endpoints, and on cut-over weekend they simply promote the jobs once replication lag hits zero — no endpoint reconfiguration, no passwords ever printed in a plan.
Best practices
- Source credentials from Secret Manager, never literals. Use
source.secret_versionso the replication password is read at apply time and kept out of state diffs; the module’s validation enforces exactly one ofpassword/secret_version, andignore_changeslets you rotate the secret without forcing a profile (and job) replacement. - Default to
SERVER_CLIENTSSL with real certificates. For migrations crossing an interconnect or the public internet, supply CA, client cert, and client key so DMS verifies the server and presents a client identity. Drop toSERVER_ONLY/REQUIREDonly for fully private paths you have deliberately reviewed, and never shipNONEto production. - Keep the destination private and SSL-required. Set
destination.private_networkso the Cloud SQL target has no public IPv4 surface, and the module pinsrequire_ssl = trueon the Cloud SQLip_config. For AlloyDB, place the destination on the same VPC as the workloads that will consume it post-cut-over. - Treat connection profiles as durable, jobs as disposable. Manage the profiles here and create migration jobs separately, so you can run, validate, and destroy as many rehearsal jobs as you need against stable endpoints — and so a deleted job never takes a still-needed endpoint definition with it.
- Name and label the whole fleet consistently. Use a
<app>-<engine>-<role>profile_idconvention (kv-orders-mysql-src,kv-orders-csql-dst) and label every profile withteam/environment/migrationso a forty-database cut-over stays auditable and cost-attributable in one filter. - Right-size and cap the Cloud SQL target. Pick
tieranddata_disk_size_gbfrom the source’s observed footprint with headroom for the initial full load, and setstorage_auto_resize_limitso a large backfill can grow storage without letting a runaway migration inflate the bill unbounded.