IaC GCP

Terraform Module: GCP Looker (Google Cloud core) — a private, OAuth-ready BI platform in one module

Quick take — Provision a production-grade Looker (Google Cloud core) instance with Terraform and hashicorp/google ~> 5.0 — private IP, OAuth, custom domain, admin settings and deletion safeguards. 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 "looker" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-looker?ref=v1.0.0"

  project_id          = "..."  # GCP project ID hosting the Looker instance.
  region              = "..."  # Region for the instance (e.g. `europe-west1`).
  instance_name       = "..."  # Instance name; lowercased, 1–63 chars.
  oauth_client_id     = "..."  # OAuth 2.0 client ID for sign-in.
  oauth_client_secret = "..."  # OAuth 2.0 client secret.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Looker (Google Cloud core) is Google’s managed, single-tenant deployment of the Looker BI platform that runs inside your own Google Cloud project — as opposed to the older “Looker (original)” SaaS that lived on *.looker.com infrastructure billed separately. With the Google Cloud core flavour you get a Looker instance that is regional, integrates with Cloud IAM, can be pinned to a private VPC via Private Service Connect or PSA, bills through your normal Cloud invoice, and is provisioned through the google_looker_instance Terraform resource.

The catch is that a single google_looker_instance block hides a lot of sharp edges: you must choose a platform edition (which fixes your user/query capacity and price), wire up an OAuth client before the instance will let anyone log in, decide between public, private, or public+private ingress/egress, and — critically — set deletion_policy and oauth_config correctly or your apply will either fail or, worse, your destroy will silently strand a billed instance. Wrapping all of this in a reusable module means every Looker instance your organisation stands up gets the same OAuth handling, the same private-networking posture, the same maintenance window, and the same labels — instead of each team rediscovering the OAuth requirement the hard way.

When to use it

Reach for the plain console flow instead only if you are doing a one-off throwaway evaluation — the OAuth + networking setup genuinely is easier to reason about once it is in Terraform.

Module structure

terraform-module-gcp-looker/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # google_looker_instance + locals
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # ids, URLs, egress IP, PSC attachment

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # Looker GCC instance names must be <= 63 chars, lowercase, RFC1035-ish.
  instance_name = lower(var.instance_name)

  # Private connectivity is only meaningful when a VPC is supplied.
  use_private = var.private_ip_enabled && var.consumer_network != null
}

resource "google_looker_instance" "this" {
  name              = local.instance_name
  project           = var.project_id
  region            = var.region
  platform_edition  = var.platform_edition

  # Ingress/egress posture. Exactly one of these (or both) must be true.
  public_ip_enabled  = var.public_ip_enabled
  private_ip_enabled = local.use_private

  # PSA (Private Services Access) consumer network for private IP.
  # Required when private_ip_enabled = true and not using PSC.
  consumer_network = local.use_private && !var.use_psc ? var.consumer_network : null

  # Reserved IP range inside the consumer network (PSA only).
  reserved_range = local.use_private && !var.use_psc ? var.reserved_range : null

  # Fully-qualified custom domain for the Looker UI (optional).
  custom_domain = var.custom_domain

  # FIPS-validated encryption modules (cannot be changed after creation).
  fips_enabled = var.fips_enabled

  # Protects against accidental terraform destroy of a billed instance.
  deletion_policy = var.deletion_policy

  # OAuth is MANDATORY: Looker GCC will not allow sign-in without it.
  oauth_config {
    client_id     = var.oauth_client_id
    client_secret = var.oauth_client_secret
  }

  # Admin-level settings surfaced in the UI's Admin panel.
  admin_settings {
    allowed_email_domains = var.allowed_email_domains
  }

  # User-facing meta: outbound mail "from" address, etc.
  user_metadata {
    additional_developer_user_count   = var.additional_developer_users
    additional_standard_user_count    = var.additional_standard_users
    additional_viewer_user_count      = var.additional_viewer_users
  }

  # Encryption at rest with a customer-managed key (optional).
  dynamic "encryption_config" {
    for_each = var.kms_key_name == null ? [] : [1]
    content {
      kms_key_name = var.kms_key_name
    }
  }

  # Private Service Connect: allow-list of projects/VPCs that may attach.
  dynamic "psc_config" {
    for_each = var.use_psc ? [1] : []
    content {
      allowed_vpcs = var.psc_allowed_vpcs

      dynamic "service_attachments" {
        for_each = var.psc_service_attachments
        content {
          local_fqdn              = service_attachments.value.local_fqdn
          target_service_attachment_uri = service_attachments.value.target_service_attachment_uri
        }
      }
    }
  }

  # Weekly maintenance window (1 = Monday ... 7 = Sunday).
  dynamic "maintenance_window" {
    for_each = var.maintenance_window == null ? [] : [var.maintenance_window]
    content {
      day_of_week = maintenance_window.value.day_of_week
      start_time {
        hours   = maintenance_window.value.hours
        minutes = maintenance_window.value.minutes
      }
    }
  }

  # Deny-maintenance window: freeze upgrades over a critical reporting period.
  dynamic "deny_maintenance_period" {
    for_each = var.deny_maintenance_period == null ? [] : [var.deny_maintenance_period]
    content {
      start_date {
        year  = deny_maintenance_period.value.start_year
        month = deny_maintenance_period.value.start_month
        day   = deny_maintenance_period.value.start_day
      }
      end_date {
        year  = deny_maintenance_period.value.end_year
        month = deny_maintenance_period.value.end_month
        day   = deny_maintenance_period.value.end_day
      }
      time {
        hours   = 2
        minutes = 0
        seconds = 0
        nanos   = 0
      }
    }
  }
}

