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
- You are migrating off Looker (original) SaaS onto Google Cloud core and want the instance lifecycle in IaC alongside the rest of your data platform (BigQuery, Dataform, Dataplex).
- You need Looker on a private IP reachable only from your VPC / via Private Service Connect, with no public endpoint — a hard requirement in regulated environments.
- You run multiple Looker instances (dev / staging / prod, or one per business unit) and want them identical apart from edition and domain.
- You want PSC service attachment allow-lists, custom domains, and admin email settings codified rather than clicked in the console.
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 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/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
- Treat OAuth as a prerequisite, not an afterthought. Looker GCC refuses sign-in until
oauth_configis set, and the OAuth client’s authorised redirect URI must include your final hostname (custom domain or GCC default). Create the Web-application OAuth client first, store the secret in Secret Manager, and pass it via asensitivevariable — never hard-code it. - Default to private, allow-list egress explicitly. Keep
public_ip_enabled = falseand reach Looker over PSA or PSC. Because Looker still needs an outbound path to data sources, surfaceegress_public_ipand add only that IP to each database firewall rather than opening0.0.0.0/0. - Guard the instance lifecycle. Leave
deletion_policynull in production so a strayterraform destroycannot delete a billed instance; flip it toDEFAULTonly in disposable environments. Pair this withprevent_destroyin your root module for belt-and-braces protection. - Right-size the edition for cost.
platform_editionfixes both capacity and price, and annual editions commit you for a year — start onLOOKER_CORE_STANDARD, addadditional_*_userseats incrementally, and only move toENTERPRISE_ANNUAL/EMBED_ANNUALonce usage justifies the spend. - Schedule maintenance around your reporting calendar. Set a low-traffic
maintenance_windowand usedeny_maintenance_periodto freeze upgrades over quarter/year-end close so a platform update never collides with critical dashboards. - Name and label for fleet clarity. Use a consistent, lowercase
instance_nameconvention (<org>-bi-<env>) since the name is immutable, and pin both Terraform and thehashicorp/googleprovider versions so a provider bump never silently re-shapes thegoogle_looker_instanceschema under you.