☁️Multi-Regional 2-Tier Web Architecture on GCP Using Terraform and GitHub Actions

Thulitha Nawagamuwa
Google Cloud - Community
25 min readJun 17, 2024

In this project, I will explain how to build a Multi-Regional 2-tier Web Architecture on GCP using Terraform and GitHub Actions. I have utilized various GCP resources in this project, including Cloud Load Balancing, Cloud CDN, Cloud Storage, Cloud IAM, Secret Manager, Cloud NAT, Compute Engine, Cloud SQL, and other cloud services.

🚀 Project Overview

Architecture Design

This project demonstrates the construction of a robust, scalable, and highly available 2-tier web architecture spread across multiple GCP regions.

Project Architecture

The architecture separates the web and database layers, ensuring better performance and manageability. The core components include:

  • Cloud DNS: Provides scalable, reliable, and managed authoritative Domain Name System (DNS) service.
  • Cloud Load Balancing: Distributes incoming traffic across multiple Compute Engine instances to ensure high availability and reliability.
  • Cloud CDN: Caches content at the edge locations to improve load times and reduce latency for end-users.
  • Cloud Storage: Stores static content, including the static web page and backend web pages.
  • Cloud IAM: Manages access control and ensures that only authorized entities can access resources.
  • Secret Manager: Stores and manages sensitive information such as API keys and database passwords securely.
  • Cloud NAT: Provides outbound internet access for instances in private networks.
  • Compute Engine: Hosts the web and application servers.
  • Cloud SQL: Manages relational databases with high availability configurations.

System Design Best Practices

Incorporating system design best practices, the architecture focuses on:

  • Load Balancing: Ensures even distribution of traffic and reduces the risk of any single point of failure.
  • Caching: Improves performance by storing frequently accessed data closer to the end-users.
  • High Availability: Achieved through redundancy and failover mechanisms across multiple regions.
  • Scalability: Allows the infrastructure to handle increased load by automatically scaling resources.
  • Enhanced Security: Implements stringent access controls, encryption, and secure management of secrets.
  • Versioning: Keeps track of changes to the infrastructure code, enabling rollbacks and better collaboration.

🏗 Infrastructure as Code (IaC) Perspective

The primary focus of this project is provisioning the architecture with an Infrastructure as Code (IaC) approach. IaC is a versatile method for designing cloud infrastructure, offering various capabilities such as:

  • Versioning: Track changes to the infrastructure configuration, allowing for easy rollbacks and auditing.
  • Collaboration: Multiple team members can work on the infrastructure code simultaneously, improving productivity and reducing errors.
  • Reusability: Templates and modules can be reused across different projects, ensuring consistency and saving time.

I have used Terraform as the IaC tool in this project due to its powerful features and community support. Terraform allows us to define the infrastructure in declarative configuration files, which can be version-controlled and shared.

🔁Continuous Deployment with GitHub Actions

For the continuous deployment part, I utilized GitHub Actions. GitHub Actions is a flexible CI/CD tool that automates the deployment process. Key benefits include:

  • Automation: Automatically deploy changes to the infrastructure when updates are pushed to the repository.
  • Integration: Seamlessly integrates with GitHub repositories, triggering workflows based on specific events.
  • Scalability: Handles complex workflows and parallel job executions.

Now, Let’s dive deep into the Project!

Prerequisites

We need the following requirements for this project:

  1. GCP Account
  2. GitHub Repository
  3. A local machine with Git installed
  4. Publically accessible Domain

Before going into the Terraform Code, let’s complete the below actions.

  1. Enable Required APIs in GCP
  2. Create a Cloud Storage Bucket to store the Terraform State File
  3. Create a Service Account and Add credentials to GitHub
  4. Create a Cloud DNS Zone on GCP
  5. Setting up GitHub Actions

1. Enable Required APIs in GCP

To proceed with the project, we need to enable the APIs below in GCP.

  • Cloud Resource Manager API
  • Service Networking API
  • Cloud SQL Admin API
  • Secret Manager API

2. Create a Cloud Storage Bucket to store the Terraform State File

We need to set up a cloud storage bucket to keep our Terraform state file.

A Terraform state file tracks the current state of our infrastructure.

Storing this file remotely in a Cloud Storage Bucket ensures consistent and secure state management, allowing multiple team members to collaborate and maintain the infrastructure’s state across various environments.

Let’s follow the below steps to create a Cloud Storage Bucket.

  1. In the Google Cloud console, go to the Cloud Storage and then the Buckets page.
  2. Click on Create and add the configuration as below.
  • Choose a Name and a Region suitable for your bucket → CREATE

Now, our Terraform State Bucket is successfully created. This will be used as a Terraform Remote Backend in our code.

3. Create a Service Account and Add credentials to GitHub

When the Terraform code is executed from the GitHub Actions Pipeline, it must be authenticated with the Google Cloud Platform. To do this, we need to create a service account with Google Cloud.

Let’s follow the below Steps.

  1. Go to IAM & Admin → Service accounts → Create service account
  2. Enter a service account name to display in the Google Cloud console
  3. Provide the roles below to the service account.
  • Editor — View, create, update, and delete most Google Cloud resources.
  • Service Networking Admin — Full control of service networking with projects.
  • Secret Manager Secret Accessor — Allows accessing the payload of secrets.

