Blog
March 11, 2019 Marie H.

Terraform for GCP: Getting Started

Terraform for GCP: Getting Started

Photo by <a href="https://unsplash.com/@mahmudahsan?utm_source=cloudista&utm_medium=referral" target="_blank" rel="noopener">Mahmud Ahsan</a> on <a href="https://unsplash.com/?utm_source=cloudista&utm_medium=referral" target="_blank" rel="noopener">Unsplash</a>

Terraform for GCP: Getting Started

When I started building out Privia Health's GCP infrastructure, we were provisioning resources manually through the console and with gcloud commands. That works fine until you have multiple environments, until someone needs to reproduce what you did, or until you need to audit what changed and when. Terraform fixes all of those problems. Here's how I set it up for GCP.

Provider Setup

The GCP provider for Terraform requires a service account with appropriate permissions. For a getting-started setup, I created a dedicated Terraform service account with the roles it needed (Compute Admin, Storage Admin, Cloud SQL Admin, etc.) and downloaded its JSON key.

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

provider "google" {
  credentials = file(var.gcp_credentials_file)
  project     = var.project_id
  region      = var.region
  zone        = var.zone
}
# variables.tf
variable "gcp_credentials_file" {
  description = "Path to the GCP service account JSON key"
  type        = string
}

variable "project_id" {
  type = string
}

variable "region" {
  type    = string
  default = "us-east4"
}

variable "zone" {
  type    = string
  default = "us-east4-a"
}

terraform.tfvars and .gitignore

Put environment-specific values in terraform.tfvars:

# terraform.tfvars  — DO NOT COMMIT
gcp_credentials_file = "/home/marie/.config/gcp/privia-terraform-sa.json"
project_id           = "privia-health-prod"
region               = "us-east4"
zone                 = "us-east4-a"

And add it to .gitignore immediately:

# .gitignore
*.tfvars
*.tfvars.json
**/.terraform/
terraform.tfstate
terraform.tfstate.backup
.terraform.lock.hcl  # commit this one, actually — it pins provider versions

Actually on the lock file: commit .terraform.lock.hcl. It pins provider version checksums and ensures everyone on the team gets the same provider binary. The .tfvars files with real credentials and environment-specific values should never be committed.

Remote State with GCS Backend

Local terraform.tfstate is fine for solo work. The moment a second person touches the infrastructure, you need remote state. We used GCS:

terraform {
  backend "gcs" {
    bucket = "privia-terraform-state"
    prefix = "terraform/state/production"
  }
}

Create the bucket before running terraform init:

gsutil mb -l us-east4 gs://privia-terraform-state
gsutil versioning set on gs://privia-terraform-state

Versioning on the bucket means you can recover from accidental state corruption. GCS also provides state locking via object metadata, so concurrent terraform apply runs from two people will block rather than corrupt the state file.

VPC, Subnet, and Compute Instance

Here's a working example that provisions a VPC network, a subnet, and a compute instance:

# network.tf
resource "google_compute_network" "vpc" {
  name                    = "privia-vpc"
  auto_create_subnetworks = false
}

resource "google_compute_subnetwork" "app_subnet" {
  name          = "privia-app-subnet"
  ip_cidr_range = "10.10.0.0/24"
  region        = var.region
  network       = google_compute_network.vpc.id

  private_ip_google_access = true  # allows VMs without public IPs to reach Google APIs
}

resource "google_compute_firewall" "allow_internal" {
  name    = "allow-internal"
  network = google_compute_network.vpc.name

  allow {
    protocol = "tcp"
    ports    = ["0-65535"]
  }

  source_ranges = ["10.10.0.0/24"]
}
# compute.tf
resource "google_compute_instance" "app_server" {
  name         = "privia-app-01"
  machine_type = "n1-standard-2"
  zone         = var.zone

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-11"
      size  = 50
      type  = "pd-ssd"
    }
  }

  network_interface {
    subnetwork = google_compute_subnetwork.app_subnet.id
    # no access_config block = no public IP
  }

  service_account {
    email  = google_service_account.app_sa.email
    scopes = ["cloud-platform"]
  }

  metadata_startup_script = file("scripts/startup.sh")

  tags = ["app-server"]

  labels = {
    environment = "production"
    team        = "platform"
  }
}

Service Accounts and IAM

resource "google_service_account" "app_sa" {
  account_id   = "privia-app-sa"
  display_name = "Privia App Service Account"
}

resource "google_project_iam_member" "app_sa_sql_client" {
  project = var.project_id
  role    = "roles/cloudsql.client"
  member  = "serviceAccount:${google_service_account.app_sa.email}"
}

resource "google_project_iam_member" "app_sa_storage_viewer" {
  project = var.project_id
  role    = "roles/storage.objectViewer"
  member  = "serviceAccount:${google_service_account.app_sa.email}"
}

IAM Binding vs. Member vs. Policy

This distinction confused me early on and it matters:

  • google_project_iam_member: Adds a single member to a role. Additive. Does not remove other members from that role. Safe to use alongside manually-granted permissions or permissions granted by other Terraform modules.
  • google_project_iam_binding: Sets the complete list of members for a role. Authoritative for that role. If you add a member outside Terraform, the next apply removes them.
  • google_project_iam_policy: Sets the complete IAM policy for the resource. Fully authoritative. Can lock out other admins if misused. Avoid this one unless you have a very specific reason.

We used iam_member everywhere unless we had an explicit reason to be authoritative. The risk of an apply accidentally removing permissions that were granted outside Terraform is real in a shared environment.

Workspaces vs. Separate State Files

We evaluated Terraform workspaces for environment separation (staging vs. production) and decided against it. Workspaces share a backend and a codebase, which means a variable mistake in production applies to the same config as staging. The blast radius is too high.

Instead, we used separate directories per environment:

terraform/
  modules/
    vpc/
    compute/
    cloud-sql/
  environments/
    staging/
      main.tf
      terraform.tfvars
      backend.tf
    production/
      main.tf
      terraform.tfvars
      backend.tf

Each environment directory calls the shared modules with its own variable values and has its own GCS backend prefix. This means staging and production are completely isolated at the state level. The cost is some duplication in main.tf, but the safety is worth it.

Variables with Validation and Outputs

variable "environment" {
  type        = string
  description = "Deployment environment"

  validation {
    condition     = contains(["staging", "production"], var.environment)
    error_message = "Environment must be 'staging' or 'production'."
  }
}

locals {
  common_labels = {
    environment = var.environment
    managed_by  = "terraform"
    team        = "platform"
  }
}

output "app_server_internal_ip" {
  value       = google_compute_instance.app_server.network_interface[0].network_ip
  description = "Internal IP address of the app server"
}

Updated March 2026: Two things worth knowing if you're picking this up now. First, Terraform 1.5+ introduced check blocks for post-apply assertions — you can verify that an HTTP endpoint is reachable or that a bucket has the right settings after a deploy, without those assertions blocking the plan. Second, import blocks (also 1.5+) let you bring existing resources under Terraform management declaratively, which is far cleaner than the old terraform import CLI command. If you're concerned about the BSL license change HashiCorp made in 2023, OpenTofu is the open-source fork under MPL-2.0 and is functionally compatible with most Terraform 1.x configs.