In the last article, we were able to successfully deploy a Google Cloud Storage (GCS) bucket using Terraform. It was a little bit anticlimactic, since the only real indication that we had done anything at all was the output of gsutil ls showing that the bucket was created. In this article, we’ll get closer to our goal of hosting a static site on GCS by getting our implementation to the point where there’s actually a URL we can visit and see some static site content.


First, we’ll need to decide what that static content which is served from GCS will be.

A basic “hello world” site, with Tailwind

For the purposes of this tutorial, we’re not doing anything particularly fancy. In short, we’re going to write a simple index.html file which, when viewed in the browser, looks half-decent. One of the ways we can do that in a low-effort way is by using Tailwind CSS, a utility-first CSS framework that allows us to style our site entirely within HTML without needing to bother with CSS files.

This article isn’t meant to go into depth about how Tailwind works, but I’ll at least give a basic explanation of the utility classes which we’re using for our “hello world” example to make it look nice.



First, I’ll create a content/ directory in my project folder which I’ll use to store the static content for my site. Inside that directory, I’ll create an index.html file with the following content:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Hello, world!</title>
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/tailwind.min.css" rel="stylesheet">
</head>
<!-- Body goes here -->

</html>

This is a super basic skeleton of an empty HTML page. The only thing we’ve set up is a link to the Tailwind CSS CDN, which will allow us to use Tailwind utility classes to style our site within the document. Additionally, I’ve added a <title> tag with the text “Hello, world!” which will be displayed in the browser tab.


For the body, I let Copilot take the wheel and generate a really basic centered “Hello World” message:

<body class="bg-gray-100 h-screen flex items-center justify-center">
    <h1 class="text-4xl text-gray-800">Hello World</h1>
</body>

You’ll note the use of a bunch of non-sensical tokens like bg-gray-100, items-center and text-4xl. These are the Tailwind utility classes I mentioned earlier. They’re a way of applying styles to elements in a way that’s more readable and maintainable than writing CSS. For example, bg-gray-100 sets the background color of the element to a light gray, items-center centers the content vertically, and text-4xl sets the text size to “quadruple extra large.”


When I open this HTML document from my local filesystem in a browser, I see a centered “Hello World” message in a nice, large font:

Looks like it will do the trick. For good measure, I’ll make a copy of this file called content/404.html, replacing the “Hello World” message(s) with “404,” to act as a fallback page in case the user tries to access a page that doesn’t exist:

...
<body class="bg-gray-100 h-screen flex items-center justify-center">
    <h1 class="text-4xl text-gray-800">404</h1>
</body>
...


Now, let’s get this content uploaded to our GCS bucket - but via Terraform, of course.

GCS bucket object resources

I’m going to create a new dedicated Terraform file for managing the content of my GCS bucket. I’ll called it site_content.tf. With the google provider, objects in cloud storage themselves can be provisioned as resources in Terraform. So, in this new site_content.tf file, I’ll define two google_storage_bucket_object resources, one for each of the HTML files I created earlier:

resource "google_storage_bucket_object" "index_html" {
  bucket       = google_storage_bucket.static_site_bucket.name
  name         = "index.html"
  source       = "content/index.html"
  content_type = "text/html"
}

resource "google_storage_bucket_object" "error_html" {
  bucket       = google_storage_bucket.static_site_bucket.name
  name         = "404.html"
  source       = "content/404.html"
  content_type = "text/html"
}

Ok, these resources look a little different from the ones we’ve been declaring in the previous article. You’ll note that the bucket configuration is not being set to an explicit value. Rather, it’s being set according to the name attribute of the google_storage_bucket resource we created in the last article, called static_site_bucket. So, the syntax for referencing attributes of other resources in Terraform is:

# <resource_type>.<resource_name>.<attribute_name>
google_storage_bucket.static_site_bucket.name

You may ask, “why not just set the bucket attribute to the name of the bucket directly?” The answer is that this way, we can ensure that the google_storage_bucket_object resources are created in the same bucket as the google_storage_bucket resource, even if the name of the bucket changes. Moreover, Terraform also takes stock of referential dependencies between resources like this one, which helps it determine the correct order in which to create resources, and which resources can be provisioned in parallel. So, implicitly, we are telling Terraform that these new google_storage_bucket_object resources depend on the google_storage_bucket resource we created earlier, so they’ll be created in sequence.

The name and content_type arguments tell GCP what to name the object in the bucket and what type of content it is, respectively. The source argument is the path to the file on the local filesystem which we want to upload to the bucket. In this case, it’s the index.html and 404.html files in the content/ directory.


