DEV Community

Abraham Naiborhu
Abraham Naiborhu

Posted on

Managed Instance Group, Cloud NAT, Service Account, and HTTP Load Balancer

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

The named port is important because the backend service uses:

port_name = "http"
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

The backend service points to the instance group output from the MIG module:

backend_instance_group = module.mig.mig_instance_group
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

The MIG instances also use:

mig_tags = ["web-backend"]
Enter fullscreen mode Exit fullscreen mode

This means the firewall rule applies to the backend instances.

The rule allows traffic on port 80:

allow = [
  {
    protocol = "tcp"
    ports    = ["80"]
  }
]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

I do not commit the real terraform.tfvars file to GitHub.

For GitHub, I use:

terraform.tfvars.example
Enter fullscreen mode Exit fullscreen mode

Initialize, Format, and Validate

After preparing the files, I ran:

terraform init
Enter fullscreen mode Exit fullscreen mode

Then:

terraform fmt -recursive
Enter fullscreen mode Exit fullscreen mode

Then:

terraform validate
Enter fullscreen mode Exit fullscreen mode

The validation result was:

Success! The configuration is valid.
Enter fullscreen mode Exit fullscreen mode

Terraform Plan

The plan should show resources across multiple modules:

module.network
module.cloud_nat
module.service_account
module.mig
module.http_lb
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Terraform Apply

After reviewing the plan:

terraform apply
Enter fullscreen mode Exit fullscreen mode

Then type:

yes
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The most important output is:

load_balancer_url
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Expected result:

HTTP/1.1 200 OK
Enter fullscreen mode Exit fullscreen mode

The response body should contain:

Hello from Terraform Lab 008
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Expected health state:

HEALTHY
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Expected result:

dev-nat-gateway
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

Expected result:

gs://terraform-gcp-learning-lab-terraform-state/terraform-gcp-learning-lab/08-mig-http-load-balancer/default.tfstate
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)