Professional Services Infrastructure as Code

Server provisioning with Terraform & GCP

One Always Free GCP virtual machine — an e2-micro running the latest Ubuntu 24.04 LTS, with an ephemeral public IP and firewall rules for SSH, HTTP and HTTPS — declared entirely in version-controlled .tf files. GCP has a default network, but this config builds its own small VPC + subnet + firewall so it's fully self-contained. Scoped to exactly one VM, with no credentials in the code. terraform apply to create it, terraform destroy to make it disappear.

Back to Professional Services
1
VM provisioned
5
Resources created
$0
On Always Free
7
Config files

What this is

This is the write-up that accompanies the studio's GCP Terraform guide — a step-by-step walkthrough of a GCP-only configuration (terraform/gcp-freetier-vm) that provisions exactly one Always Free e2-micro instance running the latest Ubuntu 24.04 LTS, with an ephemeral public IP and firewall rules open for SSH (22), HTTP (80) and HTTPS (443). The guide explains what every file does, what every entry inside each file is for, and the exact commands used to apply it.

GCP sits in the middle of the clouds: it has a default network (like AWS), but this module builds its own small VPC + subnet + firewall (like Azure and OCI) so it's fully self-contained. The free-tier shape is e2-micro, which is Always Free only in us-west1, us-central1 and us-east1 — and a project with billing enabled is required even for the free tier. It stores no credentials: auth comes from Application Default Credentials (the file gcloud writes when you log in) and only your public SSH key is ever injected. The example .tf files below are the real configuration from the guide.

Terraform in 30 seconds

