Skip to content

How to Deploy Static Site to GCP CDN with GitHub Actions

Building and deploying static sites is rarely an issue these days. Most of the PaaS providers already have full support for your live and your preview environments and a clean integration with your favorite Git provider.

However, some organizations may choose to stick with big players like GCP for various reasons.

In this blog post, you will learn how to build your frontend and deploy your static files to GCP bucket using GitHub Actions and serve it behind GCP CDN.

In this approach we will employ OpenID Connect to authenticate GitHub Actions runner to GCP API to avoid passing hard-coded credentials (Actually, GCP calls this Federated Workload Identity but it is unsurprisingly based on OIDC).

If this sounds interesting to you, let's not keep you waiting any longer.

Introduction

I find it fascinating to work with service providers who have native support for OpenID Connect (aka OIDC).

In essence, OIDC is based on establishing a trust relationship between two service providers, enabling identities from one to authenticate and access the APIs of the other.

This is a powerful feature that allows you to stay compliant and increases your security posture because you will no longer have to pass around any hard-coded credentials; better yet, you no longer have to bear any overhead for secret rotation! I can't think of any happier news than this.

If you're frustrated by GitHub PAT tokens, you know what I'm talking about. 😰

This intro should prepare you for what you can expect from the rest of this blog post.

If you're unsure of what this all means, let me break it down for you and clearly state my objective:

Let the runners of GitHub Actions of a specific repository authenticate to a specific project of GCP API and upload the static site to the bucket that is configured to serve as the backend of a GCP CDN.

That sums up the entire blog post we're about to share here. All the codes that is to come will be written in OpenTofu and applied via Terragrunt.

Pre-requisites

You should be prepared by now on what you will need installed, but let's nail it down for the sake of explicitness:

Directory Structure

Here's what you will see in the rest of this post (truncated for brevity):

.
├── 10-storage-bucket/
│   ├── main.tf
│   └── terragrunt.hcl
├── 20-github-workload-identity/
│   ├── main.tf
│   └── terragrunt.hcl
├── 30-github-actions-iam/
│   ├── main.tf
│   └── terragrunt.hcl
├── 40-dns-record/
│   ├── main.tf
│   └── terragrunt.hcl
├── 50-cdn-endpoint/
│   ├── main.tf
│   └── terragrunt.hcl
├── 60-github-frontend-repository/
│   ├── files/
│   ├── main.tf
│   └── terragrunt.hcl
├── gcp.hcl
└── github.hcl

The top two files that will be used occasionally are as follows:

gcp.hcl
generate "gcp" {
  path      = "provider_gcp.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<-EOF
    provider "google" {
      project = "developer-friendly"
      region  = "europe-west4"
    }
  EOF
}
github.hcl
generate "github" {
  path      = "provider_github.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<-EOF
    provider "github" {
      owner = "developer-friendly"
    }
  EOF
}

Google Storage Bucket

The first step is to create a bucket and enable CDN on it.

Additionally, and based on the official documentation, you will have to enable public access to the bucket7.

I personally don't like this approach. I would've preferred to allow my CDN readonly access to the files and keep the bucket private.

However, as of this writing, they do not have official support for this approach.

As a result, make sure to use this public bucket to only serve your static files and never push anything you don't want the public to see.

10-storage-bucket/versions.tf
terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "< 7"
    }
    random = {
      source  = "hashicorp/random"
      version = "< 4"
    }
  }

  required_version = "< 2"
}
10-storage-bucket/main.tf
data "google_client_config" "current" {}
data "google_project" "current" {}

resource "random_pet" "this" {
  length = 6
}

resource "google_storage_bucket" "this" {
  name          = random_pet.this.id
  location      = data.google_client_config.current.region
  force_destroy = true

  uniform_bucket_level_access = true

  website {
    main_page_suffix = "index.html"
    not_found_page   = "index.html"
  }

  cors {
    origin          = ["*"]
    method          = ["GET", "HEAD", "OPTIONS"]
    response_header = ["*"]
    max_age_seconds = 3600
  }
}

resource "google_storage_bucket_iam_member" "this" {
  bucket = google_storage_bucket.this.name
  role   = "roles/storage.objectViewer"
  member = "allUsers"
}
10-storage-bucket/terragrunt.hcl
include "gcp" {
  path = find_in_parent_folders("gcp.hcl")
}

inputs = {
}
10-storage-bucket/outputs.tf
output "bucket_name" {
  value = google_storage_bucket.this.name
}

GitHub Federated Workload Identity

This is my favorite part of this setup. It is based on the OpenID Connect protocol and it really takes the security of our setup to the next level.