variables.tf

variable "project_id" {
  description = "GCP project ID that will host the Looker instance."
  type        = string
}

variable "region" {
  description = "Region for the Looker instance (e.g. us-central1, europe-west1)."
  type        = string
}

variable "instance_name" {
  description = "Name of the Looker instance. Lowercased automatically; must be <= 63 chars."
  type        = string

  validation {
    condition     = length(var.instance_name) >= 1 && length(var.instance_name) <= 63
    error_message = "instance_name must be between 1 and 63 characters."
  }
}

variable "platform_edition" {
  description = "Looker GCC platform edition (user/query capacity tier)."
  type        = string
  default     = "LOOKER_CORE_STANDARD"

  validation {
    condition = contains([
      "LOOKER_CORE_TRIAL",
      "LOOKER_CORE_STANDARD",
      "LOOKER_CORE_STANDARD_ANNUAL",
      "LOOKER_CORE_ENTERPRISE_ANNUAL",
      "LOOKER_CORE_EMBED_ANNUAL",
    ], var.platform_edition)
    error_message = "platform_edition must be a valid LOOKER_CORE_* edition."
  }
}

variable "public_ip_enabled" {
  description = "Expose a public IP / endpoint for the Looker UI."
  type        = bool
  default     = false
}

variable "private_ip_enabled" {
  description = "Enable private IP connectivity (requires consumer_network or PSC)."
  type        = bool
  default     = true
}

variable "use_psc" {
  description = "Use Private Service Connect instead of Private Services Access for private IP."
  type        = bool
  default     = false
}

variable "consumer_network" {
  description = "Self-link of the VPC used for Private Services Access (PSA). Null when using PSC or public-only."
  type        = string
  default     = null
}

variable "reserved_range" {
  description = "Named reserved IP range in the consumer network for PSA peering."
  type        = string
  default     = null
}

variable "psc_allowed_vpcs" {
  description = "List of VPC self-links permitted to connect via Private Service Connect."
  type        = list(string)
  default     = []
}

variable "psc_service_attachments" {
  description = "PSC service attachments for outbound connections to data sources behind PSC."
  type = list(object({
    local_fqdn                    = string
    target_service_attachment_uri = string
  }))
  default = []
}

variable "custom_domain" {
  description = "Custom FQDN for the Looker UI (e.g. analytics.example.com). Null to use the default GCC hostname."
  type        = string
  default     = null
}

variable "oauth_client_id" {
  description = "OAuth 2.0 client ID used for Looker sign-in (from a Web application OAuth client)."
  type        = string
}

