Terraform Examples for Google Cloud (GCP) from AlmaLinux 10

Terraform lets you describe AWS infrastructure in plain text files, review the exact changes before they happen, and rebuild the whole environment from scratch whenever you need to. This guide walks through a small but complete AWS lab built from an AlmaLinux 10 workstation: a VPC with public subnets, a security group, an EC2 instance running AlmaLinux, an encrypted S3 bucket, and remote state stored in S3 with native locking.

Versions at the time of writing: Terraform 1.16.6, the hashicorp/aws provider 6.x, AWS CLI v2, and AlmaLinux 10.2. Every example here passes terraform validate and terraform fmt -check. The same steps work on AlmaLinux 9.x, Rocky Linux, Oracle Linux, RHEL and CentOS Stream.

Terraform works the same way on Google Cloud as it does on AWS: you describe the infrastructure in text files, review a plan, and apply it. This guide builds a small but complete Google Cloud lab from an AlmaLinux 10 workstation: a custom VPC network and subnet, firewall rules, a Compute Engine VM running the official AlmaLinux 10 image with a static IP, a private versioned Cloud Storage bucket, and remote state stored in Cloud Storage with built-in locking. If you have not read it yet, the companion post Terraform Examples for Amazon AWS from AlmaLinux 10 covers the same patterns on AWS.

Versions at the time of writing: Terraform 1.16.6, the hashicorp/google provider 8.x, the Google Cloud CLI (gcloud), and AlmaLinux 10. Every example here passes terraform validate and terraform fmt -check. The same steps work on AlmaLinux 9.x, Rocky Linux, Oracle Linux, RHEL and CentOS Stream.

What you will build

  • A custom-mode VPC network with one regional subnet (10.30.1.0/24) and Private Google Access turned on
  • Firewall rules allowing SSH only from your IP and HTTP from anywhere, applied through network tags
  • An e2-small Compute Engine VM built from the latest image in the almalinux-10 family, with Shielded VM, a reserved static external IP, and nginx installed by a startup script
  • A Cloud Storage bucket with uniform bucket-level access, public access prevention and object versioning
  • Remote Terraform state in a Cloud Storage bucket, which locks state automatically

Prerequisites

  • An AlmaLinux 10 (or 9.x) machine with sudo access and outbound HTTPS
  • A Google Cloud project with billing enabled, and an account with the Compute Admin and Storage Admin roles (or Owner) on it
  • An SSH key pair on your workstation. If you need one, see Configure SSH Key Authentication Only.
  • A small budget. An e2-small VM and a static IP cost a few cents an hour, so destroy the lab when you are done.

Step 1: Install Terraform from the HashiCorp repository

HashiCorp publishes an official RPM repository for RHEL-compatible systems:

sudo dnf install -y dnf-plugins-core
sudo dnf config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo dnf install -y terraform
terraform -version

Step 2: Install the Google Cloud CLI

Google publishes an RPM repository for the CLI. Add it and install the google-cloud-cli package:

sudo tee /etc/yum.repos.d/google-cloud-sdk.repo > /dev/null <<'EOF'
[google-cloud-cli]
name=Google Cloud CLI
baseurl=https://packages.cloud.google.com/yum/repos/cloud-sdk-el9-x86_64
enabled=1
gpgcheck=1
repo_gpgcheck=0
gpgkey=https://packages.cloud.google.com/yum/doc/rpm-package-key.gpg
EOF

sudo dnf install -y google-cloud-cli
gcloud version

Google builds the repository for EL9; the same packages install cleanly on AlmaLinux 10. If your mirror ever lags, the tar.gz installer from Google’s install page works as well.

Step 3: Authenticate and prepare the project

Log in, choose your project, and create Application Default Credentials (ADC). The Terraform provider reads ADC automatically, so no key file ever needs to sit in your code:

gcloud auth login
gcloud config set project my-lab-project-123
gcloud auth application-default login
gcloud auth application-default set-quota-project my-lab-project-123

Enable the APIs this lab uses. New projects have Compute Engine turned off until you do:

