In the previous lab, I created a private VM with no external IP and accessed it through IAP.
That lab worked for private access, but it also exposed an important issue.
The VM could be private, but without outbound internet access, the startup script could fail when trying to install packages.
In my case, Nginx installation failed because the VM had no external IP and no Cloud NAT.
That taught me an important distinction:
IAP = controlled inbound administrative access
Cloud NAT = outbound internet access for private resources
So in this lab, I wanted to build a more complete serving pattern.
Instead of using a single VM, I moved to:
instance template -> Managed Instance Group -> health check -> backend service -> HTTP load balancer
I also added Cloud NAT so the private backend instances can install Nginx during startup without needing external IP addresses.
What This Lab Builds
This lab provisions:
- custom VPC network
- app subnet
- db subnet
- Cloud Router
- Cloud NAT
- custom service account for MIG instances
- IAM bindings
- firewall rule for Google Cloud load balancer and health check traffic
- internal firewall rule
- HTTP health check
- instance template
- regional managed instance group
- backend service
- URL map
- target HTTP proxy
- global forwarding rule
- external HTTP load balancer IP
The high-level architecture is:
Client
↓
Global forwarding rule
↓
Target HTTP proxy
↓
URL map
↓
Backend service
↓
Regional Managed Instance Group
↓
Private backend VMs
↓ outbound only
Cloud NAT
Why This Lab Matters
The previous private VM lab was about controlled administrative access.
This lab is about serving traffic properly.
A single VM is not enough for a production-like serving pattern.
A better pattern is to place backend instances inside a Managed Instance Group and expose them through a load balancer.
The backend instances remain private.
The public entry point is the load balancer.
Cloud NAT gives the backend instances outbound internet access for startup tasks such as package installation.
Folder Structure
The folder structure for this lab is:
08-mig-nat-http-lb/
├── backend.tf
├── main.tf
├── outputs.tf
├── startup.sh
├── terraform.tfvars
├── variables.tf
├── modules/
│ ├── gcp-cloud-nat/
│ ├── gcp-http-lb/
│ ├── gcp-mig/
│ ├── gcp-network/
│ └── gcp-service-account/
This lab uses five modules:
| Module | Responsibility |
|---|---|
gcp-network |
Creates VPC, subnets, and firewall rules |
gcp-cloud-nat |
Creates Cloud Router and Cloud NAT |
gcp-service-account |
Creates a custom service account |
gcp-mig |
Creates instance template and managed instance group |
gcp-http-lb |
Creates the HTTP load balancer resources |
Remote State
This lab still uses Google Cloud Storage as the Terraform backend.
terraform {
backend "gcs" {
bucket = "terraform-gcp-learning-lab-terraform-state"
prefix = "terraform-gcp-learning-lab/08-mig-http-load-balancer"
}
}
Each lab uses a different prefix so the state files do not collide.
Root Module
The root module wires all child modules together.
module "network" {
source = "./modules/gcp-network"
environment = var.environment
region = var.region
network_name = var.network_name
subnets = var.subnets
firewall_rules = var.firewall_rules
}
module "cloud_nat" {
source = "./modules/gcp-cloud-nat"
environment = var.environment
region = var.region
network_self_link = module.network.network_self_link
}
module "service_account" {
source = "./modules/gcp-service-account"
account_id = "${var.environment}-${var.mig_service_account_id}"
display_name = "${var.environment}-${var.mig_service_account_display_name}"
}
module "mig" {
source = "./modules/gcp-mig"
service_account_email = module.service_account.email
environment = var.environment
mig_name = var.mig_name
region = var.region
zone = var.mig_zone
machine_type = var.mig_machine_type
subnetwork_self_link = module.network.subnets[var.mig_subnet_key].self_link
tags = var.mig_tags
startup_script_path = "${path.module}/startup.sh"
target_size = var.mig_instance_count
app_port = var.app_port
health_check_self_link = google_compute_health_check.http.self_link
depends_on = [module.cloud_nat]
}
module "http_lb" {
source = "./modules/gcp-http-lb"
environment = var.environment
lb_name = var.lb_name
backend_instance_group = module.mig.mig_instance_group
health_check_self_link = google_compute_health_check.http.self_link
app_port = var.app_port
}
The important dependency flow is:
network module -> Cloud NAT module
network module -> MIG module
service account module -> MIG module
MIG module -> HTTP load balancer module
The most important lines are:
network_self_link = module.network.network_self_link
subnetwork_self_link = module.network.subnets[var.mig_subnet_key].self_link
service_account_email = module.service_account.email
backend_instance_group = module.mig.mig_instance_group
These lines connect the modules together.
Cloud NAT Module
The Cloud NAT module creates a Cloud Router and Cloud NAT gateway.
resource "google_compute_router" "router" {
name = "${var.environment}-${var.router_name}"
region = var.region
network = var.network_self_link
}
resource "google_compute_router_nat" "nat" {
name = "${var.environment}-${var.nat_name}"
router = google_compute_router.router.name
region = var.region
nat_ip_allocate_option = "AUTO_ONLY"
source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"
log_config {
enable = true
filter = "ERRORS_ONLY"
}
}
This solves the issue from the previous lab.
The backend instances can remain private while still having outbound internet access.
Service Account Module
This lab also creates a custom service account.
resource "google_service_account" "this" {
account_id = var.account_id
display_name = var.display_name
}
The MIG instances use this service account instead of relying on the default Compute Engine service account.
The root module passes the service account email into the MIG module:
service_account_email = module.service_account.email
MIG Module
The MIG module creates an instance template and a regional managed instance group.
resource "google_compute_instance_template" "template" {
name_prefix = "${var.environment}-${var.mig_name}-template-"
machine_type = var.machine_type
tags = var.tags
disk {
source_image = "debian-cloud/debian-12"
auto_delete = true
boot = true
disk_size_gb = 10
disk_type = "pd-balanced"
}
network_interface {
subnetwork = var.subnetwork_self_link
}
metadata_startup_script = file(var.startup_script_path)
lifecycle {
create_before_destroy = true
}
service_account {
email = var.service_account_email
scopes = ["https://www.googleapis.com/auth/cloud-platform"]
}
}
There is no external IP configuration in the network interface.
That means the backend instances remain private.
The regional managed instance group uses the template:
resource "google_compute_region_instance_group_manager" "mig" {
name = "${var.environment}-${var.mig_name}"
region = var.region
base_instance_name = "${var.environment}-${var.mig_name}"
target_size = var.target_size
version {
instance_template = google_compute_instance_template.template.self_link
}
named_port {
name = "http"
port = var.app_port
}
distribution_policy_zones = [var.zone]
auto_healing_policies {
health_check = var.health_check_self_link
initial_delay_sec = 120
}
}
The named port is important because the backend service uses:
port_name = "http"
So the MIG and backend service need to agree on the named port.
HTTP Load Balancer Module
The HTTP load balancer module creates:
- global IP address
- backend service
- URL map
- target HTTP proxy
- global forwarding rule
resource "google_compute_global_address" "lb_ip" {
name = "${var.environment}-${var.lb_name}-ip"
}
resource "google_compute_backend_service" "backend" {
name = "${var.environment}-${var.lb_name}-backend"
protocol = "HTTP"
port_name = "http"
load_balancing_scheme = "EXTERNAL_MANAGED"
timeout_sec = 30
health_checks = [
var.health_check_self_link
]
backend {
group = var.backend_instance_group
balancing_mode = "UTILIZATION"
capacity_scaler = 1.0
}
}
The backend service points to the instance group output from the MIG module:
backend_instance_group = module.mig.mig_instance_group
That is the connection between the load balancer and the backend instances.
Firewall Rules
The firewall rules are still created by the network module.
The load balancer and health check rule allows:
source_ranges = ["35.191.0.0/16", "130.211.0.0/22"]
target_tags = ["web-backend"]
The MIG instances also use:
mig_tags = ["web-backend"]
This means the firewall rule applies to the backend instances.
The rule allows traffic on port 80:
allow = [
{
protocol = "tcp"
ports = ["80"]
}
]
Startup Script
The startup script installs Nginx and creates a simple HTML page.
#!/bin/bash
set -euo pipefail
apt-get update -y
apt-get install -y nginx
HOSTNAME="$(hostname)"
LOCAL_IP="$(hostname -I | awk '{print $1}')"
cat > /var/www/html/index.html <<EOF
<!doctype html>
<html>
<head>
<title>Terraform MIG Load Balancer Lab</title>
</head>
<body>
<h1>Hello from Terraform Lab 008</h1>
<p>This page is served from a private VM inside a Managed Instance Group.</p>
<p>Hostname: ${HOSTNAME}</p>
<p>Internal IP: ${LOCAL_IP}</p>
</body>
</html>
EOF
systemctl enable nginx
systemctl restart nginx
In the previous private VM lab, this script failed because the VM had no external IP and no Cloud NAT.
In this lab, Cloud NAT should allow the backend instances to reach the internet and install Nginx successfully.
Variables
For local values, I used terraform.tfvars.
Public article version:
project = "your-gcp-project-id"
region = "asia-southeast2"
environment = "dev"
admin_principal = "user:your-email@example.com"
network_name = "mig-lb-network"
subnets = {
app = {
cidr_range = "10.80.1.0/24"
}
db = {
cidr_range = "10.80.2.0/24"
}
}
firewall_rules = {
allow-lb-health-check = {
description = "Allow Google Cloud load balancer health checks and proxy traffic."
source_ranges = ["35.191.0.0/16", "130.211.0.0/22"]
target_tags = ["web-backend"]
allow = [
{
protocol = "tcp"
ports = ["80"]
}
]
}
allow-internal = {
description = "Allow internal traffic between lab subnets."
source_ranges = ["10.80.0.0/16"]
allow = [
{
protocol = "tcp"
ports = ["0-65535"]
},
{
protocol = "udp"
ports = ["0-65535"]
},
{
protocol = "icmp"
}
]
}
}
mig_name = "web-mig"
mig_instance_count = 2
mig_machine_type = "e2-micro"
mig_zone = "asia-southeast2-a"
mig_subnet_key = "app"
mig_tags = ["web-backend"]
mig_service_account_id = "web-mig-sa"
mig_service_account_display_name = "Web MIG Service Account"
lb_name = "web-lb"
app_port = 80
I do not commit the real terraform.tfvars file to GitHub.
For GitHub, I use:
terraform.tfvars.example
Initialize, Format, and Validate
After preparing the files, I ran:
terraform init
Then:
terraform fmt -recursive
Then:
terraform validate
The validation result was:
Success! The configuration is valid.
Terraform Plan
The plan should show resources across multiple modules:
module.network
module.cloud_nat
module.service_account
module.mig
module.http_lb
The important thing here is not just the number of resources.
The important thing is the dependency chain:
VPC and subnets are created first.
Cloud NAT is attached to the network.
Instance template uses the app subnet and service account.
MIG creates backend instances.
Backend service uses the MIG instance group.
Load balancer exposes the backend service.
Terraform Apply
After reviewing the plan:
terraform apply
Then type:
yes
The apply process may take several minutes because the MIG needs time to create backend instances and the load balancer needs time to detect healthy backends.
Outputs
Useful outputs from this lab include:
terraform output load_balancer_ip
terraform output load_balancer_url
terraform output curl_test_command
terraform output lab_summary
The most important output is:
load_balancer_url
This is the public HTTP endpoint for the lab.
Testing the Load Balancer
To test the load balancer:
curl -i $(terraform output -raw load_balancer_url)
Expected result:
HTTP/1.1 200 OK
The response body should contain:
Hello from Terraform Lab 008
To test whether traffic may reach different backend instances:
for i in {1..5}; do
curl -s $(terraform output -raw load_balancer_url) | grep Hostname
done
If both backend instances are healthy and receiving traffic, the hostname may change across requests.
Verifying Backend Health
I can check backend health using:
gcloud compute backend-services get-health dev-web-lb-backend \
--global
Expected health state:
HEALTHY
If the backend is unhealthy, possible causes include:
- startup script still running
- Nginx failed to install
- health check firewall rule is wrong
- named port mismatch
- backend instances are not listening on port 80
Verifying Private Backend Instances
The backend instances should not have external IP addresses.
gcloud compute instances list \
--filter="name~dev-web-mig"
The external IP column should be empty.
This confirms that the backend instances are private.
The load balancer is public, but the backend instances are not directly exposed.
Verifying Cloud NAT
To verify Cloud NAT:
gcloud compute routers nats list \
--router=dev-nat-router \
--region=asia-southeast2
Expected result:
dev-nat-gateway
This confirms that Cloud NAT exists for the VPC.
Verifying Remote State
The remote state should be stored in Google Cloud Storage:
gcloud storage ls gs://terraform-gcp-learning-lab-terraform-state/terraform-gcp-learning-lab/08-mig-http-load-balancer/
Expected result:
gs://terraform-gcp-learning-lab-terraform-state/terraform-gcp-learning-lab/08-mig-http-load-balancer/default.tfstate
What I Learned
This lab connected several important Terraform and Google Cloud concepts.
The previous labs taught me:
how to create resources
how to use variables
how to use outputs
how to use remote state
how to create modules
how to create a private VM
This lab combined those concepts into a more realistic serving architecture.
The most important lesson is:
A private backend can still serve public traffic through a load balancer.
The backend instances do not need external IP addresses.
The public entry point is the load balancer.
The backend instances can still install packages during startup because Cloud NAT provides outbound internet access.
Another important lesson is how Terraform modules compose infrastructure:
network module creates network outputs
Cloud NAT module uses network output
service account module creates workload identity
MIG module uses subnet and service account outputs
load balancer module uses MIG output
Next Step
This lab uses an HTTP load balancer.
The next improvement would be to move from HTTP to HTTPS.
That would introduce:
- managed SSL certificate
- domain mapping
- HTTPS proxy
- forwarding rule on port 443
- possibly Cloud DNS
That would make the infrastructure closer to a real public-facing production setup.
Top comments (0)