As mentioned at the beginning of this article, the objective is to let GitHub runners authenticate and copy files to the Google Cloud Storage8 while avoiding any hard-coded credentials!

20-github-workload-identity/versions.tf
terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "< 7"
    }
  }

  required_version = "< 2"
}
20-github-workload-identity/main.tf
resource "google_iam_workload_identity_pool" "this" {
  workload_identity_pool_id = "github-pool"
  display_name              = "GitHub Actions"
  description               = "Identity pool for GitHub Actions"
}

resource "google_iam_workload_identity_pool_provider" "this" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.this.workload_identity_pool_id
  workload_identity_pool_provider_id = "github-provider"
  display_name                       = "GitHub Actions"
  description                        = "OIDC identity pool provider for GitHub Actions"

  attribute_mapping = {
    "attribute.actor"            = "assertion.actor"
    "attribute.aud"              = "assertion.aud"
    "attribute.event_name"       = "assertion.event_name"
    "attribute.repository_owner" = "assertion.repository_owner"
    "attribute.repository"       = "assertion.repository"
    "attribute.run_id"           = "assertion.run_id"
    "attribute.run_number"       = "assertion.run_number"
    "attribute.workflow"         = "assertion.workflow"
    "google.subject"             = "assertion.sub"
  }

  attribute_condition = <<-EOT
    attribute.repository_owner == "developer-friendly"
  EOT

  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
}

You have the possibility to restrict the access further by adding more conditionals to the Workload Identity using the attribute_condition attribute.

For more examples, refer to the official provider documentations9.

20-github-workload-identity/terragrunt.hcl
include "gcp" {
  path = find_in_parent_folders("gcp.hcl")
}

inputs = {
}
20-github-workload-identity/outputs.tf
output "workload_identity_pool_provider_id" {
  value = google_iam_workload_identity_pool_provider.this.name
}

output "workload_identity_pool_id" {
  value = google_iam_workload_identity_pool.this.workload_identity_pool_id
}

This is only half the battle. So far, we have only created a pool of identities10, using which we can create and assign a service account to11.

GitHub Runner IAM Binding

This is where we create the service account which will be used by the CI runners within our GitHub Actions.

Service accounts in GCP are identities bound to GCP identity server. However, using the Federated Workload Identity10, you can define external services that are allowed to assume and use its ACL to talk to GCP API.

30-github-actions-iam/versions.tf
terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "< 7"
    }
  }

  required_version = "< 2"
}
30-github-actions-iam/variables.tf
variable "bucket_name" {
  type     = string
  nullable = false
}

variable "workload_identity_pool_id" {
  type     = string
  nullable = false
}

variable "github_repo" {
  type     = string
  nullable = false
  default  = "developer-friendly/frontend-in-gcp-bucket"
}
30-github-actions-iam/main.tf
data "google_project" "current" {}

resource "google_service_account" "this" {
  account_id   = "github-actions"
  display_name = "GitHub Actions CDN Publisher"
  description  = "Service account for GitHub Actions to publish to CDN bucket"
}

resource "google_project_iam_member" "this" {
  project = data.google_project.current.id
  role    = "roles/iam.serviceAccountTokenCreator"
  member  = "serviceAccount:${google_service_account.this.email}"
}

resource "google_service_account_iam_member" "this" {
  service_account_id = google_service_account.this.name
  role               = "roles/iam.workloadIdentityUser"
  member = format(
    "principalSet://iam.googleapis.com/projects/%s/locations/global/workloadIdentityPools/%s/attribute.repository/%s",
    data.google_project.current.number,
    var.workload_identity_pool_id,
    var.github_repo,
  )
}

resource "google_storage_bucket_iam_binding" "this" {
  bucket = var.bucket_name
  role   = "roles/storage.objectAdmin"
  members = [
    "serviceAccount:${google_service_account.this.email}"
  ]
}

Pay close attention to how we're defining the principal in the google_service_account_iam_member resource. This will be all the runners in our desired GitHub repository.

All thanks to OpenID Connect (or Federated Workload Identity in GCP world 🙄).

30-github-actions-iam/terragrunt.hcl
include "gcp" {
  path = find_in_parent_folders("gcp.hcl")
}

inputs = {
  bucket_name               = dependency.gcs.outputs.bucket_name
  workload_identity_pool_id = dependency.github_workload_identity.outputs.workload_identity_pool_id
}

dependency "gcs" {
  config_path = "../10-storage-bucket"
}

dependency "github_workload_identity" {
  config_path = "../20-github-workload-identity"
}

Take note of how we're using outputs from one Terragrunt stack and pass it as input to another. This is the best part of working with Terragrunt as it makes it easy to avoid using the TF remote state12.