Click on Done.

In a production environment, we should provide the minimum required permissions to the service account.

Now, the created service account can be seen on the IAM panel.

4. Now, Let’s add a key to this service account.

  • Click on the Service Account → KEYS → Add Key → Create a new key → JSON KEY → Create
  • The JSON Key will be downloaded to your local machine.

5. Now, let’s add the JSON Key to GitHub to authenticate with the GCP when Terraform executes from GitHub.

  • Go to Settings → Secrets and Variables → Actions
  • Under Secrets, create a New repository secret.
  • Give a name for the secret (Eg: GCP_SA_KEY) and Paste the JSON Key file and apply it.

So now the GitHub Actions should be able to authenticate with GCP successfully.

4. Create a Cloud DNS Zone on GCP

First, we need to create a GCP Cloud DNS resource. Cloud DNS is a scalable, reliable, and managed domain name system (DNS) service provided by GCP.

  1. Search for Cloud DNS in Search Box → Enable Cloud DNS
  2. Then go to Cloud DNS → Create a Zone.
Cloud DNS

3. Provide a Zone name and DNS name and Create.

  • A domain should be created for the given DNS name. In this project, I have already created a domain name called tech-with-thulitha.site from GoDaddy.com.

4. Our created Cloud DNS should be listed.

Cloud DNS

5. Setting up GitHub Actions

GitHub Actions CI/CD Script should be included as a YAML file in the GitHub Repository in the following directory.

.github\workflows\terraform.yaml

We should provide the JSON Key as an environment variable named GOOGLE_CREDENTIALSin this YAML file.

GOOGLE_CREDENTIALS: ${{ secrets.GCP_SA_KEY }}

When a change is pushed into the master branch, the following GitHub Actions pipeline will be executed. This pipeline contains the key steps for the infrastructure, including

  1. Terraform Init
  2. Terraform Plan
  3. Terraform Apply
name: 'Terraform CI/CD'

on:
push:
branches:
- master

jobs:
terraform:
name: 'Terraform'
runs-on: ubuntu-latest

steps:
- name: 'Checkout'
uses: actions/checkout@v2

- name: 'Set up Google Cloud'
uses: google-github-actions/setup-gcloud@v1
with:
version: 'latest'
service_account_key: ${{ secrets.GCP_SA_KEY }}
project_id: your-project-id

- name: 'Set up Terraform'
uses: hashicorp/setup-terraform@v1

- name: 'Terraform Init'
run: terraform init
env:
GOOGLE_CREDENTIALS: ${{ secrets.GCP_SA_KEY }}

- name: 'Terraform Plan'
run: terraform plan
env:
GOOGLE_CREDENTIALS: ${{ secrets.GCP_SA_KEY }}

- name: 'Terraform Apply'
if: github.ref == 'refs/heads/master'
run: terraform apply -auto-approve
env:
GOOGLE_CREDENTIALS: ${{ secrets.GCP_SA_KEY }}

Infrastructure as Code With Terraform

In this project, I have used Terraform modularization principles, which provide several benefits such as reusability, abstraction, and reduced complexity in the code.

Below is the file architecture for the project:

Root Directory
├── backend.tf
├── main.tf
├── provider.tf
├── README.md
├── terraform.tfvars
├── variables.tf
└── modules
├── load-balancer
│ ├── main.tf
│ └── variables.tf
├── static-page
│ ├── index.html
│ ├── main.tf
│ └── outputs.tf
├── network
│ ├── firewall.tf
│ ├── nat.tf
│ ├── network.tf
│ ├── outputs.tf
│ └── variables.tf
├── managed-instance-group
│ ├── main.tf
│ ├── outputs.tf
│ ├── startup-script.sh
│ └── variables.tf
└── database
├── main.tf
└── variables.tf

Below are the files and folders inside the Root Directory.

  1. backend.tf

This block sets up the Terraform State Bucket as a remote backend for storing the Terraform state file.

terraform {
backend "gcs" {
bucket = "terraform-state-bucket-gcp"
prefix = "terraform/state"
}
}j

2. main.tf

This file defines the primary infrastructure resources and configurations for the project. The architecture is divided into distinct modules for networking, instance groups, static content hosting, load balancing, and database management.

  • Network Module: Creates a Virtual Private Cloud (VPC) with two subnets, each located in different regions (us-central1 and europe-north1). This setup ensures isolation and efficient routing for the application’s web tier.
  • Managed Instance Groups: Two separate managed instance groups are deployed in the specified regions. Each group consists of Compute Engine instances running Debian 11, set up for high availability and scalability. The instances are distributed across multiple zones within each region to enhance fault tolerance and load distribution. Autoscaling is configured to dynamically adjust the number of instances between 2 and 4 based on demand.
  • Static Page Module: Hosts a static web page, potentially used for serving static content like HTML, CSS, and JavaScript files.
  • Load Balancer Module: Implements a load balancer that distributes traffic between the managed instance groups across the two regions. This module ensures efficient traffic management and failover capabilities. It also integrates a backend bucket, likely used for serving static assets.
  • Database Module: Sets up a multi-regional PostgreSQL database with instances in both regions. The database is configured with high availability and automatic failover to ensure continuous availability and reliability of the data.

module "network" {
source = "./modules/network"
vpc_network_name = var.vpc_network_name
web_subnet1_name = var.web_subnet1_name
web_subnet1_ip = var.web_subnet1_ip
web_subnet2_name = var.web_subnet2_name
web_subnet2_ip = var.web_subnet2_ip
region1 = var.region1
region2 = var.region2
}


module "managed_instance_group_region1" {
source = "./modules/managed-instance-group"
region = var.region1
vpc_network_name = var.vpc_network_name
web_subnet_self_link = module.network.web_subnet1_self_link # helps to create subnet before the instance group
machine_type = "e2-medium"
source_image = "debian-cloud/debian-11"
environment = "dev"
zones = ["us-central1-a", "us-central1-b"]
target_size = 2
min_replicas = 2
max_replicas = 4
}

module "managed_instance_group_region2" {
source = "./modules/managed-instance-group"
region = var.region2
vpc_network_name = var.vpc_network_name
web_subnet_self_link = module.network.web_subnet2_self_link # helps to create subnet before the instance group
machine_type = "e2-medium"
source_image = "debian-cloud/debian-11"
environment = "dev"
zones = ["europe-north1-a", "europe-north1-b"]
target_size = 2
min_replicas = 2
max_replicas = 4
}

module "static_page" {
source = "./modules/static-page"
}

module "load_balancer" {
source = "./modules/load-balancer"
project_id = var.project_id
managed_instance_group_region1 = module.managed_instance_group_region1.app_instance_group
managed_instance_group_region2 = module.managed_instance_group_region2.app_instance_group
backend_bucket_id = module.static_page.backend_bucket_id
}


module "database" {
source = "./modules/database"
region1 = var.region1
region2 = var.region2
vpc_network_name = var.vpc_network_name
project_id = var.project_id
database_version = "POSTGRES_15"
database_disk_size = "10"
}

3. provider.tf

provider.tf helps to Specify the configuration for the cloud provider (GCP) used in the project.

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

# GCP provider
provider "google" {
project = var.project_id
region = var.region1 #default region
}

# GCP beta provider
provider "google-beta" {
project = var.project_id
region = var.region1 #default region
}

4. variables.tf

This file declares the variables used throughout the Terraform configuration files.

variable "project_id" {
description = "The GCP project ID"
type = string
}

variable "region1" {
description = "The default GCP region"
type = string
}

variable "region2" {
description = "The 2nd GCP region"
type = string
}

variable "vpc_network_name" {
description = "The name of the VPC"
type = string
}

variable "web_subnet1_name" {
description = "The name of the web subnet 1"
type = string
}

variable "web_subnet1_ip" {
description = "The IP range of the web subnet 1"
type = string
}

variable "web_subnet2_name" {
description = "The name of the web subnet 2"
type = string
}

variable "web_subnet2_ip" {
description = "The IP range of the web subnet 2"
type = string
}

5. terraform.tfvars

terraform.tfvars provides the values for the variables defined in the project.

project_id = "gcp-terraform-project-424308"

region1 = "us-central1"
region2 = "europe-north1"

vpc_network_name = "web-app-vpc"

web_subnet1_name = "web-subnet-1"
web_subnet1_ip = "10.0.1.0/24"

web_subnet2_name = "web-subnet-2"
web_subnet2_ip = "10.0.2.0/24"

6. /modules directory

Contains the custom terraform modules that will be used to build the cloud architecture.

Now, let’s go into the modules directory.

Network Module

The network module is responsible for setting up the foundational networking infrastructure for the project. It includes,

  • firewall.tf Defines the firewall rules to control incoming and outgoing traffic.
  • nat.tf Configures Network Address Translation (NAT) for instances without public IP addresses to access the internet.
  • network.tf Creates the Virtual Private Cloud (VPC) and subnets.
  • outputs.tf Exports outputs from the network module to be used in other modules.
  • variables.tf Declares the variables used within the network module for customizable and reusable configuration.
  1. network.tf
resource "google_compute_network" "vpc_network" {
name = var.vpc_network_name
auto_create_subnetworks = false
}

resource "google_compute_subnetwork" "vpc_web_subnet1" {
name = var.web_subnet1_name
ip_cidr_range = var.web_subnet1_ip
region = var.region1
network = google_compute_network.vpc_network.name

depends_on = [
google_compute_network.vpc_network
]
}

resource "google_compute_subnetwork" "vpc_web_subnet2" {
name = var.web_subnet2_name
ip_cidr_range = var.web_subnet2_ip
region = var.region2
network = google_compute_network.vpc_network.name

depends_on = [
google_compute_network.vpc_network
]
}

2. firewall.tf

resource "google_compute_firewall" "ssh" {
name = "${var.vpc_network_name}-firewall-ssh"
network = var.vpc_network_name

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

target_tags = ["${var.vpc_network_name}-firewall-ssh"]
source_ranges = ["0.0.0.0/0"]
}



resource "google_compute_firewall" "http" {
name = "${var.vpc_network_name}-firewall-http"
network = var.vpc_network_name

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

target_tags = ["${var.vpc_network_name}-firewall-http"]
source_ranges = ["0.0.0.0/0"]
}



