IaC GCP

Terraform Module: GCP Database Migration Service — reusable source and destination connection profiles

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

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 configlive/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 configlive/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

TerraformGCPDatabase Migration ServiceModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading