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:
- OpenTofu v1.x1
- Terragrunt v0.x2
- GitHub account3
- GCP account4
- gcloud CLI5
- A domain name
- GitHub CLI (optional)6
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:
generate "gcp" {
path = "provider_gcp.tf"
if_exists = "overwrite_terragrunt"
contents = <<-EOF
provider "google" {
project = "developer-friendly"
region = "europe-west4"
}
EOF
}
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.
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "< 7"
}
random = {
source = "hashicorp/random"
version = "< 4"
}
}
required_version = "< 2"
}
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"
}
include "gcp" {
path = find_in_parent_folders("gcp.hcl")
}
inputs = {
}
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!
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "< 7"
}
}
required_version = "< 2"
}
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.
include "gcp" {
path = find_in_parent_folders("gcp.hcl")
}
inputs = {
}
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.
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "< 7"
}
}
required_version = "< 2"
}
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"
}
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 ).
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.
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.
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "< 7"
}
cloudflare = {
source = "cloudflare/cloudflare"
version = "< 6"
}
}
required_version = "< 2"
}
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
}
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
}
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.
terraform {
required_providers {
github = {
source = "integrations/github"
version = "< 7"
}
}
required_version = "< 2"
}
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.
<!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>
{
"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"
}
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
}
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,
]
}
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>



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
-
https://github.com/gruntwork-io/terragrunt/releases/tag/v0.73.5 ↩
-
https://cloud.google.com/cdn/docs/setting-up-cdn-with-bucket#make_your_bucket_public ↩
-
https://registry.terraform.io/providers/hashicorp/google/6.20.0/docs/resources/iam_workload_identity_pool_provider#example-usage---iam-workload-identity-pool-provider-github-actions ↩
-
https://cloud.google.com/iam/docs/workload-identity-federation ↩↩
-
https://cloud.google.com/iam/docs/service-account-overview ↩
-
https://developer.hashicorp.com/terraform/language/state/remote-state-data ↩