resource "google_compute_firewall" "https" {
name = "${var.vpc_network_name}-firewall-https"
network = var.vpc_network_name

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

target_tags = ["${var.vpc_network_name}-firewall-https"]
source_ranges = ["0.0.0.0/0"]
}



resource "google_compute_firewall" "icmp" {
name = "${var.vpc_network_name}-firewall-icmp"
network = var.vpc_network_name

allow {
protocol = "icmp"
}

target_tags = ["${var.vpc_network_name}-firewall-icmp"]
source_ranges = ["0.0.0.0/0"]
}

3. nat.tf

When setting up the NAT configuration, we should create the Cloud Router instance in the same region as the instances that need to use Cloud NAT. Cloud NAT is only used to place NAT information onto the VMs, not as part of the actual Cloud NAT gateway.

This configuration allows all instances in the region to use Cloud NAT for all primary and alias IP ranges. It also automatically allocates the external IP addresses for the NAT gateway.

resource "google_compute_router" "nat-router-region1" {
name = "nat-router-${var.region1}"
region = "${var.region1}"
network = var.vpc_network_name
}

resource "google_compute_router_nat" "nat-config-region1" {
name = "nat-config-${var.region1}"
router = "${google_compute_router.nat-router-region1.name}"
region = "${var.region1}"
nat_ip_allocate_option = "AUTO_ONLY"
source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"
}



resource "google_compute_router" "nat-router-region2" {
name = "nat-router-${var.region2}"
region = "${var.region2}"
network = var.vpc_network_name
}

resource "google_compute_router_nat" "nat-config-region2" {
name = "nat-config-${var.region2}"
router = "${google_compute_router.nat-router-region2.name}"
region = "${var.region2}"
nat_ip_allocate_option = "AUTO_ONLY"
source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"
}

4. outputs.tf

output "web_subnet1_self_link" {
value = google_compute_subnetwork.vpc_web_subnet1.self_link
}

output "web_subnet2_self_link" {
value = google_compute_subnetwork.vpc_web_subnet2.self_link
}

5. variables.tf

variable "vpc_network_name" {
description = "The name of the VPC"
type = string
}

variable "web_subnet1_name" {
description = "The name of the web subnet 1"
type = string
}

variable "web_subnet1_ip" {
description = "The IP range of the web subnet 1"
type = string
}

variable "web_subnet2_name" {
description = "The name of the web subnet 2"
type = string
}

variable "web_subnet2_ip" {
description = "The IP range of the web subnet 2"
type = string
}

variable "region1" {
description = "The 1st (default) GCP region"
type = string
}

variable "region2" {
description = "The 2nd GCP region"
type = string
}

Load Balancer Module

Load Balancing in GCP

GCP uses non-standard vocabulary for load-balancing concepts. Here are the main components of a GCP Load Balancer.

  • Global forwarding rules route traffic by IP address, port, and protocol to a load-balancing configuration consisting of a target proxy, URL map, and one or more backend services.
  • Target proxies terminate HTTP(S) connections from clients. One or more global forwarding rules direct traffic to the target proxy, and the target proxy consults the URL map to determine how to route traffic to backends.
  • URL maps define matching patterns for URL-based routing of requests to the appropriate backend services. A default service is defined to handle any requests that do not match a specified host rule or path-matching rule.
  • Backends are resources to which a GCP load balancer distributes traffic. These include backend services, such as instance groups or backend buckets.

Google offers global load balancers, which route traffic to a backend service in the region closest to the user to reduce latency. In our project, we can observe that traffic is forwarded to a backend service in one region. If the traffic increases or an issue occurs with the consuming region, the load balancer forwards the traffic to the second region.

Session Affinity

The session_affinity Setting in Google Cloud Platform (GCP) Load Balancer's backend service controls how incoming requests are distributed across instances in a backend.

  • When session_affinity is set to GENERATED_COOKIE, the load balancer uses a cookie to ensure that all requests from a client during a session are sent to the same backend instance. This is useful for s e-commerce websites or applications where user sessions and cart contents need to persist.
  • When session_affinity is set to NONE, the load balancer distributes incoming requests randomly. This is useful for stateless applications where each request can be handled independently by any instance.

Enable Caching

enable_cdn is set to true in the google_compute_backend_service resource. This enables caching from the Cloud CDN for our backend service.

  1. main.tf
# Reserve an external IP
resource "google_compute_global_address" "website" {
provider = google
name = "website-lb-ip"
}

# Get the managed DNS zone
data "google_dns_managed_zone" "custom_dns_zone" {
provider = google
name = "gcp-terraform-dns-zone"
}

# Add the IP to the DNS
resource "google_dns_record_set" "website" {
provider = google
name = "www.${data.google_dns_managed_zone.custom_dns_zone.dns_name}" # The fully qualified domain name (FQDN) for the DNS record.
type = "A"
ttl = 300
managed_zone = data.google_dns_managed_zone.custom_dns_zone.name
rrdatas = [google_compute_global_address.website.address] # Adding external IP address to the DNS record
}

resource "google_compute_health_check" "default" {
name = "tcp-proxy-health-check"
timeout_sec = 5
check_interval_sec = 5
healthy_threshold = 4
unhealthy_threshold = 5

tcp_health_check {
port = "80"
}

log_config {
enable = true
}
}

