Terraform #
In the cloud native era, serverless computing has redefined how we deploy and run applications. By eliminating the need to maintain physical servers or virtual machines, serverless offers incredible deployment speed and highly efficient automatic scaling. However, there’s one important reality often overlooked by development teams: even though physical servers disappear from our sight, the infrastructure itself still exists — and becomes even more fragmented.
When we build production-scale serverless applications on Google Cloud Platform (GCP), our system doesn’t consist of just one piece of code. In the field, a complete serverless architecture typically includes dozens of Cloud Run services, several Cloud Functions, Pub/Sub queue channels, cron schedulers, secrets in Secret Manager, Eventarc triggers, Workflows orchestrators, and hundreds of Identity and Access Management (IAM) access policies. Configuring this entire web of infrastructure manually through the Google Cloud Console (GUI) is a high-risk action triggering human error, inconsistency across work environments (dev, staging, prod), and a lack of audit trails for changes.
To address these challenges, Terraform comes in as the industry’s leading declarative Infrastructure as Code (IaC) tool. By writing our serverless infrastructure definitions into declarative HashiCorp Configuration Language (HCL) code files, Terraform lets us provision, update, and manage the entire GCP serverless ecosystem as a single source of truth trackable by Git, reviewable through Pull Requests, and automatically deployable through CI/CD pipelines.
Infrastructure as Code Architecture for the Serverless Ecosystem #
Before diving into HCL code writing details, we need to understand Terraform’s internal architecture concepts and how it interacts with Google Cloud APIs securely and reliably.
1. Declarative System and Dependency Graph #
Terraform works with a Declarative model. That means we write the desired state of the infrastructure we want (e.g., “create a Cloud Run service named backend with 1GB memory”), and Terraform analyzes the difference between the current real cloud state and our desired state.
Terraform builds an internal Dependency Graph mapping dependencies between resources. For example, if an Eventarc trigger needs a Pub/Sub topic, and the Pub/Sub topic needs a service account, Terraform automatically knows the resource creation order without manual instruction from us.
2. Detailed Terraform Command Lifecycle #
Terraform’s work cycle is managed through four main sequential commands:
terraform init: Initializes the working directory. At this stage, Terraform reads configuration code, downloads Google Provider plugins from the HashiCorp Registry, and prepares the state storage backend plugin. The.terraform.lock.hcldependency lock file is also created to record provider version checksums, guaranteeing build consistency on both developer laptops and CI/CD servers.terraform plan: Performs comparative analysis. Terraform reads the current infrastructure state using GCP APIs, compares it with the state file, then generates an action plan list. We can use the-out=tfplanoption to save this action plan into an encrypted physical file, ensuring only that approved plan gets executed in the next step.terraform apply: Executes the plan instructions to the GCP API. Before applying changes, Terraform uses backend locking (e.g., GCS lock) to hold write access from other users. After successful execution, the state file is updated and output variables are printed to the terminal.terraform destroy: Used to delete all resources registered in the state file. This command must be run with the highest level of caution in production, but is very useful for deleting temporary sandbox environments to save operational costs.
3. State File: An Isolated Single Source of Truth #
Terraform stores the real infrastructure state map in a special file called the State File (terraform.tfstate). The state file acts as Terraform’s internal memory to detect manual changes (configuration drift) in the GCP console.
- State Storage in GCS (Remote Backend): In production environments, storing the state file on a developer’s local computer is strictly forbidden (strict anti-pattern). We must store the state file securely in Google Cloud Storage (GCS) with encryption, object versioning, and state locking enabled. The locking feature guarantees that if two developers (or CI/CD pipelines) run deployment processes simultaneously, Terraform uses the GCS metadata lock mechanism to block the second execution until the first completes, preventing state file corruption.
Terraform Provisioning Flow Diagram (IaC Architecture) #
Here’s an architecture visualization of how HCL code is evaluated by the Terraform CLI, matched against the remote state file in a GCS bucket, and deployed to GCP APIs to automatically create our various serverless resources.
flowchart TD
Local["Developer / CI/CD (Terraform CLI)"] -->|"Apply HCL Configuration"| TFEngine["Terraform Engine"]
TFEngine -->|"Read State File"| GCSState["Google Cloud Storage Bucket (Remote State)"]
TFEngine -->|"Provision Resources via GCP API"| GCP["Google Cloud Platform (GCP)"]
subgraph GCP Resources
CRService["google_cloud_run_service"]
GCFFunction["google_cloudfunctions_function"]
PSTopic["google_pubsub_topic"]
ETrigger["google_eventarc_trigger"]
IAM["google_project_iam_member"]
end
GCP --> CRService
GCP --> GCFFunction
GCP --> PSTopic
GCP --> ETrigger
GCP --> IAM
style TFEngine stroke:#0288d1,stroke-width:2pxMain Serverless Resource Declarations #
Writing IaC configuration for serverless requires HCL parameter accuracy. Here are the functional details of the main parameters used:
google_cloud_run_service: Used to provision serverless containers. Important parameters includelimits(memory and CPU allocation),autoscaling(settingminScaleto reduce cold starts andmaxScaleto limit costs), andcontainer_concurrencyto set the request concurrency limit per instance.google_pubsub_topicandgoogle_pubsub_subscription: Used to build message queues. Theack_deadline_secondsconfiguration sets the message processing tolerance time limit before redelivery, while thepush_configparameter is used to channel events directly to a Cloud Run HTTP endpoint with encrypted OIDC authentication.google_workflows_workflow: Compiles and uploads YAML orchestration code using thesource_contentsargument into the serverless engine.google_eventarc_trigger: Sets event source matching criteria (matching_criteria) and defines the target destination container in thedestinationparameter.
Least Privilege-Based Access Management (IAM) #
Security is the most crucial yet challenging aspect of serverless architecture. Because every serverless service runs isolated in the cloud network, they need special identities to access other resources (e.g., a Cloud Run service calling a Cloud SQL database or reading files in Cloud Storage).
- DON’T use the default Google Compute Engine or App Engine service account to run your serverless services in production, because those accounts have overly broad project editor access rights.
- DO: Create a custom Service Account for each serverless service using Terraform. Grant access with the Least Privilege principle using precise IAM bindings.
Crucial IAM Binding Difference: Member vs. Binding vs. Policy #
Terraform offers three resources for managing IAM permissions:
google_project_iam_policy(Very Dangerous): Overwrites all IAM policies at the project level with the defined list. All other manually created permissions are immediately removed.google_project_iam_binding(Dangerous): Takes full control of one specific role. If we define a binding for theroles/storage.adminrole, all other users or service accounts holding that role but not written in the Terraform file immediately lose access.google_project_iam_member(Highly Recommended): Adds one member to one role additively without disturbing other existing members’ access rights. This is the safest choice for avoiding accidentally removing other admin team access.
Multi-Environment Terraform Project Folder Structure #
To keep code clean and avoid the risk of accidentally destroying production infrastructure while experimenting in development environments, we must structure Terraform directories modularly and separately by environment.
Why Choose Folder Structure Over Terraform Workspaces? #
Terraform provides a built-in feature called Workspaces for managing multi-environment in one code directory. However, for enterprise production levels, using Folder Structure-based isolation is far safer because:
- State Backend Separation: Each environment folder has its own fully isolated state backend configuration in a different GCS bucket. State damage in the
devenvironment will never propagate to theprodenvironment. - Separate Credentials: We can restrict CI/CD service account access so the
devservice account has no write access at all to theprodfolder, enforcing absolute security isolation. - More Readable Dynamic Variables: Storing environment-specific variable values in
terraform.tfvarsfiles in each folder is much easier to audit than tracking workspace state stored in Terraform memory.
Implementation Code Example: Complete HCL Configuration #
Here’s a complete Terraform code example (main.tf at the environment level) configuring a GCS remote backend, provisioning a Cloud Run service, creating a Pub/Sub topic, configuring an Eventarc trigger, and setting IAM access rights securely.
# ==========================================================================
# 1. PROVIDER & REMOTE BACKEND CONFIGURATION
# ==========================================================================
terraform {
required_version = ">= 1.6.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.10.0"
}
}
# ✓ CORRECT: Storing the state file in Google Cloud Storage with lock feature
backend "gcs" {
bucket = "my-company-terraform-states"
prefix = "serverless-app/production"
}
}
provider "google" {
project = var.project_id
region = var.region
}
# ==========================================================================
# 2. SERVICE ACCOUNT & IAM PRIVILEGES (Least Privilege)
# ==========================================================================
# Creating a dedicated Service Account for the Cloud Run Backend
resource "google_service_account" "run_backend_sa" {
account_id = "cr-backend-production-sa"
display_name = "Service Account untuk Cloud Run Backend Production"
}
# Granting access so Cloud Run can read Secret Manager
resource "google_project_iam_member" "secret_accessor" {
project = var.project_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.run_backend_sa.email}"
}
# ==========================================================================
# 3. PUB/SUB TOPIC & SUBSCRIPTION PROVISIONING
# ==========================================================================
resource "google_pubsub_topic" "order_topic" {
name = "production-order-events-topic"
labels = {
environment = "production"
owner = "checkout-team"
}
}
# ==========================================================================
# 4. CLOUD RUN SERVICE CONFIGURATION (Production Ready)
# ==========================================================================
resource "google_cloud_run_service" "backend_app" {
name = "backend-api-production"
location = var.region
# Enabling automatic revision splitting to the latest revision
traffic {
percent = 100
latest_revision = true
}
template {
spec {
service_account_name = google_service_account.run_backend_sa.email
containers {
image = var.container_image_url
resources {
limits = {
cpu = "2000m" # 2 vCPU
memory = "2Gi" # 2 GB RAM
}
}
# Injecting environment variables
env {
name = "APP_ENV"
value = "production"
}
env {
name = "DATABASE_NAME"
value = "production_db"
}
}
}
metadata {
annotations = {
# ✓ CORRECT: Configuring autoscaling & concurrency limits precisely
"autoscaling.knative.dev/minScale" = "1"
"autoscaling.knative.dev/maxScale" = "30"
"container.googleapis.com/concurrency" = "80"
}
}
}
}
# ==========================================================================
# 5. EVENTARC TRIGGER CONFIGURATION
# ==========================================================================
# Service Account to allow Eventarc to perform event routing
resource "google_service_account" "eventarc_trigger_sa" {
account_id = "eventarc-order-trigger-sa"
display_name = "Service Account Eventarc Order Trigger"
}
# Role to allow the Eventarc Service Account to call Cloud Run APIs
resource "google_cloud_run_service_iam_member" "eventarc_invoker" {
service = google_cloud_run_service.backend_app.name
location = google_cloud_run_service.backend_app.location
role = "roles/run.invoker"
member = "serviceAccount:${google_service_account.eventarc_trigger_sa.email}"
}
# Eventarc Trigger to deliver messages from the Pub/Sub order topic
resource "google_eventarc_trigger" "pubsub_trigger" {
name = "pubsub-order-event-trigger"
location = var.region
destination {
cloud_run_service {
service = google_cloud_run_service.backend_app.name
region = google_cloud_run_service.backend_app.location
path = "/webhooks/order" # Target HTTP backend endpoint
}
}
matching_criteria {
attribute = "type"
value = "google.cloud.pubsub.topic.v1.messagePublished"
}
# Filtering based on the origin topic
transport {
pubsub {
topic = google_pubsub_topic.order_topic.id
}
}
service_account = google_service_account.eventarc_trigger_sa.email
depends_on = [
google_cloud_run_service_iam_member.eventarc_invoker
]
}
CI/CD Pipeline Integration (Terraform + GitOps) #
Deploying infrastructure manually from the terminal on a developer’s local laptop risks state file corruption from Terraform CLI version inconsistencies or insecure credentials.
- DO: Integrate the Terraform provisioning cycle into a GitOps-based CI/CD pipeline (like GitHub Actions) automatically.
Here’s an example GitHub Actions workflow script (.github/workflows/terraform.yml) for validating and provisioning our infrastructure:
name: "Terraform GitOps Pipeline"
on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: read
pull-requests: write
jobs:
terraform:
name: "Terraform Job"
runs-on: "ubuntu-latest"
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.6.0"
# Authenticating to Google Cloud using Workload Identity Federation
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
- name: Terraform Format Check
run: terraform fmt -check -recursive
- name: Terraform Init
run: terraform init
working-directory: ./environments/prod
- name: Terraform Validate
run: terraform validate
working-directory: ./environments/prod
- name: Terraform Plan
id: plan
if: github.event_name == 'pull_request'
run: terraform plan -no-color
working-directory: ./environments/prod
# Applying changes only when a Pull Request is merged to the main branch
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve
working-directory: ./environments/prod
Best Practices for Managing Serverless IaC #
Applying the following architecture principles is highly recommended to guarantee the long-term stability and security of our serverless Terraform code:
1. Retrieve Sensitive Credentials from Secret Manager Dynamically #
Writing database passwords directly (hardcoded) in variables.tf or .tfvars files is a high-level security hole that often leaks into public Git repositories.
- DO: Register those secrets manually in the GCP Secret Manager console. In Terraform, retrieve the secret using a data query block:
data "google_secret_manager_secret_version" "db_password" {
secret = "production-database-password"
}
# Use this query data in the Cloud Run environment variable configuration
# ${data.google_secret_manager_secret_version.db_password.secret_data}
2. Handle Container Image Versions with the Right Lifecycle #
Every time our application CI/CD pipeline builds a new container image (e.g., gcr.io/my-project/api:v1.2.0), deploying it with Terraform can leave the Terraform state file behind if the container is deployed directly by a separate CD pipeline.
- DO: If we deploy new container images independently using Cloud Build, configure a
lifecycleblock on the Terraformgoogle_cloud_run_serviceresource to ignore container image property changes, so the next terraform apply doesn’t accidentally revert the image version to an old one:
lifecycle {
ignore_changes = [
template[0].spec[0].containers[0].image,
]
}
3. Apply Resource Labeling for Cost Management #
Ballooning cloud usage costs are often hard to trace to their source if we deploy dozens of serverless resources randomly.
- DO: Always add a
labelsblock (ormetadata.labelson Cloud Run) configuration to every resource we create in Terraform. Use industry-standard labels likeenvironment = "production",cost-center = "marketing-team", andproject = "checkout-system". These labels automatically integrate into GCP Billing reports, making it easy for finance teams to audit per-team usage costs in detail.
4. Handling Stuck State Locks (Force Unlock) #
Sometimes, if our CI/CD pipeline dies suddenly mid-terraform apply due to a runner server failure, the GCS backend detects the state remains locked, permanently blocking the next deployment process.
- DO: Don’t panic and don’t try to delete the state file. Copy the Lock ID printed in the terminal error, verify no other deployment process is actively running on the backend, then run the
terraform force-unlock <LOCK_ID>command in your local terminal to safely unlock the GCS state.
5. Periodic Manual Change Detection (Scheduled Drift Detection) #
Even with GitOps, team members sometimes have to make emergency manual modifications in the Google Cloud console while handling production issues. These manual changes (drift) must be redefined into code immediately so they aren’t removed in the next deployment.
- DO: Set up a daily cron job in our GitHub Actions pipeline running the
terraform plan -detailed-exitcodecommand during quiet hours. The-detailed-exitcodeoption returns status code 2 if drift is detected. The pipeline can then send automatic notifications to the developer team’s Slack to reconcile the code immediately.
6. Version Compatibility Constraints #
Automatic Google provider version upgrades by terraform init can introduce unexpected breaking changes that break our HCL build scripts.
- DO: Always pin the Google provider version range using the
~>operator in therequired_providersblock (e.g.,~> 5.10.0). Also pin the Terraform CLI executable version using therequired_versionargument to guarantee all team developers use the same engine version.
Summary #
← Previous: Eventarc Next: NeonDB →
- Terraform is a declarative IaC tool acting as the single source of truth for all serverless infrastructure on GCP.
- Must use a GCS remote backend with versioning and object locking enabled to secure the state file from synchronization corruption.
- Design custom service accounts dedicated to each serverless service to enforce the least privilege security principle.
- Separate Terraform directories into modules (reusable blueprints) and environments (isolated dev/prod instances).
- Use GitOps CI/CD cycles to validate, plan, and apply infrastructure changes from the main Git branch.
- Apply ignore_changes on container images if new image deployments are managed by external CD pipelines outside Terraform.