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-smallCompute Engine VM built from the latest image in thealmalinux-10family, 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-smallVM 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-keysmetadata entry creates thealmalinuxuser (or whatever you set inssh_user) and installs your key.block-project-ssh-keysstops 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.osAdminLoginrole and log in withgcloud compute sshinstead. - The startup script runs on every boot, so it checks whether nginx is already installed before doing anything.
allow_stopping_for_updatelets 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 thealmalinux-10-arm64family.
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: rungcloud auth application-default login.gcloud auth loginalone does not create the credentials Terraform reads.Compute Engine API has not been used in project ... or it is disabled: rungcloud services enable compute.googleapis.comand wait a minute before retrying.The resource ... was not foundfor the image: check the family exists withgcloud compute images list --project=almalinux-cloud --filter="family~almalinux".The bucket name ... is not available: bucket names are global. Change the prefix ornamevariable.- SSH is refused or times out: confirm
admin_cidrstill matches your public IP, the VM has thesshtag, 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-scriptor, 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, useterraform 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.