# backend service with custom request and response headers
resource "google_compute_backend_service" "default" {
name = "mig-backend-service"
project = var.project_id
protocol = "HTTP"
session_affinity = "NONE"
load_balancing_scheme = "EXTERNAL"
timeout_sec = 10
enable_cdn = true # Enable CDN
health_checks = [google_compute_health_check.default.id]
backend {
group = var.managed_instance_group_region1
balancing_mode = "UTILIZATION"
capacity_scaler = 1.0
max_utilization = 0.8
}
backend {
group = var.managed_instance_group_region2
balancing_mode = "UTILIZATION"
capacity_scaler = 1.0
max_utilization = 0.8
}

depends_on = [
google_compute_health_check.default
]
}

resource "google_compute_url_map" "website" {
name = "website-url-map"
default_service = google_compute_backend_service.default.id

host_rule {
hosts = ["*"]
path_matcher = "allpaths"
}

path_matcher {
name = "allpaths"
default_service = google_compute_backend_service.default.id

path_rule {
paths = ["/static", "/static/*"]
service = var.backend_bucket_id
}

path_rule {
paths = ["/home", "/home/*"]
service = google_compute_backend_service.default.id
}

path_rule {
paths = ["/home_page"]
service = google_compute_backend_service.default.id
}

}

}


# GCP target HTTP proxy
resource "google_compute_target_http_proxy" "website" {
name = "website-target-proxy"
url_map = google_compute_url_map.website.self_link
depends_on = [
google_compute_url_map.website
]
}

# GCP forwarding rule
resource "google_compute_global_forwarding_rule" "default" {
provider = google
name = "website-forwarding-rule"
load_balancing_scheme = "EXTERNAL"
ip_address = google_compute_global_address.website.address
ip_protocol = "TCP"
port_range = "80"
target = google_compute_target_http_proxy.website.self_link
depends_on = [
google_compute_target_http_proxy.website
]
}

2. variables.tf

variable "project_id" {
description = "The project ID"
type = string
}

variable "managed_instance_group_region1" {
description = "The self-link of the managed instance group in region 1"
type = string
}

variable "managed_instance_group_region2" {
description = "The self-link of the managed instance group in region 2"
type = string
}

variable "backend_bucket_id" {
description = "The ID of the backend bucket for serving static content"
type = string
}

Managed Instance Group Module

This module defines the setup for a managed instance group in the Google Cloud Platform using Terraform. It begins with creating an instance template (google_compute_instance_template), which specifies the configuration for app server instances, including machine type, network interfaces, and a startup script. The template tags instances for specific firewall rules and labels them based on the environment.

A health check (google_compute_health_check) is established to monitor the instances' health and ensure they are operational. The google_compute_region_instance_group_manager resource manages a group of instances, distributing them across specified zones and applying an auto-healing policy based on the health check.

Additionally, the setup includes an autoscaler (google_compute_region_autoscaler) to dynamically adjust the number of instances in response to CPU utilization, maintaining a balance between performance and cost. This setup ensures high availability, scalability, and efficient resource management for the application's infrastructure.

  1. main.tf

resource "google_compute_instance_template" "appserver" {
name_prefix = "appserver-template-${var.region}-"
description = "This template is used to create app server instances in ${var.region}."

tags = [
"appserver-instance-template",
"${var.vpc_network_name}-firewall-ssh",
"${var.vpc_network_name}-firewall-http",
"${var.vpc_network_name}-firewall-https",
"${var.vpc_network_name}-firewall-icmp"
]

labels = {
environment = var.environment
}

instance_description = "description assigned to instances"
machine_type = var.machine_type
can_ip_forward = false

scheduling {
automatic_restart = true
on_host_maintenance = "MIGRATE"
}

// Create a new boot disk from an image
disk {
source_image = var.source_image
auto_delete = true
boot = true
}

network_interface {
network = var.vpc_network_name
subnetwork = var.web_subnet_self_link
# access_config {} # to assign an external ephemeral IP
}

metadata = {
startup-script = file("${path.module}/startup-script.sh")
}

lifecycle {
create_before_destroy = true
}

}



resource "google_compute_health_check" "autohealing" {
name = "autohealing-health-check-${var.region}"
check_interval_sec = 5
timeout_sec = 5
healthy_threshold = 2
unhealthy_threshold = 10 #50 seconds

http_health_check {
request_path = "/"
port = "80"
}

log_config {
enable = true
}
}


resource "google_compute_region_instance_group_manager" "instance_group_manager" {
name = "instance-group-manager-${var.region}"
base_instance_name = "app-${var.region}"
region = var.region
target_size = var.target_size
distribution_policy_zones = var.zones

version {
instance_template = google_compute_instance_template.appserver.self_link_unique
}

named_port {
name = "http"
port = 80
}

update_policy {
type = "PROACTIVE"
minimal_action = "REPLACE"
max_surge_fixed = var.max_surge
max_unavailable_fixed = var.max_unavailable
}

auto_healing_policies {
health_check = google_compute_health_check.autohealing.id
initial_delay_sec = 100 # Set this based on the application's startup time
}
}


