Google Cloud · Terraform example
Terraform Example: Cloud Run with Cloud SQL PostgreSQL on Google Cloud
A complete Terraform example for Google Cloud: a Cloud Run service connected to Cloud SQL PostgreSQL, with the database password in Secret Manager and a dedicated service account.
Updated Nov 2026
This example deploys a containerized app to Google Cloud Run, connected to a Cloud SQL PostgreSQL database. Cloud Run scales from zero to ten instances with traffic, and the database password never appears in the service definition.
What it creates
| Resource | Purpose |
|---|---|
google_project_service | Turns on the Cloud Run, Cloud SQL Admin, and Secret Manager APIs |
google_sql_database_instance | PostgreSQL 16 with backups, point-in-time recovery, and deletion protection |
google_sql_database, google_sql_user | The app's database and user |
random_password | Generates the database password |
google_secret_manager_secret and version | Stores the password |
google_service_account and IAM bindings | The service's own identity, with access to Cloud SQL and this one secret |
google_cloud_run_v2_service | The app, with the database socket mounted and the password injected from Secret Manager |
google_cloud_run_v2_service_iam_member | Allows public access to the service |
The Terraform
Save as main.tf. It works unchanged with OpenTofu.
terraform {
required_version = ">= 1.5"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
provider "google" {
project = var.project_id
region = var.region
}
variable "project_id" {
type = string
}
variable "region" {
type = string
default = "us-central1"
}
variable "name" {
type = string
default = "web"
}
variable "image" {
type = string
description = "Container image in Artifact Registry."
}
resource "google_project_service" "apis" {
for_each = toset(["run.googleapis.com", "sqladmin.googleapis.com", "secretmanager.googleapis.com"])
service = each.value
disable_on_destroy = false
}
resource "google_sql_database_instance" "db" {
name = "${var.name}-db"
database_version = "POSTGRES_16"
region = var.region
settings {
tier = "db-custom-1-3840"
backup_configuration {
enabled = true
point_in_time_recovery_enabled = true
}
}
deletion_protection = true
depends_on = [google_project_service.apis]
}
resource "google_sql_database" "app" {
name = "app"
instance = google_sql_database_instance.db.name
}
resource "random_password" "db" {
length = 32
special = false
}
resource "google_sql_user" "app" {
name = "app"
instance = google_sql_database_instance.db.name
password = random_password.db.result
}
# Store the password in Secret Manager, not in the service definition.
resource "google_secret_manager_secret" "db_password" {
secret_id = "${var.name}-db-password"
replication {
auto {}
}
depends_on = [google_project_service.apis]
}
resource "google_secret_manager_secret_version" "db_password" {
secret = google_secret_manager_secret.db_password.id
secret_data = random_password.db.result
}
# Dedicated identity for the service.
resource "google_service_account" "run" {
account_id = "${var.name}-run"
}
resource "google_project_iam_member" "sql_client" {
project = var.project_id
role = "roles/cloudsql.client"
member = "serviceAccount:${google_service_account.run.email}"
}
resource "google_secret_manager_secret_iam_member" "db_password" {
secret_id = google_secret_manager_secret.db_password.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.run.email}"
}
resource "google_cloud_run_v2_service" "web" {
name = var.name
location = var.region
template {
service_account = google_service_account.run.email
scaling {
min_instance_count = 0
max_instance_count = 10
}
volumes {
name = "cloudsql"
cloud_sql_instance {
instances = [google_sql_database_instance.db.connection_name]
}
}
containers {
image = var.image
env {
name = "DB_HOST"
value = "/cloudsql/${google_sql_database_instance.db.connection_name}"
}
env {
name = "DB_NAME"
value = google_sql_database.app.name
}
env {
name = "DB_USER"
value = google_sql_user.app.name
}
env {
name = "DB_PASSWORD"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.db_password.secret_id
version = "latest"
}
}
}
volume_mounts {
name = "cloudsql"
mount_path = "/cloudsql"
}
}
}
depends_on = [
google_project_iam_member.sql_client,
google_secret_manager_secret_iam_member.db_password,
google_secret_manager_secret_version.db_password,
]
}
# Public website: allow unauthenticated requests.
resource "google_cloud_run_v2_service_iam_member" "public" {
name = google_cloud_run_v2_service.web.name
location = google_cloud_run_v2_service.web.location
role = "roles/run.invoker"
member = "allUsers"
}
output "url" {
value = google_cloud_run_v2_service.web.uri
}
Deploy it
terraform init
terraform plan \
-var="project_id=my-project" \
-var="image=us-central1-docker.pkg.dev/my-project/app/web:1.0.0" \
-out=tfplan
terraform apply tfplan
curl "$(terraform output -raw url)"
Creating the Cloud SQL instance takes several minutes. Your container must listen on the port in the PORT environment variable (8080 by default).
Choices this example makes
- A dedicated service account. Cloud Run otherwise uses the default compute service account, which usually has broad project permissions.
- Secret Manager for the password. The service reads it at startup, and only this service account can access that one secret.
- Deletion protection on the database.
terraform destroyfails until you setdeletion_protection = false. That is deliberate. - Scale to zero. No instances run when there is no traffic. Set
min_instance_count = 1if cold starts matter. - Public service. The last resource lets anyone call the service. Remove it for internal services and grant
roles/run.invokerto specific callers instead.
Cost
Cloud SQL is the main fixed cost, because the instance runs all the time. Cloud Run charges for request time and has a free tier, so a quiet service costs little. Check current pricing for the instance tier and region you choose.
Before production
- Use a private IP for Cloud SQL and a Serverless VPC connector or Direct VPC egress if your policy requires no public database IP.
- Map a custom domain with a load balancer or Cloud Run domain mapping.
- Store state in a GCS bucket with versioning and restricted access, since it contains the password.
- Review every plan that touches the database. A change to some settings replaces the instance. Approval workflows can require a second reviewer for those plans.
