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-projectselected 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 initfrom 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
gcloudCLI. (You can delete this account from thegcloudconfiguration after(!) the workshop using thegcloud auth revokecommand.)
- 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 loginand complete the steps in the browser to create a credentials file that can be used by thegoogleTerraform provider. Note: Remember to check the boxes to give "Google Auth Library" access to your account.
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/gcp-workshop - Using SSH keys:
git clone [email protected]:bekk/gcp-workshop - Using HTTPS:
git clone https://github.com/bekk/gcp-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
solutionsfolder.
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.hcllock file. The lock file can (and should) be committed.⚠️ The.terraform/folder should not be committed, because it can contain secrets. -
Create a
main.tffile (ininfra/) and add the following code, replacing<yourid42>with a random string containing only lowercase letters and numbers, no longer than 8 characters. Theidis 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
terraformblock is used to declare providers and their versions. In this case, we use thehashicorp/google, the default provider for Google Cloud Platform. - The
providerblock 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_userinfodata 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
checkblock 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 theassertfail.
- The
-
Run
terraform apply. Confirm that you don't get a warning from thecheck, and take a look at thecurrent_user_emailoutput.
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.tfin 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_policyis set toABANDON. This is useful for Cloud SQL Postgres, where databases cannot be deleted from the API if there are users other than the defaultpostgresuser with access.- The
tierproperty decides the pricing tier. db-f1-microis the cheapest option, giving the database 0.6 GB of RAM and shared CPU.- The
ip_configurationallows access from any IP-address on the public internet. (Mind the caution above!) - The
deletion_protectionproperty 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_providersblock interraform.tf, followed byterraform initto initialize the provider.random = { source = "hashicorp/random" version = "3.6.3" }
Now, we can create a
random_passwordresource 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
resultattribute:random_password.root_password.result. This password will be stored in the terraform state file, and will not be regenerated every timeterraform applyis 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_policyis 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 applyto 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_countto 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.tfand 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_urloutput block printinggoogle_cloud_run_v2_service.backend.uri. Navigate to<url>/healthcheckin your browser (or by usingcurlor 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 instruct Terraform to upload the files in the frontend_dist/ folder.
In order to serve the website content on a custom domain, we will also need a CDN with a load balancer. The CDN will use a storage bucket as the origin server for sourcing our content.
The setup can be quite complex, so a module has been created for this workshop. Terraform modules are abstractions that can contain multiple resources, input variables, output values, and even other modules. They help organize and reuse code. You can read more about modules in the Terraform module documentation.
-
We will use the module in the
modules/gcs_static_websitefolder. Add the following tofrontend.tf:module "website" { // This is a relative path to the module source = "../modules/gcs_static_website" website_id = "website-${local.id}" website_files_dir = "${path.module}/../frontend_dist" }
-
Run
terraform applyand inspect the plan. This module invocation creates a Google Storage bucket, with an IAM policy and upload the objects. -
After applying the play, go to the GCP console. Find "Cloud Storage", and your bucket.
If you navigate to
index.htmlfile in the bucket, we should see the contents of the file has a "Public URL" that you can copy in order to navigate to the website. The URL should be similar tohttps://storage.googleapis.com/website-<yourid42>/index.html. The site might show an error, but everything is right if you're able to navigate to the site.
We want a custom domain for our CDN. Luckily the gcs_static_website module handles this too.
-
Add
enable_cdn = trueto the module invocation infrontend.tf, and runterraform apply. -
The module will expose a public IP. Create an
outputblock to print the IP address:output "website_public_ip" { value = module.website.public_ip }
Apply the changes, and verify that the IP output is displayed after running Terraform.
Outputs let's you easily expose useful information from a module, or display it after running
terraform apply. You can also find the IP in the GCP console, by navigating to "Network services" > "Load balancing". -
Try navigating to the IP address in your browser. You should see the frontend website - it will likely display a different error, but it still loads.
We want a custom domain for our frontend CDN instead of using the IP address. We will use cloudlabs-gcp.no domain 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 create a DNS A record to use <yourid42>.cloudlabs-gcp.no 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
datablock. 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 or a Kubernetes VPC network). Most resources have a corresponding data block.Create
dns.tfand 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:
locals { frontend_domain = "${local.id}.${data.google_dns_managed_zone.cloudlabs_gcp_no.dns_name}" } resource "google_dns_record_set" "frontend" { name = local.frontend_domain type = "A" ttl = 60 managed_zone = data.google_dns_managed_zone.cloudlabs_gcp_no.name rrdatas = [module.website.public_ip] }
Provision the record and verify that the DNS record is created in the GCP console.
-
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.noto 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.
terraform destroy will delete all resources created by Terraform. When you're done with the workshop, please delete all resources created by Terraform by running terraform destroy. This will read the state file, and delete all resources listed in it. You will be prompted to confirm the action. You do not need to delete the resources yet, but please remember to do so after the workshop.
To enable HTTPS for the CDN we need to create a certificate. The gcs_static_website module handles this for us. If you're curious about how it's done, take a look at the files in modules/gcs_static_website.
-
Add
enable_https = trueto the module invocation infrontend.tf. Also set the inputdomain_nameto thefrontend_domainlocal variable we defined indns.tf. -
Run Terraform appy to start the provisioning of the certificate. The underlying 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 up to a few hours.
-
Go to the "Load balancing" section in the GCP console and find the frontend load balancer with your unique id. The load balancer should have both HTTP and HTTPS enabled.
To get a custom domain for the GCR instance, the recommended way is to use a global external Application Load Balancer, setting up a regional network endpoint group a backend service, IP address and DNS records. This is quite a lot, so we will use a module to do the heavy lifting for us.
The Terraform registry not only contains providers, but also modules that wraps functionality into easy-to-use abstractions.
Caution
Both modules and providers can be provided by third-parties. Make sure to only use modules provided by trusted vendors. Google provides it's own modules, that sets up services the right way, and without malware.
-
We will use the
lb-httpmodule from Google. Use the registry search to find it. -
You'll need to create a
google_compute_region_network_endpoint_groupthat contains the Google Cloud Run service previously created. Use the example in the Terraform documenation to create the network endpoint group. Apply and verify that it's created. -
Create a new public IP using the
google_compute_global_addressresource. (Again, use the examples to figure out how to provision it.) -
Create a new DNS A record, pointing to the new backend IP. Verify, like before, that the DNS record is provisioned correctly.
-
The
lb-httpmodule is quite complex, so the configuration here is given modify the configuration to reference resources you've previously created(IP address, network endpoint, DNS record). Also note that provisioning a managed SSL cert might take up to 24 hours.module "lb-http" { source = "GoogleCloudPlatform/lb-http/google" version = "13.1.0" name = "http-lb-${local.id}-backend" project = data.google_project.current.project_id address = <your public IP address> create_address = false ssl = true managed_ssl_certificate_domains = [<A record name>] https_redirect = true backends = { default = { description = null groups = [ { # Hint: This is a reference to the id attribute of the NEG resource group = <your network endpoint group>.id } ] enable_cdn = false iap_config = { enable = false } log_config = { enable = false } } } firewall_networks = [] }