resource "google_compute_region_autoscaler" "appserver_autoscaler" {
name = "appserver-autoscaler-${var.region}"
region = var.region

target = google_compute_region_instance_group_manager.instance_group_manager.id

autoscaling_policy {
max_replicas = var.max_replicas
min_replicas = var.min_replicas
cooldown_period = 60

cpu_utilization {
target = 0.6
}
}
}

2. startup-script.sh

#!/bin/bash
exec > /var/log/startup-script.log 2>&1
set -x

# Function to extract zone information
get_zone() {
local zone_full=$(curl -s http://metadata.google.internal/computeMetadata/v1/instance/zone -H "Metadata-Flavor: Google")
local zone=$(basename "$zone_full")
echo "$zone"
}


# Update package repositories and install Apache
sudo apt update
sudo apt -y install apache2

# Define HTML content for the Apache home page
sudo bash -c "cat <<EOF > /var/www/html/index.html
<html lang=\"en\">
<head>
<meta charset=\"UTF-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
<title>Welcome to Apache</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #004d4d;
color: #ffffff;
margin: 0;
padding: 0;
}
.container {
max-width: 800px;
margin: 50px auto;
background-color: #006666;
padding: 20px;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h1 { color: #ffffff; text-align: center; }
p { color: #d9d9d9; text-align: left; }
b { color: #f7f7f7e4; }
.value-placeholder { color: #ffffff; }
h2 { color: #ffffff; text-align: center; margin-top: 40px; font-family:'Times New Roman', Times, serif;}
.social-icon {
font-size: 1.5em;
color: #ffffff;
margin: 0 5px;
}
.social-icon:hover {
color: #d9d9d9;
}
.social-container { text-align: center; }
</style>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css" rel="stylesheet">
</head>
<body>
<div class=\"container\">
<h1>Welcome to the Home Page</h1>
<p><b>This is the default home page for the Apache web server running on:</b> <span class=\"value-placeholder\">\$(hostname)</span>.</p>
<p><b>Server IP Address:</b> <span class=\"value-placeholder\">\$(hostname -i)</span></p>
<p><b>Server Domain:</b> <span class=\"value-placeholder\">\$(hostname -d)</span></p>
<p><b>Zone:</b> <span class=\"value-placeholder\">$(get_zone)</span></p>
<h2>Tech with Thulitha</h2>
<div class=\"social-container\">
<a href=\"https://github.com/ThulithaNawagamuwa\" class=\"mx-2\" target=\"_blank\"><i class=\"fab fa-github social-icon\"></i></a>
<a href=\"https://medium.com/@thulitha_n\" class=\"mx-2\" target=\"_blank\"><i class=\"fab fa-medium social-icon\"></i></a>
<a href=\"https://www.linkedin.com/in/thulitha-nawagamuwa/\" class=\"mx-2\" target=\"_blank\"><i class=\"fab fa-linkedin-in social-icon\"></i></a>
</div>
</div>
</body>
</html>
EOF"


sudo mkdir /var/www/html/home/

# copy the home page to /home directory
sudo cp /var/www/html/index.html /var/www/html/home/index.html


# Update 000-default.conf file
sudo sed -i '/# The ServerName directive/a\
Alias "/home_page" "/var/www/html/home"\
' /etc/apache2/sites-available/000-default.conf

# Start Apache and enable it to start on boot
sudo systemctl start apache2
sudo systemctl enable apache2

3. variables.tf

variable "region" {
description = "Region for the instance group"
type = string
}

variable "vpc_network_name" {
description = "VPC network name"
type = string
}

variable "web_subnet_self_link" {
description = "The self link of the subnet"
}

variable "environment" {
description = "Environment label"
type = string
}

variable "machine_type" {
description = "Machine type for the instances"
type = string
}

variable "source_image" {
description = "Source image for the instances"
type = string
default = "debian-cloud/debian-11"
}

variable "target_size" {
description = "Target size for the instance group"
type = number
default = 2
}

variable "zones" {
description = "Zones for the distribution policy"
type = list(string)
}

variable "max_surge" {
description = "Maximum surge for the update policy"
type = number
default = 2
}

variable "max_unavailable" {
description = "Maximum unavailable instances for the update policy"
type = number
default = 2
}

variable "min_replicas" {
description = "Minimum number of replicas for autoscaling"
type = number
}

variable "max_replicas" {
description = "Maximum number of replicas for autoscaling"
type = number
}

4. outputs.tf

output "app_instance_group" {
value = google_compute_region_instance_group_manager.instance_group_manager.instance_group
}

Database Module

This terraform module configures a private GCP environment with Cloud SQL instances in our VPC. It sets up a primary instance (“app-db-master”) with private IP addressing, establishes a read replica, manages sensitive data securely via Google Secret Manager, and ensures database operations comply with robust security and networking best practices.

Securing the Credentials of the Database

Go to the Google Cloud Shell and create a Secret in Google Cloud Secret Manager.

echo -n "root-password-123" | gcloud secrets create db-root-password --data-file=-

If the secret is successfully created, we can observe it from Cloud Console.

Google Secret Manager

In the Terraform code, I have retrieved the password using the data block named google_secret_manager_secret_version.

Let’s look at the Terraform code for provisioning DBs.

  1. main.tf
# Create a private IP range for the VPC
resource "google_compute_global_address" "private_ip_address" {
name = "private-ip-address"
purpose = "VPC_PEERING"
address_type = "INTERNAL"
prefix_length = 16
network = var.vpc_network_name
}

# Establish a private connection to Google services
resource "google_service_networking_connection" "private_vpc_connection" {
network = var.vpc_network_name
service = "servicenetworking.googleapis.com"
reserved_peering_ranges = [google_compute_global_address.private_ip_address.name]
}


# Primary Cloud SQL instance configuration
resource "google_sql_database_instance" "master" {
name = "app-db-master"
region = var.region1
database_version = var.database_version
depends_on = [google_service_networking_connection.private_vpc_connection]
deletion_protection = false

settings {
tier = "db-f1-micro"
availability_type = "REGIONAL"
disk_size = var.database_disk_size
backup_configuration {
enabled = true
}
ip_configuration {
ipv4_enabled = false
private_network = "projects/${var.project_id}/global/networks/${var.vpc_network_name}"
}
}
}



# Read replica Cloud SQL instance configuration
resource "google_sql_database_instance" "read_replica" {
name = "app-db-read-replica"
master_instance_name = "${google_sql_database_instance.master.name}"
region = var.region2
database_version = var.database_version
deletion_protection = false
replica_configuration {
failover_target = false
}
depends_on = [google_service_networking_connection.private_vpc_connection]
settings {
tier = "db-f1-micro"
availability_type = "REGIONAL"
disk_size = var.database_disk_size
backup_configuration {
enabled = false
}
ip_configuration {
ipv4_enabled = false
private_network = "projects/${var.project_id}/global/networks/${var.vpc_network_name}"
}
}
}


# Retrieve the secret from Google Secret Manager
data "google_secret_manager_secret_version" "db_root_password" {
secret = "db-root-password"
version = "latest"
}


# Configure a root user for the primary Cloud SQL instance
resource "google_sql_user" "root" {
name = "root"
instance = google_sql_database_instance.master.name
password = data.google_secret_manager_secret_version.db_root_password.secret_data
}

# Create an example database in the primary Cloud SQL instanc
resource "google_sql_database" "example_db" {
name = "exampledb"
instance = google_sql_database_instance.master.name
}

2. variables.tf

variable "project_id" {
description = "The GCP project ID"
type = string
}

variable "region1" {
description = "Region where the master db will be deployed"
type = string
}

variable "region2" {
description = "Region where the read replica db will be deployed"
type = string
}

variable "vpc_network_name" {
description = "The name of the VPC"
type = string
}

variable "database_version" {
description = "Version of the DB"
type = string
}

variable "database_disk_size" {
description = "Disk size of the DB"
type = string
}

Static Web Page Module

  1. main.tf
# Cloud Storage Bucket to store website (Once a bucket has been created, its location can't be changed.)
resource "google_storage_bucket" "static_webpage" {
name = "static-webpage-bucket"
location = "US" # Multi-region location code
storage_class = "STANDARD"
}


# Make new objects public
resource "google_storage_object_access_control" "public_rule" {
object = google_storage_bucket_object.static_site_src.output_name
bucket = google_storage_bucket.static_webpage.id
role = "READER"
entity = "allUsers"
}

# Upload the html file to the bucket
resource "google_storage_bucket_object" "static_site_src" {
name = "index.html"
source = "${path.module}/index.html"
bucket = google_storage_bucket.static_webpage.name

}


# Add the bucket as a CDN backend
resource "google_compute_backend_bucket" "static-webpage-backend" {
name = "static-website-backend-bucket"
description = "Contains files needed by the website"
bucket_name = google_storage_bucket.static_webpage.name
enable_cdn = true
}

2. index.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tech with Thulitha</title>
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css" rel="stylesheet">
<style>
body {
background-color: #016981; /* Sea blue background */
color: #ffffff; /* White text color */
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.container {
text-align: center;
}
h1 {
font-size: 3em;
margin-bottom: 0.5em;
animation: fadeIn 3s ease-in-out;
font-family:'Times New Roman', Times, serif;
font-weight: 800;
}
p {
font-size: 1.25em;
color: #ffffff; /* White text color for sub-topic */
font-weight: 400;

}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.social-icon {
font-size: 2em;
margin: 0.5em;
color: #ffffff; /* White color for icons */
transition: color 0.3s ease;
}
.social-icon:hover {
color: #cccccc; /* Lighter color on hover */
}
</style>
</head>
<body>
<div class="container">
<h1>Tech with Thulitha</h1>
<p>This is a static web page</p>
<div>
<a href="https://github.com/ThulithaNawagamuwa" class="mx-2" target="_blank"><i class="fab fa-github social-icon"></i></a>
<a href="https://medium.com/@thulitha_n" class="mx-2" target="_blank"><i class="fab fa-medium social-icon"></i></a>
<a href="https://www.linkedin.com/in/thulitha-nawagamuwa/" class="mx-2" target="_blank"><i class="fab fa-linkedin-in social-icon"></i></a>
</div>
</div>

<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
</body>
</html>

3. outputs.tf

# outputs the CDN Backend bucket id
output "backend_bucket_id" {
value = google_compute_backend_bucket.static-webpage-backend.id
}

So, we went through the code. If we merge the changes to our Master branch, the GitHub Actions pipeline should be automatically triggered, and cloud resources should be provisioned.

We can check the pipeline details from the GitHub Actions tab. And it should be successfully executed.

GitHub Actions Pipeline Log

Connecting External Domain to the Cloud DNS in Google Cloud

First, go to the Cloud DNSgcp-terraform-dns-zone , and copy the Name Servers.

DNS Name Servers

Add those Name Servers to the DNS Name Server configuration of the External Domain Provider. (Eg: GoDaddy.com/ Cloudflare.com)

DNS Name Server configuration in GoDaddy
  • Updating nameservers can take some time to propagate across the internet. This propagation delay, known as DNS propagation, typically ranges from a few minutes to a few hours.
  • DNS caching by ISPs and the TTL (Time to Live) settings configured for DNS records can affect how quickly the updates are visible globally.

After a few hours, let’s visit our website to see whether it works properly.

Home Page → http://www.tech-with-thulitha.site/

This page is loaded from Backend Managed Instance Groups.

If we refresh the page, we will see that the traffic has been forwarded to the other zone and the host IP has changed. This means the traffic is distributed across 2 zones in that region.

Here, only user traffic is forwarded to the region closest to the user. If that region is unavailable for any reason, the load balancer will automatically point the traffic to the other region.

Home page

Static Page → http://www.tech-with-thulitha.site/static/index.html

This web page is loaded from a Google Cloud Storage Bucket.

Static Web Page

Great !! Everything is working well. 😊

Challenges Faced 💻

When I built this project, I faced several failures and challenges. Here are some of them.

The Startup Script wasn’t executed successfully.

When I first deployed the infrastructure, I couldn’t access the webpage from outside. I checked all the cloud services and observed that the backend health check was failing.

Backend health check is failing

Then I checked the VM instances; they also run and work fine. Then, I logged into the VM and checked whether the Apache server was running. And I observed that it was not installed.

sudo systemctl status apache2

So, that means the script wasn’t executed. I needed to double-check on that. When I looked at the service, there wasn’t any issue with the script. Then, I checked on the service execution from the VM.

  1. Checked the Metadata Service
curl http://metadata.google.internal/computeMetadata/v1/instance/attributes/startup-script -H "Metadata-Flavor: Google"

This returned my startup script; therefore, the instance can access its metadata.

2. Verified the Startup Script Execution

sudo journalctl -u google-startup-scripts.service

Then I got the error. → /bin/bash^M: bad interpreter: No such file or directory

thulitha_nawagamuwa@app-s755:/var/log$ sudo journalctl -u google-startup-scripts.service
-- Journal begins at Mon 2024-06-03 06:10:21 UTC, ends at Mon 2024-06-03 06:14:28 UTC. --
Jun 03 06:10:27 app-s755 systemd[1]: Starting Google Compute Engine Startup Scripts...
Jun 03 06:10:27 app-s755 google_metadata_script_runner[858]: Starting startup scripts (version dev).
Jun 03 06:10:27 app-s755 google_metadata_script_runner[858]: Found startup-script in metadata.
Jun 03 06:10:27 app-s755 google_metadata_script_runner[858]: startup-script: /bin/bash: /tmp/metadata-scripts3950152954/startup-script: /bin/bash^M: bad interpreter: No such file or directory
Jun 03 06:10:27 app-s755 google_metadata_script_runner[858]: Script "startup-script" failed with error: exit status 126
Jun 03 06:10:27 app-s755 google_metadata_script_runner[858]: Finished running startup scripts.
Jun 03 06:10:27 app-s755 systemd[1]: google-startup-scripts.service: Succeeded.
Jun 03 06:10:27 app-s755 systemd[1]: Finished Google Compute Engine Startup Scripts.
thulitha_nawagamuwa@app-s755:/var/log$

This seems like a problem. I successfully added the startup script, yet the VM can’t read it properly. After some research, I found that this issue happened because the script was created on a Windows machine.

The startup script needs to be created with a Linux line-ending format. So then, I created the script with Notepad++ with the Linux line ending, and the problem was solved. 🙂

Issue when applying the Instance Template from Terraform!

I have received an error when applying the terraform code.

│ Error: Error creating instance template: googleapi: Error 409: The resource 'projects/gcp-terraform-project-424308/global/instanceTemplates/appserver-template' already exists, alreadyExists

To avoid this, I have used name_prefix in google_compute_instance_template . It creates a unique name beginning with the specified prefix.

Conclusion

In this project, I leveraged Terraform for Infrastructure as Code (IaC) and GitHub Actions for continuous deployment to build a Multi-Regional 2-tier Web Architecture on Google Cloud Platform (GCP).

This setup ensures scalability, high availability, and security, following best practices in system design and cloud infrastructure management. It’s been effective in automating deployments and maintaining reliability throughout the project lifecycle.

The source code is available on GitHub.

Thank you for reading! If you enjoyed this article, don’t forget to leave a 👏.

Let’s engage in meaningful discussions, exchange knowledge, and delve deeper into the fascinating world of technology. 🚀 Be sure to follow me on Medium and stay connected on LinkedIn.

See you in the next article!

--

--

Thulitha Nawagamuwa
Google Cloud - Community

DevOps Engineer | CKA | 3X GCP | BSc Eng. Hon’s (Moratuwa) | ENTC