variable "oauth_client_secret" {
  description = "OAuth 2.0 client secret paired with oauth_client_id."
  type        = string
  sensitive   = true
}

variable "allowed_email_domains" {
  description = "Email domains permitted to authenticate (e.g. [\"example.com\"]). Empty = no domain restriction."
  type        = list(string)
  default     = []
}

variable "fips_enabled" {
  description = "Use FIPS 140-2 validated encryption. Immutable after creation."
  type        = bool
  default     = false
}

variable "kms_key_name" {
  description = "Customer-managed KMS key (CMEK) resource name for encryption at rest. Null = Google-managed key."
  type        = string
  default     = null
}

variable "additional_developer_users" {
  description = "Additional Developer-role user seats beyond the edition baseline."
  type        = number
  default     = 0
}

variable "additional_standard_users" {
  description = "Additional Standard-role user seats beyond the edition baseline."
  type        = number
  default     = 0
}

variable "additional_viewer_users" {
  description = "Additional Viewer-role user seats beyond the edition baseline."
  type        = number
  default     = 0
}

variable "deletion_policy" {
  description = "Set to DEFAULT to allow destroy. Leave empty/null to block accidental deletion of a billed instance."
  type        = string
  default     = null

  validation {
    condition     = var.deletion_policy == null || contains(["DEFAULT"], var.deletion_policy)
    error_message = "deletion_policy must be null or \"DEFAULT\"."
  }
}

variable "maintenance_window" {
  description = "Weekly maintenance window. day_of_week 1=Mon..7=Sun; hours 0-23, minutes 0/15/30/45."
  type = object({
    day_of_week = number
    hours       = number
    minutes     = number
  })
  default = {
    day_of_week = 7
    hours       = 23
    minutes     = 0
  }
}

variable "deny_maintenance_period" {
  description = "Date range during which Google must not run upgrades (e.g. fiscal close). Null to disable."
  type = object({
    start_year  = number
    start_month = number
    start_day   = number
    end_year    = number
    end_month   = number
    end_day     = number
  })
  default = null
}

outputs.tf

output "id" {
  description = "Fully-qualified Looker instance ID (projects/.../locations/.../instances/...)."
  value       = google_looker_instance.this.id
}

output "name" {
  description = "Short name of the Looker instance."
  value       = google_looker_instance.this.name
}

output "looker_uri" {
  description = "URL of the Looker web UI (custom domain if set, otherwise the GCC-assigned hostname)."
  value       = google_looker_instance.this.looker_uri
}

output "egress_public_ip" {
  description = "Public egress IP Looker uses to reach external data sources — allow-list this on DB firewalls."
  value       = google_looker_instance.this.egress_public_ip
}

output "ingress_private_ip" {
  description = "Private ingress IP of the instance (populated when private_ip_enabled = true)."
  value       = google_looker_instance.this.ingress_private_ip
}

output "psc_service_attachment_uri" {
  description = "Target service attachment URI list for PSC consumers."
  value       = google_looker_instance.this.psc_config
}

output "create_time" {
  description = "RFC3339 timestamp when the instance was created."
  value       = google_looker_instance.this.create_time
}

How to use it

module "looker_google_cloud_core_" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-looker?ref=v1.0.0"

  project_id       = "kv-analytics-prod"
  region           = "europe-west1"
  instance_name    = "kv-bi-prod"
  platform_edition = "LOOKER_CORE_ENTERPRISE_ANNUAL"

  # Private-only posture over Private Services Access.
  public_ip_enabled  = false
  private_ip_enabled = true
  consumer_network   = "projects/kv-shared-vpc/global/networks/analytics-vpc"
  reserved_range     = "looker-psa-range"

  # OAuth client created out-of-band (Web application type).
  oauth_client_id     = var.looker_oauth_client_id
  oauth_client_secret = var.looker_oauth_client_secret

  custom_domain         = "analytics.kloudvin.com"
  allowed_email_domains = ["kloudvin.com"]

  # CMEK for data-at-rest.
  kms_key_name = "projects/kv-analytics-prod/locations/europe-west1/keyRings/looker/cryptoKeys/looker-cmek"

  # Sunday 02:00 maintenance; freeze upgrades over quarter-end close.
  maintenance_window = {
    day_of_week = 7
    hours       = 2
    minutes     = 0
  }
  deny_maintenance_period = {
    start_year  = 2026
    start_month = 6
    start_day   = 28
    end_year    = 2026
    end_month   = 7
    end_day     = 5
  }

  # Allow destroy only in this controlled environment.
  deletion_policy = "DEFAULT"
}