Terraform is infrastructure as code: you declare the resources you want in .tf files and Terraform works out the API calls to create, change, or delete them to match. The pieces you'll see below:

  • Provider — a plugin that talks to one platform's API (here, google).
  • Resource — a thing Terraform creates and manages (a network, a firewall, the instance).
  • Data source — a read-only lookup of something that already exists (the latest OS image).
  • Variable — a typed input, so the same code is reusable per run.
  • Output — a value Terraform prints after an apply (e.g. the instance's public IP).
  • State — Terraform's record of what it created (terraform.tfstate), kept out of git because it can hold sensitive values.

What it provisions

Terraform apply creating one Always Free GCP VM in its own VPC Running terraform apply from your machine authenticates to GCP with Application Default Credentials. A read-only data source looks up the latest Ubuntu 24.04 image. Terraform then builds a small custom VPC and subnet, two firewall rules targeting the instance by network tag (one for SSH, one for HTTP and HTTPS), and a single e2-micro instance with an ephemeral public IP. terraform apply auth: ADC (gcloud) injects public SSH key data source (read-only) google_compute_image → latest Ubuntu 24.04 GCP · custom VPC + subnet (us-west1) firewall: allow-ssh tcp 22 · target tag firewall: allow-web tcp 80 / 443 · target tag google_compute_instance · e2-micro Ubuntu 24.04 · 30 GB pd-standard · public IP tagged so the firewalls apply — one VM
One provider, a read-only image lookup, a self-contained VPC + subnet, two tag-targeted firewall rules, and a single public e2-micro instance.

The configuration, file by file

Terraform reads all .tf files in a directory as one configuration, so the split below is purely for human organization. The directory contains:

FileWhat it's for
versions.tfTerraform + provider version pins, so the config is reproducible
variables.tfThe input variables (the knobs); project_id is the only required one
main.tfThe provider, the image lookup, the VPC/subnet/firewalls, and the instance
outputs.tfValues printed after an apply (instance name, public IP, resolved image, a ready-to-run SSH command)
terraform.tfvars.exampleSample variable values — copy to terraform.tfvars, set project_id (no secrets)
.gitignoreKeeps state, secrets, plugins and terraform.tfvars out of git
README.mdThe module's own quick-start (this page is the deeper companion)

versions.tf — version constraints

Refuse to run on a Terraform CLI older than 1.5, and pin the Google provider to any 6.x (but not 7.0) so a surprise breaking upgrade can't sneak in.

versions.tf
terraform {
  required_version = ">= 1.5.0"

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

variables.tf — the inputs

Every variable has a description and a type; all but project_id have a default, so the config runs with just your project id. Note the region must be a free-tier one (us-west1 / us-central1 / us-east1) for the e2-micro to stay Always Free.

variables.tf
variable "project_id" {
  description = "GCP project id to deploy into. Get it with: gcloud config get-value project (or gcloud projects list)."
  type        = string
}

variable "region" {
  description = "GCP region. Always Free e2-micro is free ONLY in us-west1, us-central1, us-east1."
  type        = string
  default     = "us-west1"
}

variable "zone" {
  description = "GCP zone within the region (must be region + a letter, e.g. us-west1-a — not the bare region)."
  type        = string
  default     = "us-west1-a"
}

variable "name" {
  description = "Name / prefix applied to the created resources."
  type        = string
  default     = "freetier-vm"
}

variable "machine_type" {
  description = "GCE machine type. e2-micro is the Always Free shape (2 shared vCPU, 1 GB) in the free regions."
  type        = string
  default     = "e2-micro"
}

variable "image" {
  description = "Image family to boot from, as project/family. Default is the latest Ubuntu 24.04 LTS (x86)."
  type        = string
  default     = "ubuntu-os-cloud/ubuntu-2404-lts-amd64"
}

variable "ssh_user" {
  description = "Login user created from the SSH key metadata (Ubuntu images: ubuntu)."
  type        = string
  default     = "ubuntu"
}

variable "public_key_path" {
  description = "Path to the SSH PUBLIC key. The matching PRIVATE key stays on your machine and is never uploaded or committed."
  type        = string
  default     = "~/.ssh/id_rsa.pub"
}

variable "ssh_source_range" {
  description = "CIDR allowed to reach SSH (port 22). Defaults to the whole internet; tighten to your IP (e.g. \"203.0.113.4/32\")."
  type        = string
  default     = "0.0.0.0/0"
}

variable "subnet_cidr" {
  description = "Address range for the subnet the VM lives in."
  type        = string
  default     = "10.0.1.0/24"
}

variable "boot_disk_size_gb" {
  description = "Boot disk size in GB. Free tier includes 30 GB of standard persistent disk."
  type        = number
  default     = 30
}

variable "boot_disk_type" {
  description = "Boot disk type. pd-standard (standard PD) is the free-tier-eligible type."
  type        = string
  default     = "pd-standard"
}

main.tf — the provider, lookup, and resources

The heart of the configuration, read top to bottom: configure the google provider; resolve the latest Ubuntu image with a read-only data source; build a small custom VPC, a subnet, and two firewall rules that target the instance by network tag; then declare the single instance.

main.tf
# SCOPE: this configuration is GCP-only and provisions exactly ONE VM.
# It declares only the hashicorp/google provider, and the single
# google_compute_instance below uses no count/for_each. It is intentionally NOT a
# multi-cloud module and will not deploy to AWS/Azure/OCI — those clouds get their
# own separate configurations.
provider "google" {
  # Auth uses Application Default Credentials (gcloud auth application-default
  # login). No credentials live in this code.
  project = var.project_id
  region  = var.region
  zone    = var.zone
}

# Resolve the latest image in the requested family (e.g. Ubuntu 24.04 LTS). The
# `image` var is "project/family"; split it for the data source.
data "google_compute_image" "this" {
  project = split("/", var.image)[0]
  family  = split("/", var.image)[1]
}

# Build a small dedicated network so the module is self-contained (rather than
# adding firewall rules to the shared "default" network).
resource "google_compute_network" "this" {
  name                    = "${var.name}-net"
  auto_create_subnetworks = false
}

resource "google_compute_subnetwork" "this" {
  name          = "${var.name}-subnet"
  region        = var.region
  network       = google_compute_network.this.id
  ip_cidr_range = var.subnet_cidr
}

# Firewall: SSH (restrictable to your IP) ...
resource "google_compute_firewall" "ssh" {
  name      = "${var.name}-allow-ssh"
  network   = google_compute_network.this.id
  direction = "INGRESS"

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

  source_ranges = [var.ssh_source_range]
  target_tags   = [var.name]
}

# ... and HTTP/HTTPS open to the internet. (Egress is allowed by default in GCP.)
resource "google_compute_firewall" "web" {
  name      = "${var.name}-allow-web"
  network   = google_compute_network.this.id
  direction = "INGRESS"

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

  source_ranges = ["0.0.0.0/0"]
  target_tags   = [var.name]
}

# Exactly one VM: a single resource with no count/for_each. Do not add count or
# for_each here — this module is deliberately scoped to one instance.
resource "google_compute_instance" "this" {
  name         = var.name
  machine_type = var.machine_type
  zone         = var.zone
  tags         = [var.name] # matches the firewall target_tags

  boot_disk {
    initialize_params {
      image = data.google_compute_image.this.self_link
      size  = var.boot_disk_size_gb
      type  = var.boot_disk_type
    }
  }

  network_interface {
    subnetwork = google_compute_subnetwork.this.id
    # An empty access_config block assigns an ephemeral public IP.
    access_config {}
  }

  # Key-only login: GCP reads "USERNAME:KEYDATA" from the ssh-keys metadata.
  metadata = {
    ssh-keys = "${var.ssh_user}:${trimspace(file(pathexpand(var.public_key_path)))}"
  }
}

outputs.tf — what you get back

After an apply, Terraform prints these (and terraform output <name> re-prints any one). The public_ip is the ephemeral external IP reached through the interface's access_config; the ssh_command builds the private-key path and uses the ssh_user.

outputs.tf
output "instance_name" {
  description = "Name of the instance."
  value       = google_compute_instance.this.name
}

output "public_ip" {
  description = "Ephemeral public IPv4 address of the instance."
  value       = google_compute_instance.this.network_interface[0].access_config[0].nat_ip
}

output "image" {
  description = "Resolved boot image."
  value       = data.google_compute_image.this.self_link
}

output "ssh_command" {
  description = "Ready-to-run SSH command."
  value       = "ssh -i ${replace(pathexpand(var.public_key_path), ".pub", "")} ${var.ssh_user}@${google_compute_instance.this.network_interface[0].access_config[0].nat_ip}"
}

terraform.tfvars.example & .gitignore — safe by default

The example tfvars is a copy-me template containing no secrets — only your project id, the region/zone, and the path to your public key. The real terraform.tfvars and all Terraform state are gitignored, because state can contain sensitive values and should never be committed.

terraform.tfvars.example
# Copy to terraform.tfvars and set project_id (the only required value). No secrets
# belong here (auth comes from gcloud Application Default Credentials).
# terraform.tfvars is gitignored so your local choices stay out of the repo.

# REQUIRED — your GCP project id. Get it with: gcloud config get-value project
project_id = "my-project-123456"

# Always Free e2-micro is free ONLY in us-west1, us-central1, us-east1.
region = "us-west1"
zone   = "us-west1-a"

name         = "freetier-vm"
machine_type = "e2-micro"
image        = "ubuntu-os-cloud/ubuntu-2404-lts-amd64"

ssh_user        = "ubuntu"
public_key_path = "~/.ssh/id_rsa.pub"

# Tighten to your own IP for a smaller attack surface, e.g. "203.0.113.4/32".
ssh_source_range = "0.0.0.0/0"
.gitignore
# Never commit Terraform state (can contain resource details / sensitive values),
# local provider plugins, crash logs, plan files, or your local variable choices
# (terraform.tfvars holds your project id).
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.*
crash.log
*.tfplan
terraform.tfvars
*.auto.tfvars

No secrets in the repo. GCP auth comes from Application Default Credentials (gcloud auth application-default login), never the code; only the public SSH key is injected; and state plus local variable files are gitignored. Every change to the infrastructure is a reviewable diff in git.

The apply workflow

The terraform init, plan, apply, destroy workflow on GCP The workflow runs left to right: copy the example tfvars and set your project id, terraform init downloads the google provider, terraform apply creates the network, firewalls and one instance and prints the SSH command, and terraform destroy tears everything back down when you are done. set project_id in terraform.tfvars terraform init download provider terraform apply create + print outputs ssh ubuntu@… from ssh_command output terraform destroy destroy when you're done — e2-micro is Always Free only within the limits
The same four commands every time — and because it's all declarative, re-running plan shows exactly what would change before you apply it.
apply & tear down
cd terraform/gcp-freetier-vm

# auth already done: gcloud auth application-default login
cp terraform.tfvars.example terraform.tfvars   # then set project_id

terraform init      # downloads the google provider into .terraform/
terraform plan      # preview: network/subnet/firewalls + one instance
terraform apply     # type 'yes' to actually create them

terraform output ssh_command   # the exact command to connect

terraform destroy   # type 'yes' — removes the instance and network

Free, in the right region. An e2-micro in us-west1 / us-central1 / us-east1, with a 30 GB pd-standard disk and the network, is $0 within Always Free limits — outside those regions or limits it bills. A project with billing enabled is required even for the free tier, so terraform destroy when you're finished.

This is the GCP sibling of the AWS configuration; the same VM exists for Azure and OCI, and the multi-cloud configuration stands up all four at once. Once a box is up, the same fleet is patched and hardened with the Ansible playbooks, and a push to main becomes a multi-OS build and release through the CI/CD pipeline.

Want your cloud infrastructure described in code, not clicked together in a console?

Reproducible, reviewable provisioning you can stand up and tear down on demand — let's talk.