We’re finally ready to validate the execution plan with Terraform’s plan command.

terraform plan output
$ terraform plan

  Terraform used the selected providers to generate the   following execution plan. Resource actions are indicated   with the following symbols:
    + create
  
  Terraform will perform the following actions:
  
    # google_storage_bucket.static_site_bucket will be created
    + resource "google_storage_bucket" "static_site_bucket" {
        + effective_labels            = {
            + "goog-terraform-provisioned" = "true"
          }
        + force_destroy               = true
        + id                          = (known after apply)
        + location                    = "US"
        + name                        = (known after apply)
        + project                     = (known after apply)
        + project_number              = (known after apply)
        + public_access_prevention    = (known after apply)
        + rpo                         = (known after apply)
        + self_link                   = (known after apply)
        + storage_class               = "STANDARD"
        + terraform_labels            = {
            + "goog-terraform-provisioned" = "true"
          }
        + uniform_bucket_level_access = (known after apply)
        + url                         = (known after apply)
  
        + soft_delete_policy (known after apply)
  
        + versioning (known after apply)
  
        + website {
            + main_page_suffix = "index.html"
            + not_found_page   = "404.html"
          }
      }
  
    # google_storage_bucket_object.error_html will be created
    + resource "google_storage_bucket_object" "error_html" {
        + bucket         = (known after apply)
        + content        = (sensitive value)
        + content_type   = "text/html"
        + crc32c         = (known after apply)
        + detect_md5hash = "different hash"
        + generation     = (known after apply)
        + id             = (known after apply)
        + kms_key_name   = (known after apply)
        + md5hash        = (known after apply)
        + media_link     = (known after apply)
        + name           = "404.html"
        + output_name    = (known after apply)
        + self_link      = (known after apply)
        + source         = "content/404.html"
        + storage_class  = (known after apply)
      }
  
    # google_storage_bucket_object.index_html will be created
    + resource "google_storage_bucket_object" "index_html" {
        + bucket         = (known after apply)
        + content        = (sensitive value)
        + content_type   = "text/html"
        + crc32c         = (known after apply)
        + detect_md5hash = "different hash"
        + generation     = (known after apply)
        + id             = (known after apply)
        + kms_key_name   = (known after apply)
        + md5hash        = (known after apply)
        + media_link     = (known after apply)
        + name           = "index.html"
        + output_name    = (known after apply)
        + self_link      = (known after apply)
        + source         = "content/index.html"
        + storage_class  = (known after apply)
      }
  
    # random_uuid.bucket_uuid will be created
    + resource "random_uuid" "bucket_uuid" {
        + id     = (known after apply)
        + result = (known after apply)
      }
  
  Plan: 4 to add, 0 to change, 0 to destroy.

Recall that, at the end of the last article, I destroyed everything in my GCP project. So when I ran terraform plan, Terraform correctly queried the remote GCP state to determine that all of my resources need to be re-created. This is a good sign that our Terraform configuration is working as expected. We also see our expected google_storage_bucket_object resources being created, along with the website block in the google_storage_bucket resource. Let’s apply the changes.

terraform apply output
$ terraform apply
  random_uuid.bucket_uuid: Creating...
  random_uuid.bucket_uuid: Creation complete after 0s   [id=be028675-b3cd-8fbd-c48a-3ddb6629ca7b]
  google_storage_bucket.static_site_bucket: Creating...
  google_storage_bucket.static_site_bucket: Creation complete   after 2s   [id=be028675-b3cd-8fbd-c48a-3ddb6629ca7b-site-bucket]
  google_storage_bucket_object.error_html: Creating...
  google_storage_bucket_object.index_html: Creating...
  google_storage_bucket_object.error_html: Creation complete   after 1s   [id=be028675-b3cd-8fbd-c48a-3ddb6629ca7b-site-bucket-404.  html]
  google_storage_bucket_object.index_html: Creation complete   after 1s   [id=be028675-b3cd-8fbd-c48a-3ddb6629ca7b-site-bucket-index.  html]
  
  Apply complete! Resources: 4 added, 0 changed, 0 destroyed.


I can once again confirm that the bucket is created with gsutil ls:

$ gsutil ls
gs://be028675-b3cd-8fbd-c48a-3ddb6629ca7b-site-bucket/


Specifically, I can also see the objects in the bucket:

$ gsutil ls gs://be028675-b3cd-8fbd-c48a-3ddb6629ca7b-site-bucket/*
gs://be028675-b3cd-8fbd-c48a-3ddb6629ca7b-site-bucket/404.html
gs://be028675-b3cd-8fbd-c48a-3ddb6629ca7b-site-bucket/index.html