# Downstream reference: allow-list Looker's egress IP on a Cloud SQL instance
# so explores can query the operational database directly.
resource "google_sql_database_instance_authorized_networks" "looker_egress" {
  # (illustrative) — wire Looker's egress IP into your DB firewall.
  name  = "looker-egress"
  value = module.looker_google_cloud_core_.egress_public_ip
}

# Or surface the UI URL to a DNS record / monitoring check.
output "looker_url" {
  value = module.looker_google_cloud_core_.looker_uri
}

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/looker/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-looker?ref=v1.0.0"
}

inputs = {
  project_id = "..."
  region = "..."
  instance_name = "..."
  oauth_client_id = "..."
  oauth_client_secret = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/looker && 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 hosting the Looker instance.
region string Yes Region for the instance (e.g. europe-west1).
instance_name string Yes Instance name; lowercased, 1–63 chars.
platform_edition string LOOKER_CORE_STANDARD No Edition / capacity tier (validated against LOOKER_CORE_*).
public_ip_enabled bool false No Expose a public endpoint for the UI.
private_ip_enabled bool true No Enable private IP (needs consumer_network or PSC).
use_psc bool false No Use Private Service Connect instead of PSA.
consumer_network string null No VPC self-link for PSA peering.
reserved_range string null No Named reserved IP range for PSA.
psc_allowed_vpcs list(string) [] No VPCs allowed to connect via PSC.
psc_service_attachments list(object) [] No PSC service attachments for outbound data-source access.
custom_domain string null No Custom FQDN for the Looker UI.
oauth_client_id string Yes OAuth 2.0 client ID for sign-in.
oauth_client_secret string (sensitive) Yes OAuth 2.0 client secret.
allowed_email_domains list(string) [] No Email domains permitted to authenticate.
fips_enabled bool false No FIPS 140-2 encryption (immutable).
kms_key_name string null No CMEK resource name for encryption at rest.
additional_developer_users number 0 No Extra Developer seats over baseline.
additional_standard_users number 0 No Extra Standard seats over baseline.
additional_viewer_users number 0 No Extra Viewer seats over baseline.
deletion_policy string null No DEFAULT to permit destroy; null blocks deletion.
maintenance_window object Sun 23:00 No Weekly maintenance window.
deny_maintenance_period object null No Date range to freeze upgrades.

Outputs

Name Description
id Fully-qualified instance ID (projects/.../instances/...).
name Short instance name.
looker_uri URL of the Looker web UI.
egress_public_ip Public egress IP to allow-list on data-source firewalls.
ingress_private_ip Private ingress IP (when private_ip_enabled is true).
psc_service_attachment_uri PSC config / target service attachment details.
create_time RFC3339 creation timestamp.

Enterprise scenario

A retail analytics team is sunsetting their legacy Looker (original) SaaS tenant and consolidating BI inside their kv-analytics-prod project so that explores read straight from BigQuery and a Cloud SQL operational replica without traversing the public internet. They deploy this module with private_ip_enabled = true over their shared-VPC analytics-vpc, a CMEK key for data-at-rest, allowed_email_domains = ["kloudvin.com"], and a deny_maintenance_period covering the last days of each fiscal quarter so Google never pushes an upgrade during month-end reporting. The module’s egress_public_ip output is fed into the Cloud SQL authorized-networks firewall, and looker_uri is wired into a DNS record plus an uptime check — giving Finance a private, single-tenant Looker that lives and bills entirely within their Google Cloud invoice.

Best practices

TerraformGCPLooker (Google Cloud core)ModuleIaC
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