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.
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
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:
| File | What it's for |
|---|---|
versions.tf | Terraform + provider version pins, so the config is reproducible |
variables.tf | The input variables (the knobs); project_id is the only required one |
main.tf | The provider, the image lookup, the VPC/subnet/firewalls, and the instance |
outputs.tf | Values printed after an apply (instance name, public IP, resolved image, a ready-to-run SSH command) |
terraform.tfvars.example | Sample variable values — copy to terraform.tfvars, set project_id (no secrets) |
.gitignore | Keeps state, secrets, plugins and terraform.tfvars out of git |
README.md | The 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.
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.
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.
# 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.
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.
# 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"
# 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
plan shows exactly what would change before you apply it.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.