An introductory workshop in GCP with Terraform
For this workshop you'll need:
-
Git (terminal or GUI)
-
Your preferred terminal to run commands
-
Your IDE of choice to edit Terraform files, e.g., VS Code with the Terraform plugin
On macOS, with brew
, you can run brew install google-cloud-sdk terraform
.
You will receive access through a google account connected to your (work) email address.
-
Go to console.cloud.google.com.
-
If you're not logged in:
- Log in with your (work) email address.
-
If you're logged in:
- Verify that your work account is selected in the top-right corner. If not, click "Add account" and log in.
-
When you're logged in and have selected the correct account, verify that you have the
cloud-labs-workshop-project
selected in the top navigation bar (left hand side). -
You should now be good to go!
We will use the gcloud
CLI tool to log in:
-
Run
gcloud init
from the command line.- If you've previously logged in with the CLI, select the same email you used in the browser, or "Create a new configuration" if you have another account already setup in
gcloud
CLI. (You can delete this account from thegcloud
configuration after(!) the workshop using thegcloud auth revoke
command.)
- If you've previously logged in with the CLI, select the same email you used in the browser, or "Create a new configuration" if you have another account already setup in
-
Select the correct account in the browser, and authorize access to "Google Cloud SDK" when prompted.
-
In the terminal, select the project with ID
cloud-labs-workshop-42clws
. -
Check that the new account is set as active, by running
gcloud auth list
.- If you have a previously used configuration set as active, run
gcloud config set account <account>
.
- If you have a previously used configuration set as active, run
-
Run
gcloud auth application-default login
and complete the steps in the browser to create a credentials file that can be used by thegoogle
Terraform provider.
Start by cloning this repository if you haven't already done so, either using your preferred way using a GUI or any of the commands below:
- Using the GitHub CLI:
gh repo clone bekk/azure-workshop
- Using SSH keys:
git clone [email protected]:bekk/azure-workshop
- Using HTTPS:
git clone https://github.com/bekk/azure-workshop
This repository has a couple a folders:
frontend_dist/
contains some pre-built frontend files that we'll upload.infra/
will contain our terraform code.- If you're stuck you can peek in the
solutions
folder.
The infra/
folder should only contain the terraform.tf
. All files should be created in this folder, and all terraform commands assume you're in this folder, unless something else is explicitly specified.
Tip
Resources can be declared in any file inside a module (directory). For small projects, everything can declared in a single file (conventionally named main.tf
). There are some file naming conventions in the terraform style guide.
Note
terraform.tf
contains provider configuration. A provider is a plugin or library used by the terraform core to provide functionality. The google
provider we will use in this workshop provides the definition of Google Cloud Platform (GCP) resources and translates resource blocks to correct API requests when you apply your configuration.
Let's move on to running some actual commands 🚀
-
Before you can provision infrastructure, you have to initialize the providers from
terraform.tf
. You can do this by runningterraform init
(from theinfra/
folder!).This command will not do any infrastructure changes, but will create a
.terraform/
folder, a.terraform.lock.hcl
lock file. The lock file can (and should) be committed.⚠️ The.terraform/
folder should not be committed, because it can contain secrets. -
Create a
main.tf
file (ininfra/
) and add the following code, replacing<yourid42>
with a random string containing only lowercase letters and numbers, no longer than 8 characters. Theid
is used to create unique resource names and subdomains, so ideally at least 6 characters should be used to avoid collisions.locals { id = "<yourid42>" }
Note
The locals
block defines a local variable. You can reference a variable by prefixing it with local
, e.g. local.id
. Local variables, like other blocks, can be defined and used anywhere in a terraform module, meaning local.id
can be referenced in other files you create in the same directory.
-
Take a look at at
terraform.tf
.- The
terraform
block is used to declare providers and their versions. In this case, we use thehashicorp/google
, the default provider for Google Cloud Platform. - The
provider
block defines the project, region and zone we'll work in. You should not need to touch this. A project is a billing unit, used to isolate environments and apps. Regions and zones decided where our resources will be located by default (see the docs for more information). - A
google_client_openid_userinfo
data block, giving us read access to the client used to invoke Terraform, and a corresponding output block which outputs the email of the client. - A
check
block to validate the id set in the previous step. Checks are a part of the Terraform language to validate infrastructure, and will output warnings if theassert
fail.
- The
-
Run
terraform apply
. Confirm that you don't get a warning from thecheck
, and take a look at thecurrent_user_email
output.
We'll create a PostgreSQL database for our application. Cloud SQL can be used for MySQL, SQL Server, PostgreSQL and more. It is fully managed and based on open standards, providing well-known interfaces.
Caution
In this workshop we'll simplify the setup by allowing traffic from the public internet. This is not recommended for production use cases.
-
Create a new file
database.tf
in theinfra/
directory. -
Create the database and the database instance by adding the following code:
resource "google_sql_database" "database" { name = "db-todo-${local.id}" instance = google_sql_database_instance.postgres.name deletion_policy = "ABANDON" } resource "google_sql_database_instance" "postgres" { name = "db-todo-${local.id}" region = "europe-west1" database_version = "POSTGRES_14" settings { tier = "db-f1-micro" # Allow access for all IP addresses ip_configuration { authorized_networks { value = "0.0.0.0/0" } } } deletion_protection = "false" }
deletion_policy
is set toABANDON
. This is useful for Cloud SQL Postgres, where databases cannot be deleted from the API if there are users other than the defaultpostgres
user with access.- The
tier
property decides the pricing tier. db-f1-micro
is the cheapest option, giving the database 0.6 GB of RAM and shared CPU.- The
ip_configuration
allows access from any IP-address on the public internet. (Mind the caution above!) - The
deletion_protection
property is set to false, to allow us to delete the database instance through Terraform later on. This is a safety mechanism in Terraform only, it is still possible to delete the database in the cloud console.
-
We need to create a root user for our database. We'll start creating a password. Add the Random provider to the
required_providers
block interraform.tf
, followed byterraform init
to initialize the provider.random = { source = "hashicorp/random" version = "3.6.3" }
Now, we can create a
random_password
resource to generate our password. Add the following code todatabase.tf
:resource "random_password" "root_password" { length = 24 special = false }
This will create a random, 24-character password, which by default will contain uppercase, lowercase and numbers. We can reference the password by using the
result
attribute:random_password.root_password.result
. This password will be stored in the terraform state file, and will not be regenerated every timeterraform apply
is run. -
Lastly we will create the actual user. Add the following code to
database.tf
:resource "google_sql_user" "root" { name = "root" instance = google_sql_database_instance.postgres.name password = random_password.root_password.result deletion_policy = "ABANDON" }
deletion_policy
is set toABANDON
. This is useful for Postgres Cloud SQL, where users cannot be deleted from the API if they have been granted SQL roles. -
Run
terraform apply
to create the database and the database instance. This will take several minutes. While you wait, you can read the next task, Verify that the database is created in the GCP console. The simplest way to find it, is to search for "SQL" or for<yourid42>
.
Tip
Running terraform apply
implicitly both plans and applies the configuration. You can save the plan, and apply it in separate steps if you want to. E.g., tf plan -out=plan.tfplan
followed by tf apply plan.tfplan
.
terraform apply
created a state file, terraform.tfstate
in the infra/
directory. This file contains terraform's view of the state. Resources you've declared will show up here.
- Take a look at the state file. Look for the different resources you've created so far: a database, a password and a user for the database. Note how the password is in plain text in the state file.
Caution
Terraform state files are generally considered very sensitive and should have strict access controls to avoid leaking privileged information.
Tip
The terraform state file can be different from both the desired state (what's declared in the code) and the actual state (the resources that's actually there). The desired state is different from the terraform state before you apply your changes. The terraform state is different from the actual state when some configuration change has been done manually (i.e., modifying or deleting a resource).
The backend is a pre-built Docker image uploaded in the GCP Artifact Registry. We'll run it using Google Cloud Run which pulls the image and runs it as a container.
Cloud Run is a fully managed platform for containerized applications. It allows you to run your frontend and backend services, batch jobs, and queue processing workloads. Cloud Run aims to provide the flexibility of containers with the simplicity of serverless. You can use buildpacks to enable you to deploy directly from source code, or you can upload an image. Cloud Run supports pull images from the Docker image Registry and GCP Artifact Registry.
-
Create a new file,
backend.tf
(still ininfra/
) -
We'll create a new resource of type
google_cloud_run_v2_service
, namedcloudrun-service-<yourid42>
. Like this:resource "google_cloud_run_v2_service" "backend" { name = "cloudrun-service-${local.id}" location = "europe-west1" ingress = "INGRESS_TRAFFIC_ALL" template { scaling { max_instance_count = 1 } containers { image = "europe-west1-docker.pkg.dev/cloud-labs-workshop-42clws/bekk-cloudlabs/backend:latest" ports { container_port = 3000 } env { name = "DATABASE_URL" value = "postgresql://${google_sql_user.root.name}:${random_password.root_password.result}@${google_sql_database_instance.postgres.public_ip_address}:5432/${google_sql_database_instance.postgres.name}" } } } # Allow deleting using terraform destroy deletion_protection = false }
-
Run
terraform apply
. By doing this the Cloud Run resource will be created and pull the image specified in theimage
. Cloud Run resources are autoscaling, by settingmax_instance_count
to 1 we limit the service to only have one instance running. -
Verify that the Cloud Run resource is created correctly in the GCP console.
-
By default, users are not allowed to run code on a Cloud Run services. To allow all users to run code, and access the endpoints in our backend, we will give all users the invoker role on our backend service. Add the following to
backend.tf
and runterraform apply
:data "google_iam_policy" "noauth" { binding { role = "roles/run.invoker" members = [ "allUsers", ] } } resource "google_cloud_run_service_iam_policy" "noauth" { location = google_cloud_run_v2_service.backend.location project = google_cloud_run_v2_service.backend.project service = google_cloud_run_v2_service.backend.name policy_data = data.google_iam_policy.noauth.policy_data }
-
Find the Cloud Run URL in the console, or by adding an
backend_url
output block printinggoogle_cloud_run_v2_service.backend.uri
. Navigate to<url>/healthcheck
in your browser (or by usingcurl
or equivalent) and verify that you get a message stating that the database connection is ok. The app is then running ok, and has successfully connected to the database.
We will use Google Cloud Storage to store the web site. A cloud storage bucket can store any type of data object, similarly to AWS S3 and Azure Blob Storage. An object can be a text file (HTML, javascript, txt), an image, a video or any other file, and can also be used to host static websites. We will also use terraform to upload the files in the frontend_dist/
folder.
In order to serve the website content a custom domain, we will also need a CDN with a load balancer. The CDN will use a backend bucket with a storage bucket as the origin server for sourcing our content. The key part here is enabling CDN by setting the “enable_cdn=true” attribute on the backend bucket.
-
Create the bucket. Add this to a new file,
frontend.tf
resource "google_storage_bucket" "frontend" { name = "storage-bucket-${local.id}" location = "EUROPE-WEST1" website { main_page_suffix = "index.html" } force_destroy = true }
The
force_destroy
property is set totrue
to allow us to delete the bucket later on. We also use thewebsite
block to enable the website feature on the bucket. This will allow us to serve the content in the bucket as a static website.If we now go to the GCP console and click in the menu for "Cloud Storage", we should see the bucket we just created.
-
To upload files to the bucket, Terraform must track the files in the
frontend_dist/
directory. We also need some MIME type information that is not readily available, so we will create a map that we can use to look up the types later. We will create local helper variables to help us out:locals { frontend_dir = "${path.module}/../frontend_dist" frontend_files = fileset(local.frontend_dir, "**") mime_types = { ".js" = "application/javascript" ".html" = "text/html" } }
path.module
is the path to theinfra/
directory.fileset(directory, pattern)
returns a list of all files in directory matching pattern. -
Now we want to store all of these files as a object in our bucket. In order to create multiple resources, terraform provides a
for_each
meta-argument as a looping mechanism. We assign thefrontend_files
list to it, and can useeach.value
to refer to an element in the list.resource "google_storage_bucket_object" "frontend" { for_each = local.frontend_files bucket = google_storage_bucket.frontend.name source = "${local.frontend_dir}/${each.value}" content_type = lookup(local.mime_types, regex("\\.[^.]+$", each.value), null) name = each.value detect_md5hash = filemd5("${local.frontend_dir}/${each.value}") }
The code snippet performs a regex search to look up the correct content type. The
filemd5
calculates a hash of the file content, which is used to determined whether a file need to be re-uploaded. Without the hash, terraform would not be able to detect a file change (only new/deleted/renamed files).After we now run
terraform apply
, we should be able to see the cloud storage bucket with it's files in the GCP console.But we are not able to browse the website yet. We need to set up access to the bucket, in our case enable public access to the bucket. We'll create a new IAM policy for the bucket. There are three of these, you can read more about them here. We will use the
google_storage_bucket_iam_member
. Add the following tofrontend.tf
:resource "google_storage_bucket_iam_member" "member" { bucket = google_storage_bucket.frontend.name role = "roles/storage.objectViewer" member = "allUsers" }
If you navigate to
index.html
file in the bucket, we should see the contents of the file has a "Public URL". And you should be able to navigate to the website. The URL should be similar tohttps://storage.googleapis.com/storage-bucket-<yourid42>/index.html
.
In order to get a custom domain up and running, we'll create a CDN with a load balancer in front. The load balancer will be assigned a public IP which we can point a DNS record towards.
-
Create a new file
cdn.tf
and add the CDN:resource "google_compute_backend_bucket" "cdn_bucket" { name = "cdn-bucket-${local.id}" description = "Backend bucket for serving static content through CDN" bucket_name = google_storage_bucket.frontend.name enable_cdn = true }
The CDN acts as a load balancing backend service that serves content a cloud storage bucket. The key part here is enabling CDN by setting the
enable_cdn=true
attribute on the backend bucket. Provisioning the CDN doesn't do much, but find your CDN instance in the GCP console anyway. -
Reserve an external IP for the load balancer in front of the CDN. Add the following to
cdn.tf
:resource "google_compute_global_address" "cdn_public_address" { name = "cdn-public-address-${local.id}" }
You can find your IP by finding it in the GCP console, or adding an output:
output "cdn_public_ip" { value = google_compute_global_address.cdn_public_address.address }
-
Time to set up the load balancer:
resource "google_compute_url_map" "lb" { name = "cdn-url-map-${local.id}" default_service = google_compute_backend_bucket.cdn_bucket.self_link } resource "google_compute_target_http_proxy" "frontend" { name = "http-proxy-${local.id}" url_map = google_compute_url_map.lb.id } resource "google_compute_global_forwarding_rule" "frontend" { name = "frontend-forwarding-rule-${local.id}" target = google_compute_target_http_proxy.frontend.id port_range = "80" load_balancing_scheme = "EXTERNAL" ip_address = google_compute_global_address.cdn_public_address.address }
In our case, a HTTP(S) load balancer requires a backend service to serve requests. Cloud CDN requires a global load balancer, either a "global external application load balancer" or a premium tier "classic application load balancer" (see the docs). The
load_balancing_scheme
is set toEXTERNAL
, giving us a classic application load balancer (changing toEXTERNAL_MANAGED
will provision a global external application load balancer instead, see the docs).
We will use cloudlabs-gcp.no
for this workshop. It is already configured in a manged DNS zone. You can find it by searching for Cloud DNS in the GCP console. We will configure two records, api.<yourid42>.cloudlabs-gcp.no
for the backend, and <yourid42>.cloudlabs-gcp.no
for the frontend CDN.
-
To define subdomains, we'll need a reference to the managed DNS zone in our Terraform configuration. We will use the Terraform
data
block. A data block is very useful to refer to resources created externally, including resources created by other teams or common platform resources in an organization (such as a DNS zone). Most resources have a corresponding data block.Create
dns.tf
and add the following data block:data "google_dns_managed_zone" "cloudlabs_gcp_no" { name = "cloudlabs-gcp-no-dns" }
-
Add the IP to the DNS zone:
resource "google_dns_record_set" "frontend" { name = "${local.id}.${data.google_dns_managed_zone.cloudlabs_gcp_no.dns_name}" type = "A" ttl = 60 managed_zone = data.google_dns_managed_zone.cloudlabs_gcp_no.name rrdatas = [google_compute_global_address.cdn_public_address.address] }
Verify that the DNS record is created in the GCP console. And go to the newly created address
http://<your-id>.cloudlabs-gcp.no
. The propagation of the address can take some time. Try usingdig @8.8.8.8 <your-id>.cloudlabs-gcp.no
to see if the address is ready. Look for;; ANSWER SECTION:
, and find a line that looks like<your-id>.cloudlabs-gcp.no. 60 IN A 34.160.83.207
. If you can't find the answer section, the DNS record might not be propagate yet (should not take more than a couple of minutes), or an error happened.
To enable HTTPS for the CDN we need to create a certificate. The google_compute_managed_ssl_certificate
can help us out here.
-
Make a new file called
https.tf
and add the following to create a certificate:resource "google_compute_managed_ssl_certificate" "frontend" { name = "frontend-certificate-${local.id}" managed { domains = [google_dns_record_set.frontend.name] } }
-
Now we need to add it to the https proxy.
resource "google_compute_target_https_proxy" "frontend" { name = "frontend-target-proxy-https-${local.id}" url_map = google_compute_url_map.lb.self_link ssl_certificates = [google_compute_managed_ssl_certificate.frontend.self_link] }
-
And then add it to the forwarding rule
resource "google_compute_global_forwarding_rule" "frontend_https" { name = "frontend-forwarding-rule-https-${local.id}" load_balancing_scheme = "EXTERNAL" ip_address = google_compute_global_address.cdn_public_address.address ip_protocol = "TCP" port_range = "443" target = google_compute_target_https_proxy.frontend.self_link }
-
Run
terraform apply
and to start the provisioning of the certificate. The certificate resource is quite complex, and even comes with a warning in the documentation.Provisioning a Google-managed certificate might take up to 60 minutes from the moment your DNS and load balancer configuration changes have propagated across the internet. If you have updated your DNS configuration recently, it can take a significant amount of time for the changes to fully propagate. Sometimes propagation takes up to 72 hours worldwide, although it typically takes a few hours. But if you go to the "Load balancing" and in to your load balancing in the GCP console you should see HTTPS in the list of the frontend tab.
To get a custom domain for the GCR instance, the recommended way is to use a global external Application Load Balancer. We could create a new load balancer, but for the purposes of this workshop we're going to share a load balancer for the frontend and backend and use rules to direct the traffic.
-
You'll need to create a
google_compute_region_network_endpoint_group
that contains the Google Cloud Run service previously created. Then, create agoogle_compute_backend_service
using the network endpoint group (NEG). Thegoogle_compute_backend_service
plays a similar role to thegoogle_compute_backend_bucket
we created for the frontend. -
Create a new public IP address and DNS record for the backend, similar to what we did for the frontend. The backend should use
api.<yourid42>.cloudlabs-gcp.no
. You can verify usingdig
or similar tools. -
Set up a
google_compute_target_http_proxy
andgoogle_compute_global_forwarding_rule
for the backend, similar to what was done for the frontend. After applying these changes, the load balancer won't direct traffic properly until the next step is completed. -
Modify the previously created load balancer to create
host_rule
s andpath_matcher
. You should route based on host name (domain name) only, thepath_matcher
s are required, but should match all paths. Refer togoogle_compute_url_map
documentation. Please note: These changes can take a couple of minutes to propagate, meaning it can be hard to test. Using thetest
block on the URL map will let you verify that paths are properly redirected when you run apply.
Create a google_compute_managed_ssl_certificate
, google_compute_target_https_proxy
and google_compute_global_forwarding_rule
similar to what was done for the frontend.