gcloud services enable compute.googleapis.com storage.googleapis.com
gcloud services list --enabled | grep -E 'compute|storage'

On a CI server, use a service account with Workload Identity Federation, or as a last resort a service account key referenced by GOOGLE_APPLICATION_CREDENTIALS. Never paste credentials into .tf files.

Step 4: Create the project layout

mkdir -p ~/terraform/gcp-lab && cd ~/terraform/gcp-lab
touch versions.tf variables.tf network.tf firewall.tf compute.tf storage.tf outputs.tf terraform.tfvars

Example 1: Pin Terraform and the Google provider

versions.tf pins the provider to the 8.x series and sets project, region and zone once. default_labels adds the same labels to every resource that supports them, which helps with billing reports and cleanup:

terraform {
  required_version = ">= 1.10"

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

provider "google" {
  project = var.project_id
  region  = var.region
  zone    = var.zone

  default_labels = {
    project    = var.name
    managed_by = "terraform"
  }
}

Example 2: Input variables

variables.tf:

variable "project_id" {
  description = "Google Cloud project ID to deploy into"
  type        = string
}

variable "region" {
  description = "Region for the subnet and bucket"
  type        = string
  default     = "us-central1"
}

variable "zone" {
  description = "Zone for the VM"
  type        = string
  default     = "us-central1-a"
}

variable "name" {
  description = "Name prefix and label value for every resource"
  type        = string
  default     = "zwiegnet-lab"
}

variable "admin_cidr" {
  description = "Your public IP in CIDR form, allowed to SSH to the VM"
  type        = string

  validation {
    condition     = can(cidrhost(var.admin_cidr, 0))
    error_message = "admin_cidr must be a valid CIDR block, for example 203.0.113.25/32."
  }
}

variable "ssh_user" {
  description = "Login name created on the VM for your SSH key"
  type        = string
  default     = "almalinux"
}

variable "ssh_public_key_path" {
  description = "Path to the SSH public key to install on the VM"
  type        = string
  default     = "~/.ssh/id_ed25519.pub"
}

variable "machine_type" {
  description = "Compute Engine machine type"
  type        = string
  default     = "e2-small"
}

Put your own values in terraform.tfvars:

curl -s https://checkip.amazonaws.com

cat > terraform.tfvars <<'EOF'
project_id = "my-lab-project-123"
region     = "us-central1"
zone       = "us-central1-a"
admin_cidr = "203.0.113.25/32"
EOF

Example 3: A custom VPC network and subnet

Google Cloud VPC networks are global and subnets are regional. Setting auto_create_subnetworks = false gives you a custom-mode network, so you decide every address range instead of getting one subnet in every region. network.tf:

resource "google_compute_network" "lab" {
  name                    = "${var.name}-vpc"
  auto_create_subnetworks = false
  routing_mode            = "REGIONAL"
}

resource "google_compute_subnetwork" "web" {
  name                     = "${var.name}-web"
  network                  = google_compute_network.lab.id
  region                   = var.region
  ip_cidr_range            = "10.30.1.0/24"
  private_ip_google_access = true
}

Example 4: Firewall rules with network tags

Firewall rules belong to the network, not the VM. Each rule targets instances by network tag, so a VM only gets SSH or HTTP access if it carries the matching tag. firewall.tf:

resource "google_compute_firewall" "allow_ssh" {
  name          = "${var.name}-allow-ssh"
  network       = google_compute_network.lab.name
  direction     = "INGRESS"
  source_ranges = [var.admin_cidr]
  target_tags   = ["ssh"]

  allow {
    protocol = "tcp"
    ports    = ["22"]
  }
}

resource "google_compute_firewall" "allow_http" {
  name          = "${var.name}-allow-http"
  network       = google_compute_network.lab.name
  direction     = "INGRESS"
  source_ranges = ["0.0.0.0/0"]
  target_tags   = ["http-server"]

  allow {
    protocol = "tcp"
    ports    = ["80"]
  }
}

Custom networks deny all ingress by default, so without these rules nothing can reach the VM.

Example 5: An AlmaLinux 10 Compute Engine VM

The AlmaLinux OS Foundation publishes official images in the public almalinux-cloud project. Looking up the image family, rather than a fixed image name, always gives you the newest build. compute.tf:

data "google_compute_image" "almalinux" {
  family  = "almalinux-10"
  project = "almalinux-cloud"
}

resource "google_compute_address" "web" {
  name   = "${var.name}-web-ip"
  region = var.region
}

resource "google_compute_instance" "web" {
  name         = "${var.name}-web"
  machine_type = var.machine_type
  zone         = var.zone
  tags         = ["ssh", "http-server"]

  boot_disk {
    initialize_params {
      image = data.google_compute_image.almalinux.self_link
      size  = 20
      type  = "pd-balanced"
    }
  }

  network_interface {
    subnetwork = google_compute_subnetwork.web.id

    access_config {
      nat_ip = google_compute_address.web.address
    }
  }

  shielded_instance_config {
    enable_secure_boot          = true
    enable_vtpm                 = true
    enable_integrity_monitoring = true
  }

  metadata = {
    ssh-keys               = "${var.ssh_user}:${trimspace(file(pathexpand(var.ssh_public_key_path)))}"
    block-project-ssh-keys = "true"
  }

  metadata_startup_script = <<-EOT
    #!/bin/bash
    if ! rpm -q nginx >/dev/null 2>&1; then
      dnf -y install nginx
      echo "Hello from $(hostname) on $(cat /etc/almalinux-release)" > /usr/share/nginx/html/index.html
      systemctl enable --now nginx
    fi
  EOT

  allow_stopping_for_update = true
}

Points worth noting:

  • The ssh-keys metadata entry creates the almalinux user (or whatever you set in ssh_user) and installs your key. block-project-ssh-keys stops project-wide keys from also being accepted on this VM.
  • If your organisation enforces OS Login, metadata keys are ignored. Grant yourself the roles/compute.osAdminLogin role and log in with gcloud compute ssh instead.
  • The startup script runs on every boot, so it checks whether nginx is already installed before doing anything.
  • allow_stopping_for_update lets Terraform stop the VM to change settings such as the machine type, instead of failing.
  • For ARM machine types such as t2a-standard-1, use the almalinux-10-arm64 family.

Example 6: A private Cloud Storage bucket

Bucket names are global across all Google Cloud customers, so the project ID is used as a prefix. Uniform bucket-level access turns off per-object ACLs, and public access prevention blocks anyone from making the bucket public by mistake. storage.tf:

resource "google_storage_bucket" "artifacts" {
  name     = "${var.project_id}-${var.name}-artifacts"
  location = upper(var.region)

  storage_class               = "STANDARD"
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"
  force_destroy               = false

  versioning {
    enabled = true
  }

  lifecycle_rule {
    condition {
      num_newer_versions = 5
      with_state         = "ARCHIVED"
    }
    action {
      type = "Delete"
    }
  }
}

The lifecycle rule keeps at most five old versions of each object so versioning does not grow your bill forever.

Example 7: Outputs

output "image_name" {
  description = "AlmaLinux image the VM was built from"
  value       = data.google_compute_image.almalinux.name
}

output "web_public_ip" {
  description = "Static external IP of the web VM"
  value       = google_compute_address.web.address
}

output "ssh_command" {
  description = "Command to log in to the VM"
  value       = "ssh ${var.ssh_user}@${google_compute_address.web.address}"
}

output "artifacts_bucket" {
  description = "Name of the artifacts bucket"
  value       = google_storage_bucket.artifacts.name
}

output "vpc_self_link" {
  description = "Self link of the VPC network"
  value       = google_compute_network.lab.self_link
}

Step 5: Init, plan and apply

terraform init
terraform fmt -recursive
terraform validate
terraform plan -out=tfplan
terraform apply tfplan

Commit the generated .terraform.lock.hcl file so everyone uses the same provider build. A successful apply ends with output like this (names and addresses are illustrative):

Apply complete! Resources: 7 added, 0 changed, 0 destroyed.

Outputs:

artifacts_bucket = "my-lab-project-123-zwiegnet-lab-artifacts"
image_name = "almalinux-10-v20260915"
ssh_command = "ssh almalinux@34.123.x.x"
vpc_self_link = "https://www.googleapis.com/compute/v1/projects/my-lab-project-123/global/networks/zwiegnet-lab-vpc"
web_public_ip = "34.123.x.x"

Step 6: Verify the deployment

# Terraform's view
terraform output
terraform state list

# Google Cloud's view
gcloud compute instances list --filter="labels.project=zwiegnet-lab"
gcloud compute firewall-rules list --filter="network:zwiegnet-lab-vpc"
gcloud storage buckets describe "gs://$(terraform output -raw artifacts_bucket)" \
  --format="value(versioning_enabled,public_access_prevention)"

# The VM itself (give the startup script a minute after first boot)
curl "http://$(terraform output -raw web_public_ip)/"
ssh almalinux@"$(terraform output -raw web_public_ip)" 'cat /etc/almalinux-release'

You should see the VM in RUNNING state, True and enforced for the bucket, and a Hello from ... page from nginx. Run terraform plan once more; No changes means the code and the cloud agree.

Example 8: Remote state in Cloud Storage

The gcs backend stores state in a bucket and locks it automatically while a plan or apply runs, so there is nothing extra to set up for locking. Create a dedicated, versioned state bucket once:

STATE_BUCKET="my-lab-project-123-tfstate"
gcloud storage buckets create "gs://$STATE_BUCKET" --location=US-CENTRAL1 \
  --uniform-bucket-level-access --public-access-prevention
gcloud storage buckets update "gs://$STATE_BUCKET" --versioning

Add backend.tf. Backend blocks cannot use variables, so write the bucket name in directly:

terraform {
  backend "gcs" {
    bucket = "my-lab-project-123-tfstate"
    prefix = "lab"
  }
}

Move the local state into the bucket and check it arrived:

terraform init -migrate-state
gcloud storage ls "gs://$STATE_BUCKET/lab/"

State is stored as lab/default.tfstate. During a run you will also see a default.tflock object, which is removed when the run finishes.

Step 7: Destroy the lab

terraform destroy

Type yes to confirm. The artifacts bucket has force_destroy = false, so Terraform refuses to delete it while it still holds objects. Empty it with gcloud storage rm --recursive "gs://BUCKET/**" first, or set force_destroy = true and apply before destroying. Keep the state bucket if you plan to reuse it.

Troubleshooting

  • could not find default credentials: run gcloud auth application-default login. gcloud auth login alone does not create the credentials Terraform reads.
  • Compute Engine API has not been used in project ... or it is disabled: run gcloud services enable compute.googleapis.com and wait a minute before retrying.
  • The resource ... was not found for the image: check the family exists with gcloud compute images list --project=almalinux-cloud --filter="family~almalinux".
  • The bucket name ... is not available: bucket names are global. Change the prefix or name variable.
  • SSH is refused or times out: confirm admin_cidr still matches your public IP, the VM has the ssh tag, and OS Login is not enforced on the project (gcloud compute project-info describe --format="value(commonInstanceMetadata.items)").
  • nginx does not answer: read the startup script log with gcloud compute instances get-serial-port-output zwiegnet-lab-web --zone us-central1-a | grep startup-script or, on the VM, sudo journalctl -u google-startup-scripts.
  • Error acquiring the state lock: another run holds the lock. When you are sure nothing else is running, use terraform force-unlock LOCK_ID.
  • Quota exceeded: new projects have low quotas for external IPs and CPUs in each region. Check IAM & Admin > Quotas in the console or pick another region.

Next steps

From here, put the VM behind a managed instance group and load balancer, move the network into the terraform-google-modules/network/google module, and run terraform plan in CI on every pull request. The next posts in this series cover the same lab on Microsoft Azure and Oracle Cloud Infrastructure. For the AWS version, see Terraform Examples for Amazon AWS from AlmaLinux 10.

Leave a Reply

Your email address will not be published. Required fields are marked *