30-github-actions-iam/outputs.tf
output "service_account_email" {
  value = google_service_account.this.email
}

Creating this stack will allow us to let the GitHub runners assume this role and copy the static files of our frontend.

DNS Record

This is where we create a public IP address in the GCP project and create the corresponding DNS record for it.

Mind you, this step should be tailored to your setup, e.g., if you use Azure Hosted Zone, AWS Route 53, or any other DNS provider.

The example below is using a Cloudflare provider.

40-dns-record/versions.tf
terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "< 7"
    }
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "< 6"
    }
  }

  required_version = "< 2"
}
40-dns-record/main.tf
resource "google_compute_global_address" "this" {
  for_each = toset([
    "IPV4",
    "IPV6",
  ])

  name         = lower(format("%s-%s", "cdn-ip-address", each.key))
  address_type = "EXTERNAL"
  ip_version   = each.key
}
40-dns-record/cloudflare.tf
data "cloudflare_zone" "this" {
  zone_id = var.cloudflare_zone_id
}

resource "cloudflare_dns_record" "this" {
  for_each = google_compute_global_address.this

  zone_id = data.cloudflare_zone.this.zone_id
  name    = "frontend-in-gcp-bucket"
  content = each.value.address
  type    = each.key == "IPV4" ? "A" : "AAAA"
  proxied = false
  ttl     = 60
}
40-dns-record/terragrunt.hcl
include "gcp" {
  path = find_in_parent_folders("gcp.hcl")
}

inputs = {
}
40-dns-record/outputs.tf
output "public_ip_address" {
  value = {
    ipv4 = google_compute_global_address.this["IPV4"].address
    ipv6 = google_compute_global_address.this["IPV6"].address
  }
}

Why not creating the IP address within CDN endpoint stack?

There is a very important reason why we create the IP address and the DNS record before creating the Google Load Balancer (i.e. URL Map)13.

We want to take advantage of the DNS propagation delay so that by the time we are done creating the URL maps in the next step, the DNS from our domain provider is already pointing to the IP address of the load balancer.

Verify the Setup: GitHub Frontend Repository

We are now ready to push our static files from a sample frontend repository.

This step doesn't necessarily have to be written in TF codes, nor are we obsessed being a Infrastructure as Code (IaC) purist; it's just simpler to showcase the demo we're providing here.

60-github-frontend-repository/versions.tf
terraform {
  required_providers {
    github = {
      source  = "integrations/github"
      version = "< 7"
    }
  }
  required_version = "< 2"
}
60-github-frontend-repository/files/ci.yml.tftpl
name: ci

concurrency:
  cancel-in-progress: true
  group: ci-$${{ github.ref_name }}-$${{ github.event_name }}

on:
  push:
    branches:
      - main
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Setup bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest
      - name: Cache dependencies
        uses: actions/cache@v4
        with:
          path: node_modules
          key: $${{ runner.os }}-bun-$${{ hashFiles('**/bun.lockb') }}
          restore-keys: |
            $${{ runner.os }}-bun-
      - name: Install dependencies
        run: bun install
      - name: Build statics
        run: bun run build
      - name: Upload artifact
        id: upload-artifact
        uses: actions/upload-artifact@v4
        with:
          name: dist-$${{ github.ref_name }}-$${{ github.run_id }}
          path: dist
      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          service_account: ${google_service_account_email}
          workload_identity_provider: ${workload_identity_pool_provider_id}
      - name: Set up Cloud SDK
        uses: google-github-actions/setup-gcloud@v2
      - name: GCP Whoami
        run: gcloud auth list
      - name: Upload to GCS
        run: gsutil -m rsync -r ./dist/ gs://${google_storage_bucket}/

In the CI file, having the id-token: write is a required attribute for the whole setup to work properly.

60-github-frontend-repository/files/index.tml
<!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>
  </head>
  <body>
    Hello from GCP Storage Bucket!
  </body>
</html>
60-github-frontend-repository/files/index.js
console.log('Hello, World!');
60-github-frontend-repository/files/package.json
{
  "devDependencies": {
    "@types/bun": "latest",
    "vite": "^6.1.0",
    "vite-plugin-minify": "^2.1.0"
  },
  "module": "index.js",
  "name": "frontend-in-gcp-bucket",
  "peerDependencies": {
    "typescript": "^5.0.0"
  },
  "scripts": {
    "build": "vite build"
  },
  "type": "module"
}
60-github-frontend-repository/variables.tf
variable "google_service_account_email" {
  type     = string
  nullable = false
}

