Deploy Marmot to Google Cloud Run

Marmot ships as a single Go binary and needs nothing but Postgres to run. That suits serverless well, where you pay only while the app is serving traffic. This post walks through deploying it on Google Cloud Run with a managed Cloud SQL for PostgreSQL database, defined in Terraform. The result scales to zero when idle, no Kubernetes, no sidecars, and no other magic to manage.
See Marmot in Action
Explore the interface and features with the interactive demo. No installation required.
Try Live DemoWhat we're building
Two managed services, a private network, and the wiring between them:
- Cloud Run runs the
ghcr.io/marmotdata/marmotcontainer. It scales to zero, so you only pay when someone is actually using the catalog. - Cloud SQL for PostgreSQL stores everything. Marmot needs PostgreSQL 14 or later. It has no public IP and sits on a private network.
- Secret Manager holds the database password and the encryption key, injected into the container at runtime. Both are generated by Terraform ephemeral resources and written to Secret Manager with write-only arguments, so neither ever lands in Terraform state.
Cloud Run and Cloud SQL talk over a fully private path. The database has no public IP at all; Cloud Run joins the same VPC with Direct VPC egress and connects straight to its private IP.
Prerequisites
- A Google Cloud project with billing enabled
- Terraform 1.11+ (for ephemeral resources and write-only arguments)
gcloudauthenticated locally:
gcloud auth application-default login
Providers
We use the google provider for the infrastructure and random provider to generate the database password and encryption key, so no secrets are ever written by hand. Set your project here, and the region on the resources below.
# versions.tf
terraform {
required_version = ">= 1.11"
required_providers {
google = {
source = "hashicorp/google"
version = ">= 6.14"
}
random = {
source = "hashicorp/random"
version = ">= 3.7"
}
}
}
provider "google" {
project = "my-project"
}
Enable the APIs
# apis.tf
locals {
services = [
"run.googleapis.com",
"sqladmin.googleapis.com",
"secretmanager.googleapis.com",
"compute.googleapis.com",
"servicenetworking.googleapis.com",
"artifactregistry.googleapis.com",
]
}
resource "google_project_service" "marmot_apis" {
for_each = toset(local.services)
service = each.value
disable_on_destroy = false
}
Private networking
The database has no public IP, so it lives on a private VPC. We create the network, a subnet for Cloud Run's Direct VPC egress, and a private services access range that Cloud SQL's managed service peers into.
# network.tf
resource "google_compute_network" "marmot" {
name = "marmot"
auto_create_subnetworks = false
depends_on = [google_project_service.marmot_apis]
}
resource "google_compute_subnetwork" "marmot" {
name = "marmot"
region = "europe-west1"
network = google_compute_network.marmot.id
ip_cidr_range = "10.0.0.0/24"
}
# Reserved range that Cloud SQL peers into for private connectivity.
resource "google_compute_global_address" "marmot_psa" {
name = "marmot-psa-range"
purpose = "VPC_PEERING"
address_type = "INTERNAL"
prefix_length = 16
network = google_compute_network.marmot.id
}
resource "google_service_networking_connection" "marmot" {
network = google_compute_network.marmot.id
service = "servicenetworking.googleapis.com"
reserved_peering_ranges = [google_compute_global_address.marmot_psa.name]
}
The Cloud SQL database
A small Postgres instance with a single database and user.
# database.tf
resource "google_sql_database_instance" "marmot" {
name = "marmot"
database_version = "POSTGRES_16"
region = "europe-west1"
# Set to true once you're past experimenting.
deletion_protection = false
settings {
edition = "ENTERPRISE"
tier = "db-f1-micro"
ip_configuration {
ipv4_enabled = false
private_network = google_compute_network.marmot.id
ssl_mode = "ENCRYPTED_ONLY"
}
}
depends_on = [
google_project_service.marmot_apis,
google_service_networking_connection.marmot,
]
}
resource "google_sql_database" "marmot" {
name = "marmot"
instance = google_sql_database_instance.marmot.name
}
# Read the password back from Secret Manager, ephemeral (never in state).
ephemeral "google_secret_manager_secret_version" "marmot_db_password" {
secret = google_secret_manager_secret.marmot_db_password.id
version = "latest"
depends_on = [google_secret_manager_secret_version.marmot_db_password]
}
resource "google_sql_user" "marmot" {
name = "marmot"
instance = google_sql_database_instance.marmot.name
password_wo = ephemeral.google_secret_manager_secret_version.marmot_db_password.secret_data
password_wo_version = local.marmot_db_password_version
}
Secrets
Both secrets are generated by ephemeral resources and passed to Cloud Run through Secret Manager without ever landing in Terraform state. Their values are written to the secret versions with the write-only secret_data_wo argument, so neither the database password nor the encryption key is stored in the plan or state.
# secrets.tf
locals {
marmot_db_password_version = 1 # bump to rotate
marmot_encryption_key_version = 1 # locked by ignore_changes
}
ephemeral "random_password" "marmot_db" {
length = 32
special = false
}
resource "google_secret_manager_secret" "marmot_db_password" {
secret_id = "marmot-db-password"
replication {
user_managed {
replicas {
location = "europe-west1"
}
}
}
}
resource "google_secret_manager_secret_version" "marmot_db_password" {
secret = google_secret_manager_secret.marmot_db_password.id
secret_data_wo = ephemeral.random_password.marmot_db.result
secret_data_wo_version = local.marmot_db_password_version
}
# The encryption key must be base64(32 random bytes) to match
# `marmot generate-encryption-key`, which is exactly what .base64 gives us.
ephemeral "random_bytes" "marmot_encryption_key" {
length = 32
}
resource "google_secret_manager_secret" "marmot_encryption_key" {
secret_id = "marmot-encryption-key"
replication {
user_managed {
replicas {
location = "europe-west1"
}
}
}
lifecycle {
prevent_destroy = true
}
}
resource "google_secret_manager_secret_version" "marmot_encryption_key" {
secret = google_secret_manager_secret.marmot_encryption_key.id
secret_data_wo = ephemeral.random_bytes.marmot_encryption_key.base64
secret_data_wo_version = local.marmot_encryption_key_version
# Rotating the key breaks every existing encrypted credential,
# so a version bump is ignored.
lifecycle {
ignore_changes = [secret_data_wo_version]
}
}
Secret rotation is straightforward. The _wo values are write-only, so Terraform never stores them and only re-reads them when the matching _wo_version changes. Since the SQL user reads its password from the secret, bumping local.marmot_db_password_version rotates the database and Cloud Run together from one source of truth. The encryption key is the exception. A new key makes every already-encrypted credential unreadable, so we lock it down: prevent_destroy keeps a terraform destroy from deleting it, and ignore_changes on its version makes a bumped local a no-op. Rotating it is then a deliberate two-step (remove the ignore_changes block, then bump) rather than something a routine apply can do by accident. See Terraform, Google Cloud, and secrets for more.
The key lives only in Secret Manager. It never touches Terraform state, so Terraform can't regenerate it, and losing the secret means losing every credential encrypted with it. prevent_destroy guards against an accidental delete, but for real safety read the value once with gcloud secrets versions access latest --secret=marmot-encryption-key and keep an offline copy.
Service account and permissions
We create a marmot service account for the Cloud Run service to run as, and give it read access to the two secrets so the container can pull the database password and encryption key at startup.
# iam.tf
resource "google_service_account" "marmot" {
account_id = "marmot"
display_name = "Marmot Cloud Run service"
}
resource "google_secret_manager_secret_iam_member" "marmot_encryption_key" {
secret_id = google_secret_manager_secret.marmot_encryption_key.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.marmot.email}"
}
resource "google_secret_manager_secret_iam_member" "marmot_db_password" {
secret_id = google_secret_manager_secret.marmot_db_password.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.marmot.email}"
}
These permissions only get Marmot running. The plugins you enable authenticate as this same service account through Application Default Credentials, so when you enable one for GCS or BigQuery, give this service account (or a service account it impersonates) the IAM permissions that plugin needs.
Mirroring the container image
Cloud Run can only pull from Artifact Registry, Container Registry, or Docker Hub. Marmot is published to GitHub Container Registry (ghcr.io), which Cloud Run won't pull from directly, so we add an Artifact Registry remote repository that proxies and caches it.
# registry.tf
resource "google_artifact_registry_repository" "marmot_ghcr" {
location = "europe-west1"
repository_id = "ghcr-remote"
format = "DOCKER"
mode = "REMOTE_REPOSITORY"
remote_repository_config {
description = "Remote mirror of ghcr.io"
common_repository {
uri = "https://ghcr.io"
}
}
depends_on = [google_project_service.marmot_apis]
}
Because the repository lives in the same project, Cloud Run's service agent can pull through it without any extra IAM.
The Cloud Run service
With everything else in place, here's the service itself. The vpc_access block gives the service Direct VPC egress so it can reach the database on its private IP, and the database password and encryption key come in as secret references.
# run.tf
resource "google_cloud_run_v2_service" "marmot" {
name = "marmot"
location = "europe-west1"
ingress = "INGRESS_TRAFFIC_ALL"
deletion_protection = false
template {
service_account = google_service_account.marmot.email
scaling {
min_instance_count = 0
max_instance_count = 2
}
vpc_access {
egress = "PRIVATE_RANGES_ONLY"
network_interfaces {
network = google_compute_network.marmot.id
subnetwork = google_compute_subnetwork.marmot.id
}
}
containers {
image = "${google_artifact_registry_repository.marmot_ghcr.registry_uri}/marmotdata/marmot:latest"
ports {
container_port = 8080
}
env {
name = "MARMOT_DATABASE_HOST"
value = google_sql_database_instance.marmot.private_ip_address
}
env {
name = "MARMOT_DATABASE_PORT"
value = "5432"
}
env {
name = "MARMOT_DATABASE_USER"
value = google_sql_user.marmot.name
}
env {
name = "MARMOT_DATABASE_NAME"
value = google_sql_database.marmot.name
}
env {
name = "MARMOT_DATABASE_SSLMODE"
value = "require"
}
env {
name = "MARMOT_DATABASE_PASSWORD"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.marmot_db_password.secret_id
version = "latest"
}
}
}
env {
name = "MARMOT_SERVER_ENCRYPTION_KEY"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.marmot_encryption_key.secret_id
version = "latest"
}
}
}
}
}
depends_on = [
google_secret_manager_secret_iam_member.marmot_db_password,
google_secret_manager_secret_iam_member.marmot_encryption_key,
google_sql_user.marmot,
]
}
Finally, make the service reachable. For a quick start we allow public access. However, for a production deployment lock this down with authentication or IAM before putting real data behind it.
resource "google_cloud_run_v2_service_iam_member" "marmot_public" {
name = google_cloud_run_v2_service.marmot.name
location = google_cloud_run_v2_service.marmot.location
role = "roles/run.invoker"
member = "allUsers"
}
output "marmot_url" {
value = google_cloud_run_v2_service.marmot.uri
}
Deploy
terraform init
terraform apply
Terraform prints the service URL when it finishes:
marmot_url = "https://marmot-abc123-ew.a.run.app"
Open it, and log in with the default admin / admin credentials.
The first request may be a little slow. With min_instance_count = 0 the service scales to zero, so Cloud Run cold-starts the container and runs migrations against the fresh database. Set the minimum to 1 if you'd rather keep one instance warm.
Where to go next
That's a complete, self-contained Marmot install. A few things worth doing before it's truly production-ready:
- Set
deletion_protection = trueon the Cloud SQL instance and the Cloud Run service. - Add a custom domain and
MARMOT_SERVER_ROOT_URL, which is required once you enable OIDC login. - Restrict ingress and put authentication in front of the catalog.
- Start ingesting: point a plugin at your warehouses, object storage and message queues.
Add Data with Plugins
Automatically discover assets from BigQuery, GCS, Postgres, Kafka and more.
Browse Plugins