It correctly identifies that we’ve uploaded the index.html and 404.html files to the bucket. Now, let’s see if we can access the site content via the bucket URL.

Viewing the site content

To view the site content, I’ll navigate to the URL of the bucket in my browser. The URL is in the format https://<bucket-name>.storage.googleapis.com. In this case, the bucket name is be028675-b3cd-8fbd-c48a-3ddb6629ca7b-site-bucket, so the URL is https://be028675-b3cd-8fbd-c48a-3ddb6629ca7b-site-bucket.storage.googleapis.com/index.html.

Note: around this point in the exercise, I decided to destroy and re-apply my terraform project, which regenerated my UUID and bucket name. This may continue to happen insofar as randomness is involved in the generation of the bucket name. If you’re following along, please overlook any discrepancies in the bucket name within and between articles.

When I navigate to this URL, I get an error message:

<Error>
    <Code>AccessDenied</Code>
    <Message>Access denied.</Message>
    <Details>Anonymous caller does not have storage.objects.get access to the Google Cloud Storage object. Permission 'storage.objects.get' denied on resource (or it may not exist).</Details>
</Error>

This indicates that, while we’ve successfully uploaded the content to the bucket, the bucket is not publicly accessible, and so our URL is returning an access denied error. We’ll need to update the bucket’s permissions to allow public access to the objects within it.

Making the bucket public

To make the bucket public, we’ll need to update the bucket’s IAM policy to allow all users to view the objects within it. We can do this by adding a new google_storage_bucket_iam_binding resource to our Terraform configuration. I’ll add this to our existing bucket.tf file:

resource "google_storage_bucket_iam_binding" "public_read" {
  bucket = google_storage_bucket.static_site_bucket.name
  role   = "roles/storage.objectViewer"

  members = [
    "allUsers"
  ]
}

Note again that we are using the resource attribute reference syntax to set the bucket attribute to the name of the bucket we created earlier. This ensures that the IAM binding is created for the same bucket as the objects we uploaded.

The role attribute is set to roles/storage.objectViewer, which is a predefined IAM role that grants permission to view objects in a bucket. The members attribute is set to allUsers, which is a special identifier that represents all users, including anonymous users. This means that any user who visits the bucket URL will be able to view the objects within the bucket.


We can check what the output of the terraform plan command looks like in order to confirm that the IAM binding will be created as expected for our bucket.

terraform plan output
$ terraform plan
  random_uuid.bucket_uuid: Refreshing state...   [id=edb54251-622d-bbec-5005-03ddfd1117fa]
  google_storage_bucket.static_site_bucket: Refreshing state...   [id=edb54251-622d-bbec-5005-03ddfd1117fa-site-bucket]
  google_storage_bucket_object.index_html: Refreshing state...   [id=edb54251-622d-bbec-5005-03ddfd1117fa-site-bucket-index.  html]
  google_storage_bucket_object.error_html: Refreshing state...   [id=edb54251-622d-bbec-5005-03ddfd1117fa-site-bucket-404.html]
  
  Terraform used the selected providers to generate the   following execution plan. Resource actions are indicated with   the following symbols:
    + create
  
  Terraform will perform the following actions:
  
    # google_storage_bucket_iam_binding.public_read will be   created
    + resource "google_storage_bucket_iam_binding" "public_read"   {
        + bucket  =   "edb54251-622d-bbec-5005-03ddfd1117fa-site-bucket"
        + etag    = (known after apply)
        + id      = (known after apply)
        + members = [
            + "allUsers",
          ]
        + role    = "roles/storage.objectViewer"
      }
  
  Plan: 1 to add, 0 to change, 0 to destroy.


Looks good to me. Running terraform apply, we see that the policy binding gets successfully created.

$ terraform apply
  google_storage_bucket_iam_binding.public_read: Creating...
  google_storage_bucket_iam_binding.public_read: Creation complete after 5s [id=b/edb54251-622d-bbec-5005-03ddfd1117fa-site-bucket/roles/storage.objectViewer]


Now, when I navigate to the bucket URL in my browser, I see the contents of the index.html file displayed:

Success! We’ve successfully uploaded static content to a GCS bucket and made it publicly accessible. I didn’t promise that the URL used to access the website would necessarily be a nice one - so our goal was accomplished, as stated! In the next article, we’ll take a moment to look back over the terraform we’ve written so far and see if there are any improvements we can make.

Note: Once again, since I’m taking a break from this project, I’ll clean up after myself to save myself any risk of incurring cloud costs while I’m not using the resources. As with the last article, it’s easy to destroy the bucket and IAM binding with terraform destroy.