variable "workload_identity_pool_provider_id" {
  type     = string
  nullable = false
}

variable "google_storage_bucket" {
  type     = string
  nullable = false
}
60-github-frontend-repository/main.tf
resource "github_repository" "this" {
  name = "frontend-in-gcp-bucket"

  visibility = "public"

  vulnerability_alerts = true

  auto_init = true
}

resource "github_repository_file" "asset" {
  for_each = toset([
    "index.html",
    "index.js",
    "package.json",
  ])

  repository          = github_repository.this.name
  branch              = "main"
  file                = each.key
  content             = file("${path.module}/files/${each.key}")
  commit_message      = "chore: add asset ${each.key}"
  commit_author       = "opentofu[bot]"
  commit_email        = "opentofu[bot]@users.noreply.github.com"
  overwrite_on_create = true
}

resource "github_repository_file" "ci" {
  repository = github_repository.this.name
  branch     = "main"
  file       = ".github/workflows/ci.yml"
  content = templatefile("${path.module}/files/ci.yml.tftpl", {
    google_storage_bucket              = var.google_storage_bucket
    workload_identity_pool_provider_id = var.workload_identity_pool_provider_id
    google_service_account_email       = var.google_service_account_email
  })
  commit_message      = "chore(CI): add pages deployment workflow"
  commit_author       = "opentofu[bot]"
  commit_email        = "opentofu[bot]@users.noreply.github.com"
  overwrite_on_create = true

  depends_on = [
    github_repository_file.asset,
  ]
}
60-github-frontend-repository/terragrunt.hcl
include "github" {
  path = find_in_parent_folders("github.hcl")
}

inputs = {
  google_storage_bucket              = dependency.gcs.outputs.bucket_name
  workload_identity_pool_provider_id = dependency.github_workload_identity.outputs.workload_identity_pool_provider_id
  google_service_account_email       = dependency.github_actions_iam.outputs.service_account_email
}

dependency "gcs" {
  config_path = "../10-storage-bucket"
}

dependency "github_workload_identity" {
  config_path = "../20-github-workload-identity"
}

dependency "github_actions_iam" {
  config_path = "../30-github-actions-iam"
}

And the result of a sample curl request:

$ curl -sLD - frontend-in-gcp-bucket.developer-friendly.blog
HTTP/1.1 301 Moved Permanently
Cache-Control: private
Location: https://frontend-in-gcp-bucket.developer-friendly.blog:443/
Content-Length: 0
Date: Sun, 16 Feb 2025 11:54:12 GMT
Content-Type: text/html; charset=UTF-8

HTTP/2 200
x-guploader-uploadid: AHMx-iEHh4iLajDAS_vP_L-pxqOtDJVoC2JXuew_xr4Xd2ZyEwNaMqo0-o-H5cn_ifkpBuXTsR8FGWQ
x-goog-generation: 1739705267499482
x-goog-metageneration: 1
x-goog-stored-content-encoding: identity
x-goog-stored-content-length: 251
x-goog-meta-goog-reserved-file-mtime: 1739705243
x-goog-hash: crc32c=1MUXVw==
x-goog-hash: md5=MyYCc5m58a1vKmg5oqww3Q==
x-goog-storage-class: STANDARD
accept-ranges: bytes
content-length: 251
access-control-allow-origin: *
access-control-expose-headers: *
server: UploadServer
date: Sun, 16 Feb 2025 11:45:41 GMT
last-modified: Sun, 16 Feb 2025 11:27:47 GMT
etag: "3326027399b9f1ad6f2a6839a2ac30dd"
content-type: text/html
age: 511
cache-control: public,max-age=3600
alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000

<!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>
  </head>
  <body>
    Hello from GCP Storage Bucket!
  </body>
</html>
Successful CI run
Successful CI run
GCP Load Balancer
GCP Load Balancer
GCP CDN
GCP CDN

Conclusion

The aim of this blog post was to allow GitHub runners deploy our frontend code to Google Cloud Storage while avoiding the need to pass hard-coded credentials.

We have created the Federated Workload Identity, bound it to a specific repository of our organization using attribute conditions, and created a dedicated service account to be used by the runner jobs.

This setup will serve you right for a full-blown production setup. You can customize it further but it is solid.

That's all I had to say. Thanks for sticking around. 🤗

If you enjoyed this piece and read all the way down here, you might wanna subscribe to the newsletter or the rss feed. 😉

Until next time, ciao 🤠 & happy coding! 🐧 🦀

If you enjoyed this blog post, consider sharing it with these buttons 👇. Please leave a comment for us at the end, we read & love 'em all. ❣

Share on Share on Share on Share on

Comments