About this course
<p>Every team that ships software eventually builds a staging environment. And every team that builds a staging environment eventually discovers the same problem.</p>
<p>Staging is shared. One environment, many pull requests. Someone deploys a half-finished feature to test it. Someone else deploys on top of that. A third person runs a database migration. By Thursday afternoon, nobody can tell which version of which branch is actually running, and the sentence "it works on staging" has stopped carrying any information at all.</p>
<p>The workarounds are familiar: a Slack channel where people claim staging like a meeting room, a rotating deploy schedule, or a second staging environment, then a third, each one drifting further from production configuration than the last.</p>
<p>There's a better pattern, and platform teams at companies like Vercel, Netlify, and Shopify have made it standard: <strong>give every pull request its own environment</strong>.</p>
<p>Open a PR, and a fresh, isolated copy of your application deploys automatically. Push another commit, and it updates. Merge or close the PR, and the whole thing deletes itself. No queue, no claiming, no cleanup, and no drift.</p>
<p>That's what we're building in this tutorial. Here's what it looks like from the reviewer's side. A bot comment appearing on the pull request with a live, working URL</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/366bb671-d2cb-41e5-859b-aa2242c9dd77.png" alt="A GitHub pull request thread showing an automated bot comment titled "Preview environment is ready", containing a live HTTPS URL, the commit SHA, and the namespace name, with all checks passing below it." style="display: block;" width="600" height="400" loading="lazy">
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>By the end of this handbook, you'll have a working system where:</p>
<ul>
<li><p>Opening a pull request triggers a GitHub Actions workflow that builds a container image tagged specifically for that PR</p>
</li>
<li><p>The image is pushed to Google Artifact Registry</p>
</li>
<li><p>A dedicated Kubernetes namespace (<code>pr-42</code>, <code>pr-43</code>, and so on) is created inside a GKE cluster, with resource quotas applied before anything deploys</p>
</li>
<li><p>The application deploys into that namespace, completely isolated from every other PR's environment</p>
</li>
<li><p>A shared Ingress controller routes <code>pr-42.preview.yourdomain.com</code> to that namespace, with a valid TLS certificate issued automatically</p>
</li>
<li><p>A bot comments the live URL on the pull request, updating the same comment on every push</p>
</li>
<li><p>Pushing new commits redeploys the environment with the updated code</p>
</li>
<li><p>Closing or merging the PR deletes the namespace and everything inside it</p>
</li>
<li><p>A scheduled job catches any environment the teardown missed</p>
</li>
</ul>
<p>You'll also build the underlying infrastructure with Terraform, with three key design decisions, each with its own section.</p>
<ol>
<li><p><strong>No service account keys:</strong> GitHub Actions authenticates to Google Cloud through Workload Identity Federation. Short-lived OIDC tokens verified against a repository claim. Service account JSON keys sitting in GitHub secrets are one of the most common security problems in CI/CD pipelines today, and you don't need one.</p>
</li>
<li><p><strong>One load balancer, not one per PR:</strong> A single shared Ingress controller routes every environment by hostname, and one wildcard DNS record covers every hostname that will ever exist. The naïve version provisions a cloud load balancer per environment, which gets expensive quickly.</p>
</li>
<li><p><strong>Cleanup that assumes failure:</strong> Teardown runs when a PR closes. But a scheduled reaper also deletes namespaces whose PR is gone or that have outlived a maximum age, because even 95% effective automated cleanup still leaks, and leaks show up on the bill.</p>
</li>
</ol>
<p>Here's the architecture we're working toward:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/1a414785-4e82-4e8f-ba58-1e02a093677c.png" alt="Architecture diagram of the complete system. GitHub Actions builds and deploys to a GKE cluster through Workload Identity Federation and Artifact Registry, while browsers reach per-pull-request namespaces through wildcard DNS, a shared Google Cloud load balancer, and an NGINX Ingress controller with cert-manager handling TLS." style="display: block;" width="600" height="400" loading="lazy">
<p>The diagram traces two separate paths through the same system.</p>
<p>The solid blue path is automation. A pull request triggers GitHub Actions, which authenticates to Google Cloud through Workload Identity Federation, pushes a PR-tagged image to Artifact Registry, and deploys into a dedicated namespace inside the GKE cluster.</p>
<p>Each namespace (<code>pr-42</code>, <code>pr-43</code>, and so on) holds its own Deployment, Service, and Pod, completely isolated from every other one.</p>
<p>Two more automated pieces sit alongside it: a bot that posts and updates the preview URL on the pull request, and a scheduled reaper that finds and deletes orphaned namespaces.</p>
<p>The dashed green path is public traffic. A browser request for a preview URL resolves through a single wildcard DNS record to one Google Cloud load balancer, which hands it to a shared NGINX Ingress controller. That controller reads the request's <code>Host</code> header and routes it to the right namespace. cert-manager runs beside it, requesting certificates from Let's Encrypt so each environment gets its own valid TLS certificate.</p>
<p>The important detail is what <em>doesn't</em> multiply. One load balancer, one DNS record, and one Ingress controller serve every environment, no matter how many pull requests are open.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-step-1-set-up-the-project-structure">Step 1: Set Up the Project Structure</a></p>
</li>
<li><p><a href="#heading-step-2-write-the-application">Step 2: Write the Application</a></p>
</li>
<li><p><a href="#heading-step-3-write-the-dockerfile">Step 3: Write the Dockerfile</a></p>
</li>
<li><p><a href="#heading-step-4-provision-the-infrastructure-with-terraform">Step 4: Provision the Infrastructure with Terraform</a></p>
</li>
<li><p><a href="#heading-step-5-set-up-keyless-authentication-with-workload-identity-federation">Step 5: Set Up Keyless Authentication with Workload Identity Federation</a></p>
</li>
<li><p><a href="#heading-step-6-apply-the-terraform-configuration">Step 6: Apply the Terraform Configuration</a></p>
</li>
<li><p><a href="#heading-step-7-prove-the-lifecycle-manually">Step 7: Prove the Lifecycle Manually</a></p>
</li>
<li><p><a href="#heading-step-8-write-the-kubernetes-manifests-with-kustomize">Step 8: Write the Kubernetes Manifests with Kustomize</a></p>
</li>
<li><p><a href="#heading-step-9-build-the-spin-up-workflow">Step 9: Build the Spin-Up Workflow</a></p>
</li>
<li><p><a href="#heading-step-10-build-the-teardown-workflow">Step 10: Build the Teardown Workflow</a></p>
</li>
<li><p><a href="#heading-step-11-open-a-pull-request-and-watch-it-work">Step 11: Open a Pull Request and Watch It Work</a></p>
</li>
<li><p><a href="#heading-step-12-reserve-a-static-ip-address">Step 12: Reserve a Static IP Address</a></p>
</li>
<li><p><a href="#heading-step-13-install-the-ingress-controller">Step 13: Install the Ingress Controller</a></p>
</li>
<li><p><a href="#heading-step-14-point-wildcard-dns-at-the-load-balancer">Step 14: Point Wildcard DNS at the Load Balancer</a></p>
</li>
<li><p><a href="#heading-step-15-install-cert-manager-and-create-a-clusterissuer">Step 15: Install cert-manager and Create a ClusterIssuer</a></p>
</li>
<li><p><a href="#heading-step-16-add-an-ingress-to-the-base-manifests">Step 16: Add an Ingress to the Base Manifests</a></p>
</li>
<li><p><a href="#heading-step-17-patch-the-hostname-per-pull-request">Step 17: Patch the Hostname Per Pull Request</a></p>
</li>
<li><p><a href="#heading-step-18-add-guardrails-with-resourcequota-and-limitrange">Step 18: Add Guardrails with ResourceQuota and LimitRange</a></p>
</li>
<li><p><a href="#heading-step-19-update-the-deploy-workflow">Step 19: Update the Deploy Workflow</a></p>
</li>
<li><p><a href="#heading-step-20-comment-the-url-on-the-pull-request">Step 20: Comment the URL on the Pull Request</a></p>
</li>
<li><p><a href="#heading-step-21-build-the-reaper">Step 21: Build the Reaper</a></p>
</li>
<li><p><a href="#heading-step-22-test-the-whole-thing">Step 22: Test the Whole Thing</a></p>
</li>
<li><p><a href="#heading-what-this-costs">What This Costs</a></p>
</li>
<li><p><a href="#heading-cleanup">Cleanup</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-project-file-structure">Project File Structure</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, make sure you have the following ready:</p>
<ul>
<li><p><strong>A Google Cloud project</strong> with billing enabled. If you don't have one, create it at <a href="https://console.cloud.google.com">console.cloud.google.com</a>. Running this tutorial costs roughly $5–10 depending on how long you leave the cluster up. Don't skip the cleanup section at the end.</p>
</li>
<li><p><code>gcloud</code> <strong>CLI</strong> installed and authenticated. Run <code>gcloud auth login</code> to sign in, and <code>gcloud config set project YOUR_PROJECT_ID</code> to point it at your project.</p>
</li>
<li><p><strong>Terraform</strong> version 1.5 or later. Check with <code>terraform version</code>.</p>
</li>
<li><p><code>kubectl</code> installed. This is the CLI for interacting with Kubernetes clusters.</p>
</li>
<li><p><strong>Docker</strong> installed locally, for testing the build before you hand it to CI.</p>
</li>
<li><p><strong>A GitHub repository</strong> that you own. This needs to be a repository you can push branches to and open pull requests against, since that's the trigger for everything we build.</p>
</li>
<li><p><strong>Basic familiarity with Docker</strong> (images, layers, Dockerfile), <strong>Kubernetes</strong> (pods, deployments, services, namespaces), and <strong>GitHub Actions</strong> (workflows, jobs, steps). You don't need to be an expert, but you should know what these things are.</p>
</li>
<li><p><strong>Helm</strong> version 3, for installing the Ingress controller and cert-manager. Check with <code>helm version</code>.</p>
</li>
<li><p><strong>A domain name you control</strong>, with the ability to add DNS records. It doesn't matter where it's registered. You'll dedicate one subdomain to previews, like <code>preview.yourdomain.com</code>, and every environment lives beneath it.</p>
</li>
</ul>
<p>A note on the domain requirement: this is the part of the project that can't be faked. Let's Encrypt needs to verify that you control the domain before it will issue a certificate, and it verifies this by making a real HTTP request from the public internet. A <code>.local</code> domain, an entry in <code>/etc/hosts</code>, or a domain you don't own won't work.</p>
<p>If you don't have a spare domain, a <code>.dev</code> or <code>.xyz</code> costs a few pounds a year and is worth having for exactly this kind of project.</p>
<p>One more thing worth bringing your attention to early: you'll also need the <code>gke-gcloud-auth-plugin</code>. Recent versions of <code>gcloud</code> prompt you to install it the first time you connect to a GKE cluster. You can install it ahead of time with:</p>
<pre><code class="language-bash">gcloud components install gke-gcloud-auth-plugin
</code></pre>
<h2 id="heading-step-1-set-up-the-project-structure">Step 1: Set Up the Project Structure</h2>
<p>Let's lay out the directories first, so you have a clear mental model of where everything lives.</p>
<pre><code class="language-bash">mkdir -p pr-preview-envs/{app,terraform,k8s/base,.github/workflows}
cd pr-preview-envs
git init
</code></pre>
<p>Here's what each directory holds:</p>
<ul>
<li><p><code>app/</code>: the Go application and its Dockerfile</p>
</li>
<li><p><code>terraform/</code>: infrastructure as code: the GKE cluster, Artifact Registry, and Workload Identity Federation setup</p>
</li>
<li><p><code>k8s/base/</code>: the base Kubernetes manifests that every preview environment is built from</p>
</li>
<li><p><code>.github/workflows/</code>: the two GitHub Actions workflows that create and destroy environments</p>
</li>
</ul>
<p>Connect this to a GitHub repository now, because the Workload Identity Federation setup in Step 5 needs to know your repository's full name (<code>owner/repo</code>):</p>
<pre><code class="language-bash">git remote add origin https://github.com/YOUR_USERNAME/pr-preview-envs.git
</code></pre>
<h2 id="heading-step-2-write-the-application">Step 2: Write the Application</h2>
<p>We need something to deploy. We'll use <strong>Go</strong>, for three reasons that matter to this project specifically.</p>
<p>First, Go compiles to a single statically-linked binary. There's no runtime to install and no interpreter, which means very small container images, and small images mean fast pushes and fast pulls, which matters a lot when you're building an image on <em>every single commit to every open PR</em>.</p>
<p>Second, Go's <code>-ldflags -X</code> mechanism lets us inject values into the binary at build time. We'll use this to bake the PR number and Git commit SHA directly into the application, so the running app can tell us exactly which build it is.</p>
<p>And lastly, build times are fast, and CI minutes aren't free.</p>
<p>Create <code>app/main.go</code>:</p>
<pre><code class="language-go">package main
import (
"fmt"
"net/http"
"os"
)
// These values are empty at compile time and get injected by the
// linker via -ldflags -X during the Docker build. See the Dockerfile.
var (
prNumber = "local"
gitSha = "dev"
)
func handler(w http.ResponseWriter, r *http.Request) {
// Kubernetes injects these via the Downward API — see deployment.yaml
namespace := os.Getenv("POD_NAMESPACE")
podName := os.Getenv("POD_NAME")
if namespace == "" {
namespace = "not-in-kubernetes"
}
if podName == "" {
podName, _ = os.Hostname()
}
fmt.Fprintf(w, "Preview environment is live\n\n")
fmt.Fprintf(w, "PR number : %s\n", prNumber)
fmt.Fprintf(w, "Git SHA : %s\n", gitSha)
fmt.Fprintf(w, "Namespace : %s\n", namespace)
fmt.Fprintf(w, "Pod name : %s\n", podName)
}
func healthz(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
}
func main() {
http.HandleFunc("/", handler)
http.HandleFunc("/healthz", healthz)
fmt.Println("Server starting on port 8080...")
if err := http.ListenAndServe(":8080", nil); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
os.Exit(1)
}
}
</code></pre>
<p>Initialize the Go module from inside <code>app/</code>:</p>
<pre><code class="language-bash">cd app
go mod init preview-app
</code></pre>
<p>Let's walk through what this code does and why each piece is here.</p>
<p><code>var prNumber = "local"</code> <strong>and</strong> <code>var gitSha = "dev"</code> are package-level string variables with default values. The defaults are what you'll see if you run the binary locally without going through the Docker build.</p>
<p>During the CI build, the Go linker overwrites them with the real PR number and commit SHA. This is the single most useful debugging feature in the whole project: when you hit a preview environment, the app itself tells you exactly which commit it was built from. There's no guessing.</p>
<p><code>os.Getenv("POD_NAMESPACE")</code> <strong>and</strong> <code>os.Getenv("POD_NAME")</code> read environment variables that Kubernetes injects into the container using something called the <strong>Downward API</strong>. We'll configure that in the deployment manifest in Step 8.</p>
<p>The Downward API lets a pod read metadata about itself (its own name, namespace, and labels) without needing any Kubernetes API credentials. It's the cleanest way for an app to know where it's running.</p>
<p><strong>The</strong> <code>handler</code> <strong>function</strong> is registered on the root path <code>/</code>. It writes the PR number, commit SHA, namespace, and pod name to the response. It's four pieces of information that together answer "what exactly am I looking at right now?"</p>
<p><strong>The</strong> <code>healthz</code> <strong>function</strong> is registered on <code>/healthz</code>, and returns HTTP 200 with the text <code>ok</code>. Kubernetes uses this endpoint for liveness and readiness probes, which we'll wire up in the deployment manifest.</p>
<p><code>http.ListenAndServe(":8080", nil)</code> starts the server on port 8080. If it fails to start, it prints the error and exits with a non-zero code, so Kubernetes knows the container is broken and can restart it.</p>
<p>Test it locally before moving on:</p>
<pre><code class="language-bash">go run main.go
</code></pre>
<p>In a second terminal:</p>
<pre><code class="language-bash">curl http://localhost:8080
</code></pre>
<p>You should see <code>PR number : local</code> and <code>Git SHA : dev</code>, since you're running outside the Docker build. That's the expected local behaviour.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/1ee23b56-dcd8-48f1-a0c8-5c1e3a908d2c.png" alt="Terminal output from curl showing the application reporting PR number "local" and Git SHA "dev" when run outside the Docker build" style="display: block;" width="600" height="400" loading="lazy">
<h2 id="heading-step-3-write-the-dockerfile">Step 3: Write the Dockerfile</h2>
<p>Create <code>app/Dockerfile</code>:</p>
<pre><code class="language-dockerfile"># -----------------------------------------------------------
# Stage 1: Build
# -----------------------------------------------------------
FROM golang:1.27-alpine AS builder
#Ensure the version matches what is in your go.mod file
# These are injected by the CI pipeline at build time
ARG PR_NUMBER=local
ARG GIT_SHA=dev
WORKDIR /app
# Copy go.mod first so Docker can cache the dependency layer
COPY go.mod .
RUN go mod download
COPY main.go .
# -w -s strips debug symbols (~30% smaller binary)
# -X overwrites package-level string variables at link time
RUN CGO_ENABLED=0 go build \
-ldflags="-w -s -X main.prNumber=${PR_NUMBER} -X main.gitSha=${GIT_SHA}" \
-o server main.go
# -----------------------------------------------------------
# Stage 2: Runtime
# -----------------------------------------------------------
FROM alpine:latest
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]
</code></pre>
<p>There are a few things happening here worth understanding properly.</p>
<h3 id="heading-1-the-ldflags-x-injection">1. The <code>-ldflags -X</code> Injection</h3>
<pre><code class="language-plaintext">-X main.prNumber=${PR_NUMBER}
</code></pre>
<p>The <code>-X</code> linker flag sets the value of a package-level string variable at link time. The syntax is <code>-X importpath.name=value</code>. Since our variables live in <code>package main</code>, the import path is just <code>main</code>.</p>
<p>This only works on <code>string</code> variables that are already declared in the source. You can't create new variables this way, and you can't inject into an <code>int</code> or a <code>bool</code>. That's why <code>prNumber</code> is a string in <code>main.go</code> even though it holds a number.</p>
<p>Why do this instead of just passing an environment variable through the deployment manifest? Because build metadata belongs to the <em>image</em>, not the <em>deployment</em>. If you pass it as an env var, someone can deploy image <code>abc123</code> with an env var claiming it's commit <code>def456</code>, and the app will happily lie to you. Baking it into the binary means the image can only ever report what it actually is.</p>
<h3 id="heading-the-cgoenabled0-flag">The <code>CGO_ENABLED=0</code> Flag</h3>
<p>By default, Go can link against C libraries. When it does, the resulting binary depends on the system's C library being present at runtime. Alpine uses <code>musl</code> instead of the more common <code>glibc</code>, and that mismatch is a classic source of "works in the builder stage, crashes in the runtime stage" errors.</p>
<p>Setting <code>CGO_ENABLED=0</code> forces a pure-Go, fully static binary with no external library dependencies. It will run on any Linux system, including a <code>scratch</code> image with nothing in it at all.</p>
<h3 id="heading-copy-gomod-before-copy-maingo"><code>COPY go.mod .</code> Before <code>COPY main.go .</code></h3>
<p>Docker builds images layer by layer and caches each layer. By copying only <code>go.mod</code> first and running <code>go mod download</code> before copying the source code, we create a cached dependency layer.</p>
<p>On subsequent builds (and remember, this pipeline builds on <em>every commit to every open PR</em>), Docker skips the dependency download entirely as long as <code>go.mod</code> hasn't changed. On a real project with dozens of dependencies, this is the difference between a 20-second build and a 3-minute build, multiplied by every push your team makes.</p>
<h3 id="heading-the-non-root-user">The Non-Root User</h3>
<pre><code class="language-dockerfile">RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
</code></pre>
<p>Containers run as root by default. If an attacker finds a vulnerability in your application, running as root inside the container gives them significantly more room to escalate. Creating an unprivileged user and switching to it limits the blast radius. It costs you two lines, and there's no reason not to do it.</p>
<p>Build and test it locally:</p>
<pre><code class="language-bash">docker build --build-arg PR_NUMBER=99 --build-arg GIT_SHA=abc1234 -t preview-app:test .
docker run -p 8080:8080 preview-app:test
</code></pre>
<p>Then <code>curl http://localhost:8080</code> in another terminal. You should now see <code>PR number : 99</code> and <code>Git SHA : abc1234</code>. The injection works.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/bce5bd63-02fc-4ee6-9631-34f810fad546.png" alt="Terminal output from curl showing the application reporting PR number 99 and Git SHA abc1234, injected at build time by the Docker build arguments" style="display: block;" width="600" height="400" loading="lazy">
<h2 id="heading-step-4-provision-the-infrastructure-with-terraform">Step 4: Provision the Infrastructure with Terraform</h2>
<p>Now we'll build the cloud infrastructure. We're using Terraform rather than a series of <code>gcloud</code> commands for a reason that becomes obvious the moment something goes wrong: with Terraform, <code>terraform destroy</code> cleans up everything you created, in the right order, with no orphaned resources quietly billing you.</p>
<p>Create <code>terraform/variables.tf</code>:</p>
<pre><code class="language-hcl">variable "project_id" {
description = "GCP project ID"
type = string
}
variable "region" {
description = "GCP region"
type = string
default = "europe-west2"
}
variable "zone" {
description = "GCP zone. A zonal cluster is cheaper than a regional one."
type = string
default = "europe-west2-a"
}
variable "cluster_name" {
description = "Name of the GKE cluster"
type = string
default = "pr-preview-cluster"
}
variable "github_repo" {
description = "GitHub repository in owner/repo format, e.g. amina/pr-preview-envs"
type = string
}
variable "machine_type" {
description = "Node machine type"
type = string
default = "e2-standard-2"
}
variable "node_count" {
description = "Number of nodes in the pool"
type = number
default = 2
}
</code></pre>
<p>Now create <code>terraform/main.tf</code>:</p>
<pre><code class="language-hcl">terraform {
required_version = ">= 1.5"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
provider "google" {
project = var.project_id
region = var.region
}
# Look up the project so we can reference its numeric project number,
# which Workload Identity Federation requires (it doesn't accept the
# human-readable project ID).
data "google_project" "current" {}
# -----------------------------------------------------------
# Enable required APIs
# -----------------------------------------------------------
resource "google_project_service" "apis" {
for_each = toset([
"container.googleapis.com",
"artifactregistry.googleapis.com",
"iamcredentials.googleapis.com",
"sts.googleapis.com",
])
service = each.value
disable_on_destroy = false
}
# -----------------------------------------------------------
# Artifact Registry — where preview images live
# -----------------------------------------------------------
resource "google_artifact_registry_repository" "preview" {
location = var.region
repository_id = "pr-preview"
description = "Container images for PR preview environments"
format = "DOCKER"
depends_on = [google_project_service.apis]
}
# -----------------------------------------------------------
# GKE cluster
# -----------------------------------------------------------
resource "google_container_cluster" "primary" {
name = var.cluster_name
location = var.zone
# Create the cluster with a throwaway default pool, then remove it
# and manage nodes through a separate node pool resource. This is the
# standard pattern — it lets you resize or upgrade the node pool
# without recreating the cluster control plane.
remove_default_node_pool = true
initial_node_count = 1
networking_mode = "VPC_NATIVE"
ip_allocation_policy {}
# Set to true before you use this for anything real.
deletion_protection = false
depends_on = [google_project_service.apis]
}
resource "google_container_node_pool" "primary_nodes" {
name = "${var.cluster_name}-pool"
location = var.zone
cluster = google_container_cluster.primary.name
node_count = var.node_count
node_config {
machine_type = var.machine_type
disk_size_gb = 50
oauth_scopes = [
"https://www.googleapis.com/auth/cloud-platform",
]
labels = {
purpose = "pr-preview-envs"
}
}
management {
auto_repair = true
auto_upgrade = true
}
}
</code></pre>
<p>There are a few things that're worth calling out here.</p>
<p><code>data "google_project" "current"</code> isn't creating anything. It's a data source, which reads existing information from your provider. We need it because Workload Identity Federation identifies your project by its <strong>numeric project number</strong>, not the project ID you type in the console. Rather than making you look it up manually and paste it in, Terraform fetches it for you.</p>
<p><code>for_each</code> <strong>on</strong> <code>google_project_service</code>: instead of writing four nearly identical resource blocks to enable four APIs, <code>for_each</code> iterates over a set and creates one resource per item. <code>disable_on_destroy = false</code> means <code>terraform destroy</code> won't turn these APIs off, which is what you want, because other things in your project might be using them.</p>
<p><code>remove_default_node_pool = true</code>: GKE insists on creating a default node pool when you create a cluster. Setting this immediately deletes it, and we define our own node pool as a separate resource. The benefit is that node pool changes (resizing, changing machine type, upgrading) don't force a change to the cluster resource itself.</p>
<p><code>node_count = 2</code> <strong>with</strong> <code>e2-standard-2</code>: two nodes with 2 vCPU and 8GB each. Preview environments are small, but you'll have several running at once, plus GKE's own system pods. Two nodes give you comfortable headroom. If you plan to keep more than about eight preview environments alive simultaneously, bump this up.</p>
<p><code>deletion_protection = false</code>: GKE added this as a guard against accidentally destroying production clusters. We're disabling it because this is a tutorial cluster you'll delete at the end. If you adapt this for real use, set it to <code>true</code>.</p>
<h2 id="heading-step-5-set-up-keyless-authentication-with-workload-identity-federation">Step 5: Set Up Keyless Authentication with Workload Identity Federation</h2>
<p>This is the section that matters most, so let's take it slowly.</p>
<h3 id="heading-the-problem-with-service-account-keys">The Problem With Service Account Keys</h3>
<p>GitHub Actions needs permission to push images to Artifact Registry and deploy to your GKE cluster. The way most tutorials handle this is:</p>
<ol>
<li><p>Create a service account in GCP</p>
</li>
<li><p>Generate a JSON key for it</p>
</li>
<li><p>Paste that JSON into a GitHub secret called <code>GCP_SA_KEY</code></p>
</li>
<li><p>Load it in the workflow</p>
</li>
</ol>
<p>This works, but it's also a security problem, and here's why:</p>
<p>First, <strong>the key never expires.</strong> A service account JSON key is a long-lived credential. It's valid until someone explicitly revokes it, which in practice means it's valid forever, because nobody remembers to rotate them.</p>
<p>Second, <strong>the key is a bearer token.</strong> Anyone who obtains that JSON file <em>is</em> your service account. No second factor, IP restriction, or expiry. Copy the file, and use it from anywhere in the world.</p>
<p>Third, <strong>the blast radius is enormous.</strong> GitHub secrets are exposed to workflows. A compromised third-party action, a malicious dependency in your build, or a workflow modified in a pull request can exfiltrate that secret. Once it's out, an attacker has persistent access to your cloud project.</p>
<p>This isn't theoretical. Leaked service account keys are one of the most common root causes in cloud security incidents.</p>
<h3 id="heading-how-workload-identity-federation-fixes-it">How Workload Identity Federation Fixes It</h3>
<p><strong>Workload Identity Federation</strong> removes the long-lived key entirely. Here's the flow:</p>
<ol>
<li><p>GitHub Actions generates a short-lived <strong>OIDC token</strong> for the running workflow. This token is signed by GitHub and contains verified claims: which repository is running, which branch, which workflow, and which actor.</p>
</li>
<li><p>Your workflow presents that token to Google Cloud's Security Token Service.</p>
</li>
<li><p>Google checks the token's signature against GitHub's public keys, and checks the claims against rules <strong>you</strong> configured, specifically that the token came from your repository and no other.</p>
</li>
<li><p>If everything matches, Google issues a short-lived access token, typically valid for about an hour.</p>
</li>
<li><p>The workflow uses that token, and it expires shortly after.</p>
</li>
</ol>
<p>There's no secret stored anywhere. Nothing to leak, rotate, or revoke. If someone forks your repository and runs the same workflow, the OIDC token they generate will carry <em>their</em> repository name in its claims, Google's check will fail, and they get nothing.</p>
<p>Add this to <code>terraform/main.tf</code>:</p>
<pre><code class="language-hcl"># -----------------------------------------------------------
# Service account that GitHub Actions will impersonate
# -----------------------------------------------------------
resource "google_service_account" "github_actions" {
account_id = "github-actions-preview"
display_name = "GitHub Actions — PR Preview Environments"
}
# Permission to push images to Artifact Registry
resource "google_project_iam_member" "artifact_writer" {
project = var.project_id
role = "roles/artifactregistry.writer"
member = "serviceAccount:${google_service_account.github_actions.email}"
}
# Permission to create namespaces and deploy workloads in GKE.
# container.developer grants full access to Kubernetes API objects,
# but NOT permission to modify the cluster itself.
resource "google_project_iam_member" "gke_developer" {
project = var.project_id
role = "roles/container.developer"
member = "serviceAccount:${google_service_account.github_actions.email}"
}
# -----------------------------------------------------------
# Workload Identity Federation
# -----------------------------------------------------------
resource "google_iam_workload_identity_pool" "github" {
workload_identity_pool_id = "github-pool"
display_name = "GitHub Actions Pool"
description = "Identity pool for GitHub Actions OIDC"
depends_on = [google_project_service.apis]
}
resource "google_iam_workload_identity_pool_provider" "github" {
workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id
workload_identity_pool_provider_id = "github-provider"
display_name = "GitHub OIDC Provider"
# Map claims from the GitHub OIDC token to Google attributes
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.actor" = "assertion.actor"
"attribute.repository" = "assertion.repository"
}
# CRITICAL: without this condition, ANY GitHub repository on the
# internet could authenticate against your pool.
attribute_condition = "assertion.repository == '${var.github_repo}'"
oidc {
issuer_uri = "https://token.actions.githubusercontent.com"
}
}
# Allow tokens from your specific repo to impersonate the service account
resource "google_service_account_iam_member" "github_impersonation" {
service_account_id = google_service_account.github_actions.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/projects/${data.google_project.current.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.github.workload_identity_pool_id}/attribute.repository/${var.github_repo}"
}
</code></pre>
<p>Let's break down the parts that trip people up.</p>
<p><code>attribute_mapping</code>: GitHub's OIDC token contains a set of claims. This block tells Google which claims to read and what to call them internally. <code>assertion.sub</code> is the token's subject (something like <code>repo:owner/name:ref:refs/heads/main</code>). <code>assertion.repository</code> is the full repository name. <code>google.subject</code> is required, while the others are optional attributes you can then write rules against.</p>
<p><code>attribute_condition</code>: read this line twice, because getting it wrong is a serious vulnerability:</p>
<pre><code class="language-hcl">attribute_condition = "assertion.repository == '${var.github_repo}'"
</code></pre>
<p>Without this condition, your identity pool trusts <em>the GitHub OIDC issuer</em>, which means it trusts every repository on GitHub. Anyone who knows your pool's resource path (which isn't secret and appears in workflow files) could authenticate and impersonate your service account.</p>
<p>This condition narrows the trust to exactly one repository. Google now requires an attribute condition when you map <code>assertion.repository</code>, precisely because so many people got this wrong.</p>
<p><code>principalSet://...</code>: this is the identity being granted the <code>workloadIdentityUser</code> role. Read it right to left: any principal in the <code>github-pool</code> whose <code>attribute.repository</code> equals your repo may impersonate this service account. Note that it uses <code>data.google_project.current.number</code>, which is the numeric project number, not the project ID. Using the project ID here produces an error that doesn't clearly explain what's wrong, and it's one of the most common mistakes with WIF.</p>
<p><strong>Why</strong> <code>roles/container.developer</code> <strong>and not</strong> <code>roles/container.admin</code><strong>?</strong> <code>container.developer</code> grants full access to Kubernetes API objects inside clusters: creating namespaces, applying deployments, deleting resources. <code>container.admin</code> additionally allows modifying the clusters themselves: resizing node pools, upgrading, deleting the cluster entirely.</p>
<p>Our workflow never needs to touch the cluster's own configuration, so it shouldn't have permission to. This is least privilege in practice: grant exactly what the job requires and nothing beyond it.</p>
<p>Now create <code>terraform/outputs.tf</code>:</p>
<pre><code class="language-hcl">output "cluster_name" {
value = google_container_cluster.primary.name
}
output "cluster_zone" {
value = var.zone
}
output "artifact_registry_url" {
description = "Base URL for pushing images"
value = "${var.region}-docker.pkg.dev/${var.project_id}/${google_artifact_registry_repository.preview.repository_id}"
}
output "workload_identity_provider" {
description = "Paste this into the GitHub Actions workflow"
value = "projects/${data.google_project.current.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.github.workload_identity_pool_id}/providers/${google_iam_workload_identity_pool_provider.github.workload_identity_pool_provider_id}"
}
output "service_account_email" {
description = "Paste this into the GitHub Actions workflow"
value = google_service_account.github_actions.email
}
output "get_credentials_command" {
value = "gcloud container clusters get-credentials ${google_container_cluster.primary.name} --zone ${var.zone} --project ${var.project_id}"
}
</code></pre>
<p>Those outputs aren't decoration. <code>workload_identity_provider</code> and <code>service_account_email</code> are the two values you'll paste into your GitHub Actions workflow, and both are tedious to construct by hand.</p>
<h2 id="heading-step-6-apply-the-terraform-configuration">Step 6: Apply the Terraform Configuration</h2>
<p>From inside the <code>terraform/</code> directory:</p>
<pre><code class="language-bash">terraform init
</code></pre>
<p>This downloads the Google provider and sets up the backend. You should see "Terraform has been successfully initialized!"</p>
<p>Now preview what will be created:</p>
<pre><code class="language-bash">terraform plan \
-var="project_id=YOUR_PROJECT_ID" \
-var="github_repo=YOUR_USERNAME/pr-preview-envs"
</code></pre>
<p>Read the plan output. Terraform tells you exactly what it intends to create before it creates anything. You should see roughly a dozen resources to add and none to change or destroy.</p>
<p>If that looks right:</p>
<pre><code class="language-bash">terraform apply \
-var="project_id=YOUR_PROJECT_ID" \
-var="github_repo=YOUR_USERNAME/pr-preview-envs"
</code></pre>
<p>Type <code>yes</code> when prompted. Cluster creation takes about five minutes.</p>
<p><strong>Tip:</strong> typing those <code>-var</code> flags repeatedly gets old fast. Create a file called <code>terraform.tfvars</code> containing <code>project_id = "your-project-id"</code> and <code>github_repo = "you/pr-preview-envs"</code>, and Terraform picks it up automatically. Add it to <code>.gitignore</code>.</p>
<p>When it finishes, Terraform prints your outputs. <strong>Save these</strong>, as you'll need them in Step 9:</p>
<pre><code class="language-plaintext">artifact_registry_url = "europe-west2-docker.pkg.dev/your-project/pr-preview"
service_account_email = "github-actions-preview@your-project.iam.gserviceaccount.com"
workload_identity_provider = "projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github-provider"
</code></pre>
<p>You can print them again any time with <code>terraform output</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/23a9ff48-3691-41c5-876f-83cfdf226909.png" alt="Output of Terraform apply showing credentials needed" style="display: block;" width="600" height="400" loading="lazy">
<p>Connect <code>kubectl</code> to the new cluster using the command from the <code>get_credentials_command</code> output:</p>
<pre><code class="language-bash">gcloud container clusters get-credentials pr-preview-cluster \
--zone europe-west2-a \
--project YOUR_PROJECT_ID
</code></pre>
<p>Verify:</p>
<pre><code class="language-bash">kubectl get nodes
</code></pre>
<p>You should see two nodes in <code>Ready</code> state.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/cbfcf36b-9e9a-4d38-8165-3c1327e3b0f6.png" alt="Output of kubectl get nodes showing two GKE nodes in Ready status" style="display: block;" width="600" height="400" loading="lazy">
<h2 id="heading-step-7-prove-the-lifecycle-manually">Step 7: Prove the Lifecycle Manually</h2>
<p>Before automating anything, do it by hand once.</p>
<p>This isn't busywork. Automation is fundamentally just a script running commands you could have typed. If you don't know what the commands are, you can't debug the automation when it fails...and it will fail, usually at 11 pm before a demo. Ten minutes here saves hours later.</p>
<p>Create a namespace:</p>
<pre><code class="language-bash">kubectl create namespace pr-test
</code></pre>
<p>Deploy a placeholder into it:</p>
<pre><code class="language-bash">kubectl create deployment hello \
--image=nginx:1.27-alpine \
--namespace=pr-test
</code></pre>
<p>Check it:</p>
<pre><code class="language-bash">kubectl get pods --namespace=pr-test
</code></pre>
<p>Now the important part – prove the isolation:</p>
<pre><code class="language-bash">kubectl get pods --all-namespaces | grep hello
</code></pre>
<p>The pod exists only in <code>pr-test</code>. Nothing in <code>default</code> or any other namespace knows about it. That's the property the entire project is built on: a namespace is a boundary, and everything inside one PR's namespace is invisible to every other.</p>
<p>Now tear it down:</p>
<pre><code class="language-bash">kubectl delete namespace pr-test
</code></pre>
<p>Watch what happens:</p>
<pre><code class="language-bash">kubectl get namespace pr-test
</code></pre>
<p>For a few seconds, you'll see <code>Terminating</code>, then the namespace disappears entirely. Deleting a namespace deletes every resource inside it: deployments, pods, services, config maps, secrets, everything. One command, complete cleanup.</p>
<p>That single property is why namespace-per-PR is such a good fit for preview environments. Teardown isn't a careful sequence of deletions you have to get right. It's one command that can't leave anything behind.</p>
<h2 id="heading-step-8-write-the-kubernetes-manifests-with-kustomize">Step 8: Write the Kubernetes Manifests with Kustomize</h2>
<p>Every preview environment deploys the same application. The only differences between <code>pr-42</code> and <code>pr-43</code> are the namespace and the image tag.</p>
<p>This is exactly the problem <strong>Kustomize</strong> solves. You write the manifests once as a <strong>base</strong>, then apply small, declarative changes on top of it (an <strong>overlay</strong>) without templating, string substitution, or <code>sed</code> scripts mangling your YAML.</p>
<p>Kustomize is built into <code>kubectl</code> (that's the <code>-k</code> flag), so there's nothing extra to install.</p>
<p>Create <code>k8s/base/deployment.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
name: preview-app
labels:
app: preview-app
spec:
replicas: 1
selector:
matchLabels:
app: preview-app
template:
metadata:
labels:
app: preview-app
spec:
containers:
- name: preview-app
# This is a placeholder. The overlay replaces it with the
# real registry path and PR-specific tag.
image: preview-app
ports:
- containerPort: 8080
env:
# Downward API: the pod learns its own identity
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 2
periodSeconds: 5
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "128Mi"
</code></pre>
<p>Create <code>k8s/base/service.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Service
metadata:
name: preview-app
spec:
selector:
app: preview-app
ports:
- protocol: TCP
port: 80
targetPort: 8080
type: ClusterIP
</code></pre>
<p>Create <code>k8s/base/kustomization.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
</code></pre>
<p>There are some deliberate choices here.</p>
<p><code>replicas: 1</code>: production runs multiple replicas for availability. A preview environment doesn't need high availability. One replica per environment, multiplied across every open PR, keeps your node count and your bill sane.</p>
<p><code>image: preview-app</code>: a bare placeholder with no registry and no tag. It would never pull as-is. Kustomize's image transformer matches on this name and replaces it wholesale, so the base manifest never needs to know your registry URL.</p>
<p><strong>The Downward API</strong> <code>env</code> <strong>block</strong>: this is where <code>POD_NAMESPACE</code> and <code>POD_NAME</code> come from. <code>fieldRef</code> reads a field from the pod's own spec at runtime and exposes it as an environment variable. No API calls, credentials, or service account needed.</p>
<p><code>type: ClusterIP</code>: internal-only. Each <code>LoadBalancer</code> service provisions a real Google Cloud load balancer with a real hourly cost and a public IP. Doing that per PR would be expensive and wasteful. We'll reach the app with <code>kubectl port-forward</code> for now, and in Step 13 a single shared Ingress controller takes over, serving every environment through one load balancer.</p>
<p><strong>Resource requests and limits</strong>: <code>requests</code> reserve capacity so the scheduler knows how much room a pod needs. <code>limits</code> cap what it can consume. Without limits, one runaway preview environment could starve every other one on the node. With many short-lived environments coming and going, this isn't optional.</p>
<p>Test the base renders correctly:</p>
<pre><code class="language-bash">kubectl kustomize k8s/base
</code></pre>
<p>That prints the rendered YAML without applying it. This is useful for debugging.</p>
<h2 id="heading-step-9-build-the-spin-up-workflow">Step 9: Build the Spin-Up Workflow</h2>
<p>Now we connect everything.</p>
<p>Create <code>.github/workflows/preview-deploy.yml</code>:</p>
<pre><code class="language-yaml">name: Deploy PR Preview
on:
pull_request:
types: [opened, synchronize, reopened]
# If someone pushes three commits quickly, cancel the older runs.
# Without this, three deploys race each other into the same namespace
# and whichever finishes last wins — which may not be the newest commit.
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
id-token: write # REQUIRED for Workload Identity Federation
pull-requests: write
env:
PROJECT_ID: YOUR_PROJECT_ID
REGION: europe-west2
CLUSTER_NAME: pr-preview-cluster
CLUSTER_ZONE: europe-west2-a
REPOSITORY: pr-preview
IMAGE_NAME: preview-app
jobs:
deploy:
name: Build and deploy preview
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set environment variables
run: |
PR_NUMBER=${{ github.event.pull_request.number }}
SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7)
echo "PR_NUMBER=${PR_NUMBER}" >> $GITHUB_ENV
echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV
echo "NAMESPACE=pr-${PR_NUMBER}" >> $GITHUB_ENV
echo "IMAGE_TAG=pr-${PR_NUMBER}-${SHORT_SHA}" >> $GITHUB_ENV
echo "IMAGE_BASE=${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_ENV
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v2
- name: Configure Docker for Artifact Registry
run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet
- name: Build and push image
run: |
docker build \
--build-arg PR_NUMBER="${PR_NUMBER}" \
--build-arg GIT_SHA="${SHORT_SHA}" \
-t "${IMAGE_BASE}:${IMAGE_TAG}" \
./app
docker push "${IMAGE_BASE}:${IMAGE_TAG}"
- name: Get GKE credentials
uses: google-github-actions/get-gke-credentials@v2
with:
cluster_name: ${{ env.CLUSTER_NAME }}
location: ${{ env.CLUSTER_ZONE }}
- name: Create namespace
run: |
# --dry-run=client | apply makes this idempotent.
# Running it on an existing namespace is a no-op, not an error.
kubectl create namespace "${NAMESPACE}" \
--dry-run=client -o yaml | kubectl apply -f -
- name: Generate Kustomize overlay
run: |
mkdir -p k8s/overlays/"${NAMESPACE}"
cat > k8s/overlays/"${NAMESPACE}"/kustomization.yaml <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ${NAMESPACE}
resources:
- ../../base
images:
- name: preview-app
newName: ${IMAGE_BASE}
newTag: ${IMAGE_TAG}
EOF
echo "--- Rendered manifest ---"
kubectl kustomize k8s/overlays/"${NAMESPACE}"
- name: Deploy
run: kubectl apply -k k8s/overlays/"${NAMESPACE}"
- name: Wait for rollout
run: |
kubectl rollout status deployment/preview-app \
--namespace="${NAMESPACE}" \
--timeout=180s
- name: Show deployment details
run: |
echo "Namespace: ${NAMESPACE}"
kubectl get pods,svc --namespace="${NAMESPACE}"
</code></pre>
<h3 id="heading-add-the-two-secrets">Add the Two Secrets</h3>
<p>Go to your repository on GitHub and go to <strong>Settings</strong>, <strong>Secrets and variables</strong>, <strong>Actions</strong>, and <strong>New repository secret</strong>. Add:</p>
<table>
<thead>
<tr>
<th>Secret name</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><code>WIF_PROVIDER</code></td>
<td>The <code>workload_identity_provider</code> output from Terraform</td>
</tr>
<tr>
<td><code>WIF_SERVICE_ACCOUNT</code></td>
<td>The <code>service_account_email</code> output from Terraform</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/3badb7bc-a162-4db7-ae18-5c93b492c5cc.png" alt="GitHub repository secrets page listing WIF_PROVIDER and WIF_SERVICE_ACCOUNT" style="display: block;" width="600" height="400" loading="lazy">
<p>Neither of these is a credential. The provider path and service account email are identifiers, not secrets. They grant nothing on their own, because the OIDC token and the attribute condition are what actually control access. Storing them as secrets is just tidiness. Update <code>PROJECT_ID</code> and the region values in the workflow's <code>env:</code> block to match your setup.</p>
<h3 id="heading-understanding-the-workflow">Understanding the Workflow</h3>
<p><code>on: pull_request: types: [opened, synchronize, reopened]</code>: these are three distinct triggers.</p>
<ul>
<li><p><code>opened</code> fires when the PR is first created.</p>
</li>
<li><p><code>synchronize</code> fires on every subsequent push to the branch, which is what keeps the environment current.</p>
</li>
<li><p><code>reopened</code> handles a PR that was closed and then reopened. Without it, that PR would have a torn-down environment and nothing to recreate it.</p>
</li>
</ul>
<p><code>concurrency</code>: this one saves real debugging time. Push three commits in quick succession, and you get three workflow runs targeting the same namespace. They race. The one that finishes last wins, and that's not necessarily the newest code. A fast early build can overtake a slow later one, leaving your preview environment silently running stale code while the PR shows green. Grouping by PR number with <code>cancel-in-progress: true</code> means only the newest run survives.</p>
<p><code>permissions: id-token: write</code>: without this line, nothing else works. It's what allows the workflow to request an OIDC token from GitHub. Leave it out and <code>google-github-actions/auth</code> fails with an error about being unable to get an ID token. This is the single most common WIF setup mistake.</p>
<p><code>$GITHUB_ENV</code>: writing <code>KEY=value</code> to this file makes the variable available in all <em>subsequent</em> steps. Not the current one, as a variable you write in a step can't be read later in that same step. It's why the setup happens in its own step at the top.</p>
<p><code>github.event.pull_request.head.sha</code>: note this rather than <code>github.sha</code>. On <code>pull_request</code> events, GitHub checks out a <em>merge commit</em>. This is a temporary commit combining your branch with the base branch. <code>github.sha</code> refers to that merge commit, which doesn't exist in your repository's history and will confuse anyone who tries to look it up. <code>head.sha</code> is the actual last commit on the PR branch, which is what a developer expects to see.</p>
<p><code>--dry-run=client -o yaml | kubectl apply -f -</code> is a small trick worth stealing. <code>kubectl create namespace</code> fails if the namespace already exists, which would break every push after the first. This pipeline generates the namespace YAML without contacting the cluster, then pipes it to<code>apply</code>, which creates it if it's missing and does nothing if it isn't. Idempotent by construction.</p>
<p><strong>The heredoc overlay</strong>: instead of committing an overlay per PR (impossible, as you don't know PR numbers in advance), the workflow writes one at runtime and throws it away when the runner is destroyed. It's ephemeral configuration for an ephemeral environment.</p>
<p><code>kubectl rollout status</code> is what makes the workflow honest. Without it, the job succeeds the moment <code>kubectl apply</code> is accepted by the API server, which happens before a single pod has started. A crash-looping image would still show a green check. <code>rollout status</code> blocks until pods are actually ready, or fails after 180 seconds. Green means working.</p>
<h3 id="heading-a-note-on-forked-pull-requests">A Note on Forked Pull Requests</h3>
<p>Something is worth knowing before you deploy this anywhere public: pull requests from forks do <strong>not</strong> receive <code>id-token: write</code> permission, by design. GitHub blocks it so a stranger can't open a PR that steals your cloud credentials.</p>
<p>For a private repository or a personal project, this doesn't affect you. For a public repository accepting outside contributions, you'd need a <code>workflow_run</code>-based pattern where a privileged workflow runs from the base repository after the untrusted build completes. That's beyond this handbook's scope, but if you're deploying this on an open-source project, don't skip it.</p>
<h2 id="heading-step-10-build-the-teardown-workflow">Step 10: Build the Teardown Workflow</h2>
<p>Spin-up is only half the value. An environment that never disappears is just a slow leak in your cloud bill.</p>
<p>Create <code>.github/workflows/preview-teardown.yml</code>:</p>
<pre><code class="language-yaml">name: Tear Down PR Preview
on:
pull_request:
types: [closed]
permissions:
contents: read
id-token: write
env:
PROJECT_ID: YOUR_PROJECT_ID
CLUSTER_NAME: pr-preview-cluster
CLUSTER_ZONE: europe-west2-a
jobs:
teardown:
name: Delete preview namespace
runs-on: ubuntu-latest
steps:
- name: Set namespace
run: echo "NAMESPACE=pr-${{ github.event.pull_request.number }}" >> $GITHUB_ENV
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
- name: Get GKE credentials
uses: google-github-actions/get-gke-credentials@v2
with:
cluster_name: ${{ env.CLUSTER_NAME }}
location: ${{ env.CLUSTER_ZONE }}
- name: Delete namespace
run: |
echo "Deleting namespace ${NAMESPACE}"
kubectl delete namespace "${NAMESPACE}" \
--ignore-not-found=true \
--wait=false
- name: Confirm
run: |
echo "Preview environment for PR #${{ github.event.pull_request.number }} torn down."
</code></pre>
<p>A few details here matter more than they might seem.</p>
<p>First, <code>types: [closed]</code> <strong>covers merges too.</strong> GitHub doesn't have a separate <code>merged</code> event type. Merging a pull request fires <code>closed</code> with the payload field <code>merged: true</code>. So this single trigger handles both "merged successfully" and "abandoned and closed." Both should tear down the environment, so we don't need to distinguish between them.</p>
<p>Next, <code>--ignore-not-found=true</code>: if the namespace doesn't exist (perhaps the deploy failed, or someone deleted it manually), <code>kubectl delete</code> would normally exit with an error and mark the workflow red. That's a false alarm: the desired end state is "namespace doesn't exist," and it doesn't. This flag makes the step succeed when there's nothing to do.</p>
<p>Finally, <code>--wait=false</code>: namespace deletion is asynchronous. Kubernetes marks it<code>Terminating</code>, then works through deleting every resource inside it. Waiting for that to complete could hold your CI runner for a minute or more, for no benefit. Fire the delete and let the cluster finish on its own time.</p>
<h2 id="heading-step-11-open-a-pull-request-and-watch-it-work">Step 11: Open a Pull Request and Watch It Work</h2>
<p>Commit everything and push:</p>
<pre><code class="language-bash">git add .
git commit -m "Add PR preview environment infrastructure"
git push -u origin main
</code></pre>
<p>Now create a branch and make a visible change:</p>
<pre><code class="language-bash">git checkout -b test-preview
</code></pre>
<p>Edit <code>app/main.go</code>. Change the first line of the handler output:</p>
<pre><code class="language-go">fmt.Fprintf(w, "Preview environment is live — testing the pipeline\n\n")
</code></pre>
<p>Commit and push:</p>
<pre><code class="language-bash">git add .
git commit -m "Test preview environment"
git push -u origin test-preview
</code></pre>
<p>Open a pull request on GitHub, then go to the <strong>Actions</strong> tab. You should see "Deploy PR Preview" running.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/4da5dcda-506a-4a9d-8413-5c5cd32af0c1.png" alt="GitHub Actions summary for a pull request showing the Deploy PR Preview workflow completed successfully" style="display: block;" width="600" height="400" loading="lazy">
<p>Watch the steps in order: authentication succeeds without a key, the image builds and pushes, the namespace is created, Kustomize renders the overlay (you'll see the fully resolved manifest printed), the deployment applies, and <code>rollout status</code> waits until pods are ready.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/3f0ae123-82fb-447e-9a5a-db1e87589340.png" alt="Expanded GitHub Actions log showing the deploy, wait for rollout, and show deployment details steps completing successfully" style="display: block;" width="600" height="400" loading="lazy">
<p>Once it's green, verify from your terminal:</p>
<pre><code class="language-shell">kubectl get namespaces | grep pr-
</code></pre>
<p>You should see your <code>pr-1</code> (or whatever number your PR got).</p>
<pre><code class="language-shell">kubectl get pods --namespace=pr-1
</code></pre>
<p>Now reach the application:</p>
<pre><code class="language-shell">kubectl port-forward --namespace=pr-1 service/preview-app 8080:80
</code></pre>
<p>In a second terminal:</p>
<pre><code class="language-shell">curl http://localhost:8080
</code></pre>
<p>You should see:</p>
<pre><code class="language-shell">Preview environment is live — testing the pipeline
PR number : 1
Git SHA : a1b2c3d
Namespace : pr-1
Pod name : preview-app-7d9f8b6c4-xk2mp
</code></pre>
<p>The application is reporting the PR number and commit SHA that were baked into its binary at build time, and the namespace it discovered through the Downward API. Every one of those values was resolved by a different part of the pipeline, and they all agree. That's your proof the whole chain works.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/17b0712a-0fa3-47be-8078-67663239acfe.png" alt="Terminal output from curl showing the preview environment reporting PR number 1, its commit SHA, namespace pr-1, and the pod name" style="display: block;" width="600" height="400" loading="lazy">
<p>Now push another commit to the same branch and watch the environment update in place.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/2ca76645-9fe0-491d-8dae-05789a6b5498.png" alt="GitHub Actions log showing the Deploy PR Preview workflow running again after a second commit was pushed to the same branch" style="display: block;" width="600" height="400" loading="lazy">
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/426cc240-5ee7-487b-9fe4-dc955b06ac31.png" alt="Terminal showing kubectl port-forward running against namespace pr-1 alongside the curl command used to reach it" style="display: block;" width="600" height="400" loading="lazy">
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/a66c098b-b972-4282-8f84-6f4ab49fc34b.png" alt="Terminal output from curl showing the updated greeting text and new commit SHA after the second push, confirming the environment redeployed in place" style="display: block;" width="600" height="400" loading="lazy">
<p>Then close the pull request and watch the teardown workflow fire.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/bbcacedc-2ade-4945-b849-fd6be8931819.png" alt="GitHub Actions teardown workflow triggered and in progress" style="display: block;" width="600" height="400" loading="lazy">
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/4b5fb004-ebb6-424c-a535-caa9f8037265.png" alt="GitHub Actions teardown workflow completing" style="display: block;" width="600" height="400" loading="lazy">
<pre><code class="language-bash">kubectl get namespace pr-1
</code></pre>
<p><code>Terminating</code>, then gone.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/8d373dd3-f627-400c-aad2-9a845b549ce5.png" alt="Terminal output showing kubectl get namespace returning "namespaces pr-1 not found"" style="display: block;" width="600" height="400" loading="lazy">
<h3 id="heading-what-you-have-and-whats-still-missing">What You Have, and What's Still Missing</h3>
<p>Stop and take stock, because the hard infrastructure work is done. A pull request now produces a real, isolated environment with no manual steps, and closing it removes every trace. That's the whole lifecycle, automated.</p>
<p>But look again at how you reached it:</p>
<pre><code class="language-plaintext">kubectl port-forward --namespace=pr-1 service/preview-app 8080:80
</code></pre>
<p>That requires <code>kubectl</code> installed, credentials for the cluster, permission to use them, and knowledge of which namespace maps to which PR. This is fine for the person who built the pipeline, but it's useless for the designer checking a spacing change, the product manager reviewing a copy edit, or the QA engineer clicking through a flow before sign-off.</p>
<p>The whole point of a preview environment is that <em><strong>anyone</strong></em> can look at the change. An environment only engineers can reach isn't really a preview environment. It's a debugging tool.</p>
<p>The rest of this tutorial closes that gap, and then makes the result safe to leave running. First, we'll set up a public HTTPS URL for every environment. Then we'll build the guardrails and go through the cleanup that stop it becoming a quiet line item on your cloud bill.</p>
<h2 id="heading-step-12-reserve-a-static-ip-address">Step 12: Reserve a Static IP Address</h2>
<p>Before installing anything, you'll need to reserve an external IP address.</p>
<p>This matters because when you install the Ingress controller, it creates a Kubernetes Service of type <code>LoadBalancer</code>, and Google Cloud provisions a real load balancer with a public IP. By default, that IP is <strong>ephemeral</strong>. If the Service is deleted and recreated, you get a different one.</p>
<p>Your wildcard DNS record points at that IP. If it changes, every preview environment breaks at once, and you won't find out until someone tells you the link is dead. Reserving the IP first means it belongs to your project, not to the load balancer, and it survives anything you do to the cluster.</p>
<p>Add this to <code>terraform/main.tf</code>:</p>
<pre><code class="language-hcl"># -----------------------------------------------------------
# Static external IP for the Ingress controller
# -----------------------------------------------------------
resource "google_compute_address" "ingress" {
name = "pr-preview-ingress-ip"
region = var.region
address_type = "EXTERNAL"
depends_on = [google_project_service.apis]
}
</code></pre>
<p>And add the output to <code>terraform/outputs.tf</code>:</p>
<pre><code class="language-hcl">output "ingress_ip" {
description = "Point your wildcard DNS record at this address"
value = google_compute_address.ingress.address
}
</code></pre>
<p>You'll also want <code>compute.googleapis.com</code> in the APIs list if it isn't there already. Update the <code>for_each</code> set in your <code>google_project_service</code> resource:</p>
<pre><code class="language-hcl"> for_each = toset([
"container.googleapis.com",
"artifactregistry.googleapis.com",
"iamcredentials.googleapis.com",
"sts.googleapis.com",
"compute.googleapis.com",
])
</code></pre>
<p>Apply it:</p>
<pre><code class="language-bash">cd terraform
terraform apply \
-var="project_id=YOUR_PROJECT_ID" \
-var="github_repo=YOUR_USERNAME/pr-preview-envs"
</code></pre>
<p>Note the IP that comes back:</p>
<pre><code class="language-bash">terraform output ingress_ip
</code></pre>
<p>Write it down. You'll use it twice in the next two steps.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/a9b0586d-3b1e-4fe2-9eb4-53087b70a45a.png" alt="terraform output ingress_ip showing the reserved address" style="display: block;" width="600" height="400" loading="lazy">
<p><strong>Important:</strong> this must be a <strong>regional</strong> address in the same region as your cluster, which is what <code>region = var.region</code> gives you. Google Cloud also offers global addresses, used by global HTTP(S) load balancers.</p>
<p>The Service type <code>LoadBalancer</code> that ingress-nginx creates is a regional load balancer, and it can't bind a global address. Mixing these up produces a Service stuck in <code>Pending</code> forever with an error that doesn't clearly explain why.</p>
<h2 id="heading-step-13-install-the-ingress-controller">Step 13: Install the Ingress Controller</h2>
<p>An <strong>Ingress</strong> is a Kubernetes resource that describes HTTP routing rules: "requests for this hostname go to that Service." But the Ingress resource is only a description. Something has to actually read those rules and route traffic, and that something is an <strong>Ingress controller</strong>.</p>
<p>We'll use <strong>ingress-nginx</strong>, which is the community-maintained controller built on NGINX. It runs as pods in your cluster, watches the Kubernetes API for Ingress resources, and rewrites its own NGINX configuration whenever one changes.</p>
<p>The architectural point that makes this work for preview environments is that <strong>one controller serves every namespace</strong>. Each preview environment creates its own Ingress resource. The controller picks it up, adds a routing rule for that hostname, and sends traffic to the right Service. The controller doesn't care how many environments exist.</p>
<p>Contrast this with what we'd otherwise do. If each preview environment used a <code>LoadBalancer</code> Service, every PR would provision its own Google Cloud load balancer, each with an hourly charge and its own IP address that DNS would need to know about. Ten open PRs would mean ten load balancers. With a shared Ingress controller, ten open PRs mean one.</p>
<p>Install it with Helm:</p>
<pre><code class="language-bash">helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
helm install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx \
--create-namespace \
--set controller.service.loadBalancerIP="YOUR_STATIC_IP" \
--set controller.service.externalTrafficPolicy=Local
</code></pre>
<p>Replace <code>YOUR_STATIC_IP</code> with the address from Step 12.</p>
<p>Let's break down the flags before we go on:</p>
<p><code>--namespace ingress-nginx --create-namespace</code>: the controller gets its own namespace, separate from any preview environment. It's shared infrastructure, and it should never be caught up in a preview environment's teardown.</p>
<p><code>controller.service.loadBalancerIP</code>: tells GKE to bind the load balancer to the specific address you reserved, rather than allocating a new one. This is the line that makes your DNS record stable.</p>
<p><code>controller.service.externalTrafficPolicy=Local</code>: preserves the client's real source IP. Without it, Kubernetes may route a request through an intermediate node, and by the time it reaches NGINX the source IP has been rewritten to a node's internal address. Your access logs would show cluster IPs instead of real visitors.</p>
<p>The tradeoff is that traffic only lands on nodes actually running a controller pod, which is fine here.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/7813b917-231e-44fc-9d5b-9501c1745145.png" alt="Terminal output from helm install showing the ingress-nginx chart deployed successfully into the ingress-nginx namespace" style="display: block;" width="600" height="400" loading="lazy">
<p>Watch for the external IP to be assigned:</p>
<pre><code class="language-bash">kubectl get service ingress-nginx-controller \
--namespace ingress-nginx \
--watch
</code></pre>
<p>It shows <code><pending></code> for a minute or two while Google provisions the load balancer, then displays your static IP from Step 12. Press Ctrl+C once it appears.</p>
<p>Verify that the controller is healthy:</p>
<pre><code class="language-bash">kubectl get pods --namespace ingress-nginx
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/f38b8e9c-d7e6-4c18-b5ac-927aef24118f.png" alt="Output of kubectl get service and kubectl get pods showing the ingress-nginx controller running with the reserved static external IP assigned" style="display: block;" width="600" height="400" loading="lazy">
<h2 id="heading-step-14-point-wildcard-dns-at-the-load-balancer">Step 14: Point Wildcard DNS at the Load Balancer</h2>
<p>This step takes about two minutes and is the piece that makes the whole pattern scale.</p>
<p>Go to wherever your domain's DNS is managed and create a single <strong>A record</strong>:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Type</td>
<td><code>A</code></td>
</tr>
<tr>
<td>Name / Host</td>
<td><code>*.preview</code></td>
</tr>
<tr>
<td>Value</td>
<td>Your static IP from Step 1</td>
</tr>
<tr>
<td>TTL</td>
<td>300 (or the lowest your provider allows)</td>
</tr>
</tbody></table>
<p>That's it. That's the entire DNS configuration for the project.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/461ad5af-b55e-4ec4-912a-0fac144a4db5.png" alt="DNS provider interface showing a wildcard A record for *.preview pointing to the static IP address of the ingress load balancer" style="display: block;" width="600" height="400" loading="lazy">
<p>Here's why one record is enough. A wildcard DNS record matches <strong>any</strong> label in that position. <code>*.preview.yourdomain.com</code> resolves <code>pr-1.preview.yourdomain.com</code>, <code>pr-42.preview.yourdomain.com</code>, and <code>pr-99999.preview.yourdomain.com</code>, all to the same IP, all without anyone creating a record for them.</p>
<p>This is worth pausing on, because it's the design decision that keeps the system simple. The obvious approach would be to have the CI pipeline create a DNS record when a PR opens and delete it when the PR closes. That means DNS API credentials in your pipeline, another thing to fail, another thing to leak, and another source of orphaned resources.</p>
<p>The wildcard approach eliminates that entire category of work: DNS is configured once, manually, and never touched again.</p>
<p>Routing to the right environment doesn't happen in DNS. Every hostname resolves to the same load balancer, and the Ingress controller reads the <code>Host</code> header of each incoming request to decide which namespace's Service should handle it.</p>
<p>Verify the record has propagated:</p>
<pre><code class="language-bash">dig +short anything-at-all.preview.yourdomain.com
</code></pre>
<p>You should get your static IP back. Note that the subdomain doesn't need to exist yet. That's the whole point of a wildcard.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/a11681bf-5899-47b5-9731-f45096915f02.png" alt="Terminal output from dig returning the ingress static IP for a subdomain that was never explicitly created, confirming the wildcard record resolves" style="display: block;" width="600" height="400" loading="lazy">
<p>If nothing comes back, wait a few minutes and try again. DNS propagation is usually fast with a low TTL, but it isn't instant.</p>
<h2 id="heading-step-15-install-cert-manager-and-create-a-clusterissuer">Step 15: Install cert-manager and Create a ClusterIssuer</h2>
<p>Now for HTTPS.</p>
<p><strong>cert-manager</strong> is a Kubernetes controller that automates the full certificate lifecycle. It watches for Ingress resources annotated with an issuer, requests a certificate from a certificate authority, proves you control the domain, stores the certificate in a Kubernetes Secret, and renews it before it expires. Ther eare no cron jobs, manual renewal, or expired-certificate incident at 3 am.</p>
<p>Install it:</p>
<pre><code class="language-bash">helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--version v1.21.2 \
--set crds.enabled=true
</code></pre>
<p><code>--set crds.enabled=true</code> Installs cert-manager's Custom Resource Definitions: <code>Certificate</code>, <code>Issuer</code>, <code>ClusterIssuer</code>, <code>CertificateRequest</code> and others. These extend the Kubernetes API with new resource types. Without them, cert-manager has nothing to watch for. (If you're following an older tutorial that uses <code>installCRDs=true</code>, that flag was renamed in v1.15.)</p>
<p>Check it's running:</p>
<pre><code class="language-bash">kubectl get pods --namespace cert-manager
</code></pre>
<p>You should see three pods: the controller, the webhook, and cainjector.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/dad07949-4123-4854-861e-5d96e19a4591.png" alt="Output of kubectl get pods showing the cert-manager controller, webhook, and cainjector pods running" style="display: block;" width="600" height="400" loading="lazy">
<h3 id="heading-create-the-clusterissuer">Create the ClusterIssuer</h3>
<p>An <strong>Issuer</strong> tells cert-manager where to get certificates from. A <strong>ClusterIssuer</strong> is the same thing with cluster-wide scope, which is what we need. A namespaced Issuer would have to be recreated in every single preview namespace.</p>
<p>Create <code>k8s/cluster/letsencrypt-staging.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
# Let's Encrypt's staging environment. Certificates from this
# server are NOT trusted by browsers, but the rate limits are
# far more generous. Use this while debugging.
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: YOUR_EMAIL@example.com
privateKeySecretRef:
name: letsencrypt-staging-account-key
solvers:
- http01:
ingress:
ingressClassName: nginx
</code></pre>
<p>And <code>k8s/cluster/letsencrypt-prod.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: YOUR_EMAIL@example.com
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
ingress:
ingressClassName: nginx
</code></pre>
<p>Apply both:</p>
<pre><code class="language-bash">kubectl apply -f k8s/cluster/letsencrypt-staging.yaml
kubectl apply -f k8s/cluster/letsencrypt-prod.yaml
</code></pre>
<p>Confirm they're ready:</p>
<pre><code class="language-bash">kubectl get clusterissuer
</code></pre>
<p>Both should show <code>READY: True</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/e76271ef-0b0c-471f-b826-d5f476bc33f2.png" alt="Output of kubectl get clusterissuer showing both letsencrypt-staging and letsencrypt-prod with READY status True" style="display: block;" width="600" height="400" loading="lazy">
<h3 id="heading-why-two-issuers">Why Two Issuers?</h3>
<p>This isn't redundancy. It's how you avoid a frustrating afternoon.</p>
<p>Let's Encrypt enforces strict rate limits on its production environment. The one that matters here is <strong>50 certificates per registered domain per week</strong>. There's also a duplicate-certificate limit of 5 per week for the identical set of hostnames.</p>
<p>While you're setting this up, you'll likely misconfigure something. The Ingress annotation will be wrong, or the HTTP-01 challenge won't route correctly, or DNS won't have propagated. Each failed attempt burns against your quota, and once you hit the limit you're locked out for a week with no appeal.</p>
<p>The staging issuer has far looser limits. Its certificates are signed by an untrusted root, so browsers show a warning, but that warning <em>is itself proof the pipeline works</em>. If you see a certificate warning naming "Let's Encrypt Staging," everything functioned correctly, and the only remaining step is switching issuers.</p>
<p>Use <code>letsencrypt-staging</code> until the flow works end to end, then switch to <code>letsencrypt-prod</code>.</p>
<h3 id="heading-how-the-http-01-challenge-works">How the HTTP-01 Challenge Works</h3>
<p>This is worth understanding, because it's what breaks when things go wrong.</p>
<p>When cert-manager requests a certificate for <code>pr-42.preview.yourdomain.com</code>, Let's Encrypt needs proof you control that hostname. With the HTTP-01 solver, the sequence is:</p>
<ol>
<li><p>Let's Encrypt gives cert-manager a random token.</p>
</li>
<li><p>cert-manager creates a temporary pod, Service, and Ingress rule that serves that token at <code>http://pr-42.preview.yourdomain.com/.well-known/acme-challenge/<token></code>.</p>
</li>
<li><p>Let's Encrypt makes a real HTTP request to that URL from the public internet.</p>
</li>
<li><p>If the token matches, domain control is proven and the certificate is issued.</p>
</li>
<li><p>cert-manager deletes the temporary resources and stores the certificate in a Secret.</p>
</li>
</ol>
<p>Three things must be true for this to work, and they're the three things to check when it doesn't: DNS must resolve the hostname to your load balancer, port 80 must be reachable from the internet (don't redirect everything to HTTPS at the load balancer level. The challenge is plain HTTP), and the Ingress controller must be routing correctly.</p>
<h3 id="heading-a-note-on-scale">A Note on Scale</h3>
<p>HTTP-01 issues one certificate per hostname, and every preview environment has a unique hostname. That's one certificate per pull request.</p>
<p>Against Let's Encrypt's 50-per-week limit, that's comfortable for a solo project or a small team. An active team opening more than 50 PRs a week would hit the ceiling.</p>
<p>The production answer is to issue a <strong>single wildcard certificate</strong> for <code>*.preview.yourdomain.com</code> and share it across every namespace. Wildcards can't be issued over HTTP-01. They require the <strong>DNS-01</strong> challenge, where cert-manager proves control by creating a TXT record instead of serving a file.</p>
<p>With Cloud DNS, cert-manager can do this automatically using a Google service account. You'd then replicate the resulting Secret into each preview namespace with a tool like <a href="https://github.com/emberstack/kubernetes-reflector">reflector</a>.</p>
<p>That's more moving parts than this handbook needs, but it's the right answer once your PR volume justifies it, and it's worth knowing that limit exists before you hit it.</p>
<h2 id="heading-step-16-add-an-ingress-to-the-base-manifests">Step 16: Add an Ingress to the Base Manifests</h2>
<p>Now we'll give the application a route.</p>
<p>Create <code>k8s/base/ingress.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: preview-app
annotations:
# Tells cert-manager to request a certificate for the hosts
# listed under spec.tls, using this ClusterIssuer.
cert-manager.io/cluster-issuer: letsencrypt-staging
# Redirect HTTP to HTTPS once the certificate exists.
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- preview-host-placeholder
secretName: preview-app-tls
rules:
- host: preview-host-placeholder
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: preview-app
port:
number: 80
</code></pre>
<p>Add it to <code>k8s/base/kustomization.yaml</code>:</p>
<p>You created this file back in Step 8 with two resources. Add the Ingress to it:</p>
<pre><code class="language-yaml">apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- ingress.yaml
</code></pre>
<p>Here are the pieces that matter:</p>
<p><code>cert-manager.io/cluster-issuer</code>: this single annotation is what triggers the entire certificate workflow. cert-manager watches for it, reads the hostnames from <code>spec.tls</code>, and does everything else. Note it's set to <code>letsencrypt-staging</code> for now. We'll switch to production once the flow is proven.</p>
<p><code>ingressClassName: nginx</code>: identifies which controller should handle this Ingress. A cluster can run several controllers simultaneously, and this field prevents them from fighting over the same resource. Older tutorials use the <code>kubernetes.io/ingress.class</code> annotation, which is deprecated.</p>
<p><code>secretName: preview-app-tls</code>: where cert-manager stores the issued certificate. It's created automatically in the same namespace as the Ingress, so each preview environment has its own.</p>
<p><code>preview-host-placeholder</code>: deliberately not a valid hostname. It appears twice: once in <code>spec.tls.hosts</code> and once in <code>spec.rules.host</code>, and both get replaced per PR in the next step. Using something that obviously isn't a real domain means that if the patch ever fails, you get an immediate visible error rather than a subtle misroute.</p>
<p><code>pathType: Prefix</code> <strong>with</strong> <code>path: /</code>: matches every path on the host. Preview environments serve the whole application, not a subpath.</p>
<h2 id="heading-step-17-patch-the-hostname-per-pull-request">Step 17: Patch the Hostname Per Pull Request</h2>
<p>Each environment needs its own hostname substituted into two places in the Ingress. Kustomize handles this with a <strong>JSON 6902 patch</strong>. A precise operation that targets an exact path within a resource.</p>
<p>In Step 9, the workflow generated an overlay that set the namespace and image tag. We extend that generated overlay with a patch block. Here's what the complete generated <code>kustomization.yaml</code> looks like for PR 42:</p>
<pre><code class="language-yaml">apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: pr-42
resources:
- ../../base
images:
- name: preview-app
newName: europe-west2-docker.pkg.dev/your-project/pr-preview/preview-app
newTag: pr-42-a1b2c3d
patches:
- target:
kind: Ingress
name: preview-app
patch: |-
- op: replace
path: /spec/rules/0/host
value: pr-42.preview.yourdomain.com
- op: replace
path: /spec/tls/0/hosts/0
value: pr-42.preview.yourdomain.com
</code></pre>
<p>The patch syntax reads literally: in the resource matching <code>kind: Ingress, name: preview-app</code>, replace the value at <code>/spec/rules/0/host</code>, and replace the value at <code>/spec/tls/0/hosts/0</code>.</p>
<p>The <code>0</code> indices are array positions. <code>/spec/rules/0</code> is the first (and only) rule. <code>/spec/tls/0/hosts/0</code> is the first hostname in the first TLS block. If you later add a second hostname per environment, you'd add another operation targeting index <code>1</code>.</p>
<p>Why a JSON 6902 patch rather than a strategic merge patch? Because strategic merge on an array of objects is ambiguous. Kubernetes has to guess whether you mean "replace this list" or "merge into an existing entry," and the answer depends on per-field merge keys. For replacing a single value at a known position, an explicit <code>op: replace</code> says exactly what you mean and fails loudly if the path doesn't exist.</p>
<p>The workflow generates this file at runtime, so you never commit a per-PR overlay. We'll write that step in Step 19.</p>
<h2 id="heading-step-18-add-guardrails-with-resourcequota-and-limitrange">Step 18: Add Guardrails with ResourceQuota and LimitRange</h2>
<p>Before we wire up the workflow, let's stop a preview environment from being able to take down the cluster.</p>
<p>Right now nothing prevents a pull request from deploying a manifest requesting 32 CPUs, or scaling to 200 replicas, or creating a dozen <code>LoadBalancer</code> Services. Someone doesn't need bad intent for this to happen. A bad merge, a copied manifest, or a typo in a replica count does it just as effectively.</p>
<p>Two Kubernetes resources solve this, and they work together.</p>
<p>Create <code>k8s/cluster/namespace-guardrails.yaml</code> as a template:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: ResourceQuota
metadata:
name: preview-quota
spec:
hard:
requests.cpu: "1"
requests.memory: 1Gi
limits.cpu: "2"
limits.memory: 2Gi
pods: "10"
services: "5"
services.loadbalancers: "0"
persistentvolumeclaims: "2"
---
apiVersion: v1
kind: LimitRange
metadata:
name: preview-limits
spec:
limits:
- type: Container
# Applied to containers that don't specify their own
defaultRequest:
cpu: 50m
memory: 64Mi
default:
cpu: 200m
memory: 128Mi
# Hard ceiling — a container asking for more is rejected
max:
cpu: "1"
memory: 512Mi
</code></pre>
<h3 id="heading-what-each-one-does">What Each One Does</h3>
<p>A <strong>ResourceQuota</strong> caps the total consumption of an entire namespace. Add up every pod's requests and limits. The sum can't exceed these numbers. A request that would push the namespace over its quota is rejected by the API server at admission time, before anything is scheduled.</p>
<p>The <code>services.loadbalancers: "0"</code> line is worth highlighting. It makes it structurally impossible for a preview environment to provision a Google Cloud load balancer, even if someone commits a manifest with <code>type: LoadBalancer</code>. That's a direct financial guardrail: the most expensive mistake this system could make is now unrepresentable.</p>
<p>A <strong>LimitRange</strong> operates on individual containers rather than the namespace total. It sets defaults for containers that don't specify resources, and a hard maximum that no single container may exceed.</p>
<h3 id="heading-why-they-must-be-deployed-together">Why They Must Be Deployed Together</h3>
<p>Once a ResourceQuota that constrains <code>requests.cpu</code> exists in a namespace, <strong>every container in that namespace must declare resource requests</strong>. A pod without them is rejected outright, because Kubernetes can't verify it fits within the quota if it doesn't know what it's asking for.</p>
<p>That means adding a ResourceQuota on its own will break any manifest that omits resource specs, and most manifests found in the wild do omit them.</p>
<p>The LimitRange fixes this by supplying defaults. A container with no resource spec gets the <code>defaultRequest</code> and <code>default</code> values injected automatically at admission time, which satisfies the quota's requirement.</p>
<p>Deploy one without the other, and you get confusing failures. Deploy both and the system is forgiving of incomplete manifests while still being strictly bounded.</p>
<h3 id="heading-sizing-the-numbers">Sizing the Numbers</h3>
<p>The values above suit a small application like ours. To adjust them, work backwards from your node capacity.</p>
<p>Two <code>e2-standard-2</code> nodes give you 4 vCPU and 16GB total, minus roughly 15% that GKE reserves for system components. Call it 3.4 usable vCPU. With <code>requests.cpu: "1"</code> per namespace, three preview environments could theoretically fill the cluster.</p>
<p>Our application requests only 50m CPU, so in practice you'll fit far more than three. But the quota is a ceiling on what a namespace <em>may</em> request, and you should set it well below "one PR can consume everything."</p>
<p>If you expect ten concurrent environments, size the per-namespace quota at roughly a tenth of usable capacity, or add nodes.</p>
<h2 id="heading-step-19-update-the-deploy-workflow">Step 19: Update the Deploy Workflow</h2>
<p>Here's the complete <code>.github/workflows/preview-deploy.yml</code>, with everything added in this section marked <code># NEW</code>. Replace your Step 9 version with this one.</p>
<pre><code class="language-yaml">name: Deploy PR Preview
on:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
id-token: write
pull-requests: write
env:
PROJECT_ID: YOUR_PROJECT_ID
REGION: europe-west2
CLUSTER_NAME: pr-preview-cluster
CLUSTER_ZONE: europe-west2-a
REPOSITORY: pr-preview
IMAGE_NAME: preview-app
# NEW: the base domain for all preview environments
PREVIEW_DOMAIN: preview.yourdomain.com
# NEW: switch to letsencrypt-prod once the flow is proven
CLUSTER_ISSUER: letsencrypt-staging
jobs:
deploy:
name: Build and deploy preview
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set environment variables
run: |
PR_NUMBER=${{ github.event.pull_request.number }}
SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7)
echo "PR_NUMBER=${PR_NUMBER}" >> $GITHUB_ENV
echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV
echo "NAMESPACE=pr-${PR_NUMBER}" >> $GITHUB_ENV
echo "IMAGE_TAG=pr-${PR_NUMBER}-${SHORT_SHA}" >> $GITHUB_ENV
echo "IMAGE_BASE=${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_ENV
# NEW: the hostname this environment will be reachable at
echo "PREVIEW_HOST=pr-${PR_NUMBER}.${{ env.PREVIEW_DOMAIN }}" >> $GITHUB_ENV
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v2
- name: Configure Docker for Artifact Registry
run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet
- name: Build and push image
run: |
docker build \
--build-arg PR_NUMBER="${PR_NUMBER}" \
--build-arg GIT_SHA="${SHORT_SHA}" \
-t "${IMAGE_BASE}:${IMAGE_TAG}" \
./app
docker push "${IMAGE_BASE}:${IMAGE_TAG}"
- name: Get GKE credentials
uses: google-github-actions/get-gke-credentials@v2
with:
cluster_name: ${{ env.CLUSTER_NAME }}
location: ${{ env.CLUSTER_ZONE }}
- name: Create namespace
run: |
kubectl create namespace "${NAMESPACE}" \
--dry-run=client -o yaml | kubectl apply -f -
# NEW: label it so the reaper knows this namespace is ours
kubectl label namespace "${NAMESPACE}" \
app.kubernetes.io/managed-by=pr-preview \
preview.pr="${PR_NUMBER}" \
--overwrite
# NEW: apply the quota and limits before anything deploys
- name: Apply namespace guardrails
run: |
kubectl apply -f k8s/cluster/namespace-guardrails.yaml \
--namespace="${NAMESPACE}"
- name: Generate Kustomize overlay
run: |
mkdir -p k8s/overlays/"${NAMESPACE}"
cat > k8s/overlays/"${NAMESPACE}"/kustomization.yaml <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ${NAMESPACE}
resources:
- ../../base
images:
- name: preview-app
newName: ${IMAGE_BASE}
newTag: ${IMAGE_TAG}
patches:
- target:
kind: Ingress
name: preview-app
patch: |-
- op: replace
path: /spec/rules/0/host
value: ${PREVIEW_HOST}
- op: replace
path: /spec/tls/0/hosts/0
value: ${PREVIEW_HOST}
- op: replace
path: /metadata/annotations/cert-manager.io~1cluster-issuer
value: ${CLUSTER_ISSUER}
EOF
echo "--- Rendered manifest ---"
kubectl kustomize k8s/overlays/"${NAMESPACE}"
- name: Deploy
run: kubectl apply -k k8s/overlays/"${NAMESPACE}"
- name: Wait for rollout
run: |
kubectl rollout status deployment/preview-app \
--namespace="${NAMESPACE}" \
--timeout=180s
# NEW: wait for the TLS certificate to be issued
- name: Wait for certificate
continue-on-error: true
run: |
echo "Waiting for TLS certificate for ${PREVIEW_HOST}..."
kubectl wait certificate/preview-app-tls \
--namespace="${NAMESPACE}" \
--for=condition=Ready \
--timeout=180s
- name: Show deployment details
run: |
echo "Preview URL: https://${PREVIEW_HOST}"
kubectl get pods,svc,ingress,certificate --namespace="${NAMESPACE}"
</code></pre>
<p>There are a few things to understand about the new steps.</p>
<p><strong>The</strong> <code>~1</code> <strong>in the annotation patch path:</strong> this looks like a typo and isn't:</p>
<pre><code class="language-plaintext">path: /metadata/annotations/cert-manager.io~1cluster-issuer
</code></pre>
<p>JSON Pointer paths use <code>/</code> as a separator, so a literal <code>/</code> inside a key has to be escaped. The escape sequence is <code>~1</code>. (And <code>~</code> itself escapes to <code>~0</code>.) Since the annotation key is<code>cert-manager.io/cluster-issuer</code>, the pointer becomes <code>cert-manager.io~1cluster-issuer</code>. Get this wrong, and the patch silently targets a path that doesn't exist.</p>
<p><strong>Namespace labels:</strong> The <code>app.kubernetes.io/managed-by=pr-preview</code> label is what makes the reaper in Step 21 safe. A cleanup job that deletes namespaces matching a name pattern is one bad regex away from deleting something important. A job that only ever touches namespaces carrying a label it applied itself has a much smaller worst case.</p>
<p><strong>Guardrails before deploy:</strong> The ResourceQuota must exist before the application deploys, not after. Applying it afterwards means the first deployment escapes it entirely, and you'd only find out when something exceeded a limit that was never enforced.</p>
<p><code>continue-on-error: true</code> <strong>on the certificate wait:</strong> This is deliberate. Certificate issuance depends on Let's Encrypt, an external service with rate limits and occasional slowness. If issuance is delayed, the environment is still deployed and still works over HTTP.</p>
<p>So failing the entire workflow would be wrong. We note the problem and continue. The PR comment in the next step reflects whether the certificate is actually ready.</p>
<h2 id="heading-step-20-comment-the-url-on-the-pull-request">Step 20: Comment the URL on the Pull Request</h2>
<p>The environment is live. Now it's time to tell people about it.</p>
<p>Add this step to the end of the deploy job:</p>
<pre><code class="language-yaml"> - name: Comment preview URL on PR
uses: actions/github-script@v7
with:
script: |
const host = process.env.PREVIEW_HOST;
const sha = process.env.SHORT_SHA;
const ns = process.env.NAMESPACE;
// A hidden marker so we can find our own comment later
const marker = '<!-- pr-preview-environment -->';
const body = [
marker,
'### Preview environment is ready',
'',
`**https://${host}**`,
'',
`Commit \`${sha}\` · Namespace \`${ns}\``,
'',
'<sub>This environment updates on every push and is deleted when the PR closes.</sub>',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
</code></pre>
<p>So why this approach?</p>
<p><code>actions/github-script</code> is maintained by GitHub itself and gives you an authenticated Octokit client with no setup. There are third-party actions that post PR comments in fewer lines, but every third-party action in a workflow with write permissions is a supply chain dependency.</p>
<p>Given the effort we spent in Step 5 removing a long-lived credential, adding an unaudited action that can write to your repository would be an odd trade.</p>
<p><strong>The hidden marker comment</strong> is the mechanism that makes this a <em>sticky</em> comment. <code><!-- pr-preview-environment --></code> renders as nothing in GitHub's markdown, but it's present in the comment body. On each run, we list the PR's comments, look for one containing the marker, and update it if found.</p>
<p>Without this, a PR with fifteen commits gets fifteen near-identical bot comments, and the thread becomes unreadable. Reviewers start ignoring bot comments entirely, which defeats the purpose. One comment that stays current is the difference between a useful tool and noise.</p>
<p><code>process.env.PREVIEW_HOST</code>: values written to <code>$GITHUB_ENV</code> in earlier steps are available as environment variables in the script. This is cleaner than interpolating <code>${{ }}</code> expressions directly into JavaScript, which can break in surprising ways if a value contains quotes or backticks.</p>
<p><code>context.issue.number</code> works on <code>pull_request</code> events because GitHub treats pull requests as a specialised kind of issue. It's why we use the <code>issues</code> API to comment on a PR.</p>
<p>The step needs <code>pull-requests: write</code>, which the workflow already declares.</p>
<h2 id="heading-step-21-build-the-reaper">Step 21: Build the Reaper</h2>
<p>Here's the uncomfortable truth about the teardown workflow from Part 1: it will eventually fail to run.</p>
<p>The failure modes are mundane. Someone closes a PR while GitHub Actions is having an incident. A workflow file gets edited on a branch in a way that breaks the teardown job. Someone deletes a branch without closing its PR. A repository is archived with PRs still open. GitHub's event delivery hiccups.</p>
<p>None of these are common. All of them leave a namespace running indefinitely, consuming cluster capacity and (if it happens enough) real money. Cleanup that works 95% of the time is still a leak.</p>
<p>The fix is a <strong>reaper</strong>: a scheduled job that periodically compares what's running against what should be running, and deletes the difference.</p>
<p>Create <code>.github/workflows/preview-reaper.yml</code>:</p>
<pre><code class="language-yaml">name: Reap Orphaned Preview Environments
on:
schedule:
# 03:00 UTC daily
- cron: '0 3 * * *'
# Allow manual runs from the Actions tab
workflow_dispatch:
permissions:
contents: read
id-token: write
pull-requests: read
env:
PROJECT_ID: YOUR_PROJECT_ID
CLUSTER_NAME: pr-preview-cluster
CLUSTER_ZONE: europe-west2-a
# Delete environments older than this even if the PR is still open
MAX_AGE_DAYS: 7
jobs:
reap:
name: Delete orphaned namespaces
runs-on: ubuntu-latest
steps:
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
- name: Get GKE credentials
uses: google-github-actions/get-gke-credentials@v2
with:
cluster_name: ${{ env.CLUSTER_NAME }}
location: ${{ env.CLUSTER_ZONE }}
- name: Reap
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
echo "Fetching open pull requests..."
OPEN_PRS=$(gh pr list \
--repo "${{ github.repository }}" \
--state open \
--limit 500 \
--json number \
--jq '.[].number')
echo "Open PRs: ${OPEN_PRS:-none}"
echo ""
# Only consider namespaces we created ourselves.
NAMESPACES=$(kubectl get namespaces \
-l app.kubernetes.io/managed-by=pr-preview \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.labels.preview\.pr}{"\t"}{.metadata.creationTimestamp}{"\n"}{end}')
if [ -z "${NAMESPACES}" ]; then
echo "No preview namespaces found. Nothing to do."
exit 0
fi
NOW=$(date -u +%s)
MAX_AGE_SECONDS=$(( MAX_AGE_DAYS * 86400 ))
DELETED=0
while IFS=$'\t' read -r NS PR_NUM CREATED; do
[ -z "${NS}" ] && continue
CREATED_EPOCH=$(date -u -d "${CREATED}" +%s)
AGE=$(( NOW - CREATED_EPOCH ))
AGE_DAYS=$(( AGE / 86400 ))
REASON=""
# Rule 1: the PR is no longer open
if ! echo "${OPEN_PRS}" | grep -qx "${PR_NUM}"; then
REASON="PR #${PR_NUM} is not open"
# Rule 2: the environment has outlived MAX_AGE_DAYS
elif [ "${AGE}" -gt "${MAX_AGE_SECONDS}" ]; then
REASON="environment is ${AGE_DAYS} days old (max ${MAX_AGE_DAYS})"
fi
if [ -n "${REASON}" ]; then
echo "DELETING ${NS} — ${REASON}"
kubectl delete namespace "${NS}" \
--ignore-not-found=true \
--wait=false
DELETED=$(( DELETED + 1 ))
else
echo "KEEPING ${NS} — PR #${PR_NUM} open, ${AGE_DAYS}d old"
fi
done <<< "${NAMESPACES}"
echo ""
echo "Reaped ${DELETED} namespace(s)."
</code></pre>
<h3 id="heading-where-does-githubtoken-come-from">Where does <code>GITHUB_TOKEN</code> come from?</h3>
<p>Unlike <code>WIF_PROVIDER</code> and <code>WIF_SERVICE_ACCOUNT</code>, which you created in Step 6, <code>GITHUB_TOKEN</code> <strong>isn't something you set up</strong>. You won't find it on the secrets page, and you shouldn't add it there.</p>
<p>GitHub mints a fresh installation token at the start of every workflow run, exposes it as <code>secrets.GITHUB_TOKEN</code>, and revokes it the moment the job finishes. It's scoped to the repository running the workflow and nothing else.</p>
<h3 id="heading-understanding-the-reaper">Understanding the Reaper</h3>
<p>The reaper runs from GitHub Actions, not inside the cluster. A CronJob in Kubernetes would need a GitHub token stored as a cluster Secret to query open PRs. That's a long-lived credential sitting in the cluster, which is exactly what we avoided in Step 5.</p>
<p>A scheduled workflow already has <code>GITHUB_TOKEN</code> (scoped, automatic, short-lived) and already authenticates to GCP through Workload Identity Federation. No new secrets.</p>
<p>The label selector is the safety mechanism. The query is <code>-l app.kubernetes.io/managed-by=pr-preview</code>, not a name pattern like <code>pr-*</code>. This is a deliberate choice: a name-pattern match would happily delete a production namespace someone named <code>pr-routing-service</code>. The reaper can only ever see namespaces that the deploy workflow explicitly labelled, which means the set of things it could possibly delete is the set of things it created.</p>
<p>Be careful when you adapt this. Any cleanup automation should operate on a positive allowlist it controls, never a pattern that could match something it didn't create.</p>
<p>We also have two deletion rules, for two different problems. Rule 1 catches the actual orphan. A namespace whose PR is closed but whose teardown never ran. Rule 2 catches the stale environment: a PR that's been open for three weeks, hasn't been touched in two, and is quietly holding cluster capacity. Seven days is a reasonable default. If someone needs their environment back, they just push a commit, and it redeploys in a couple of minutes.</p>
<p><code>set -euo pipefail</code>: <code>-e</code> exits on any command failure, <code>-u</code> errors on undefined variables, and <code>-o pipefail</code> makes a pipeline fail if <em>any</em> stage fails rather than only the last. In a script whose job is deleting things, silent failure is the worst outcome. If something goes wrong, stop immediately.</p>
<p><code>grep -qx</code>: the <code>-x</code> flag requires the whole line to match. Without it, PR <code>4</code> would match against open PR <code>42</code> and the reaper would spare an orphaned namespace forever. It's a small flag, but a real bug.</p>
<p><code>workflow_dispatch</code> lets you run the reaper manually from the Actions tab. Do that the first few times and read the output before trusting the schedule. The log prints <code>KEEPING</code> or <code>DELETING</code> with a reason for every namespace, so you can verify the logic matches your expectations before it runs unattended.</p>
<h2 id="heading-step-22-test-the-whole-thing">Step 22: Test the Whole Thing</h2>
<p>Commit everything and push:</p>
<pre><code class="language-bash">git add .
git commit -m "Add Ingress, TLS, PR comments, guardrails, and reaper"
git push
</code></pre>
<p>Create a branch, make a visible change, and open a pull request:</p>
<pre><code class="language-bash">git checkout -b test-preview-urls
# edit app/main.go — change the greeting text
git add .
git commit -m "Test preview URLs"
git push -u origin test-preview-urls
</code></pre>
<p>Open the PR and watch the Actions tab.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/5c13299f-c72c-4073-8ab0-2f552749cee5.png" alt="The deploy workflow running with the new certificate and comment step" style="display: block;" width="600" height="400" loading="lazy">
<p>When it finishes, the bot comment should appear with your URL. Open it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/652f2033-7672-4f32-9a53-e2837c97bab2.png" alt="The bot comment showing the new URL" style="display: block;" width="600" height="400" loading="lazy">
<p>Because you're still on the staging issuer, your browser will show a certificate warning. Something like "Your connection is not private," with an issuer of "Let's Encrypt Staging."</p>
<p><strong>This is the success condition.</strong> It means DNS resolved, the load balancer routed, the Ingress matched the hostname, the HTTP-01 challenge completed, and a certificate was issued. Everything worked. The certificate simply comes from an untrusted test authority.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/4e0e252f-66e4-47fa-a0cf-31a0c491a73a.png" alt="Browser security warning for the preview environment URL, showing the certificate was issued by Let's Encrypt Staging rather than a trusted authority" style="display: block;" width="600" height="400" loading="lazy">
<p>Click Advanced, pass through the warning, and confirm the application responds with the right PR number and namespace.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/094f0273-1a55-4d84-ac42-b3dca2b8ce58.png" alt="The preview environment loading in the browser, showing the application's output with the correct PR number, commit SHA, and namespace" style="display: block;" width="600" height="400" loading="lazy">
<p>Check the certificate from the command line:</p>
<pre><code class="language-bash">kubectl get certificate --namespace=pr-2
#Ensure you use your own pr-number
</code></pre>
<p>You want <code>READY: True</code>. If it's <code>False</code>, inspect what's happening:</p>
<pre><code class="language-bash">kubectl describe certificate preview-app-tls --namespace=pr-2
kubectl get challenges --all-namespaces
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/d27a623d-f1fc-49bd-a1eb-69d277a17631.png" alt="Checking the certificate from the command line" style="display: block;" width="600" height="400" loading="lazy">
<p>A pending challenge that never resolves almost always means DNS or port 80 reachability. Confirm with:</p>
<pre><code class="language-bash">curl -I http://pr-2.preview.yourdomain.com/.well-known/acme-challenge/test
</code></pre>
<p>You want any HTTP response from NGINX. A 404 is fine, as it proves that the routing works. A connection timeout means the request isn't reaching your cluster.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/5b539f11-bc2b-46e8-853d-7f8b9d783b8e.png" alt="Confirming DNS reachability" style="display: block;" width="600" height="400" loading="lazy">
<h3 id="heading-switch-to-production-certificates">Switch to Production Certificates</h3>
<p>Once the staging flow works end to end, change one line in the workflow's <code>env:</code> block:</p>
<pre><code class="language-yaml"> CLUSTER_ISSUER: letsencrypt-prod
</code></pre>
<p>Make this change <strong>on your existing PR branch</strong> and push it. You don't need to close the pull request or merge to <code>main</code> first. You can also change the message in your main.go file to confirm everything works.</p>
<p>Push a new commit to the PR. The environment redeploys, cert-manager requests a real certificate, and this time the browser shows a padlock.</p>
<p>Open the URL in a <strong>private browsing window</strong> to check. Your browser cached an exception for the staging certificate against that hostname, and a normal tab may show a trusted-looking page that's really just a remembered override and still shows <code>not secure</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/54b5979c-f3d9-4dca-a63d-970e4e66e219.png" alt="The preview environment loading over HTTPS with a valid certificate" style="display: block;" width="600" height="400" loading="lazy">
<h3 id="heading-verify-the-guardrails">Verify the Guardrails</h3>
<p>Confirm the quota is active:</p>
<pre><code class="language-bash">kubectl describe resourcequota preview-quota --namespace=pr-2
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/9d50f2e6-9802-424e-8be7-7955ba8dcd92.png" alt="Output of kubectl describe resourcequota showing the preview-quota in namespace pr-1 with used and hard limits listed for CPU, memory, pods, services, and load balancers" style="display: block;" width="600" height="400" loading="lazy">
<p>You'll see used versus hard limits for each resource. Then try to break it:</p>
<pre><code class="language-bash">kubectl scale deployment/preview-app --replicas=50 --namespace=pr-2
kubectl get events --namespace=pr-2 | grep -i quota
</code></pre>
<p>The deployment will scale partway and then stop, with events explaining that it exceeded the quota. That's the guardrail doing its job: the namespace is bounded, and the rest of the cluster is unaffected.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/13872121-d7a9-49e5-b85f-34a629afc77a.png" alt="Terminal output showing the deployment scaled only partway to 50 replicas, with Kubernetes events reporting that the request exceeded the namespace quota" style="display: block;" width="600" height="400" loading="lazy">
<h3 id="heading-verify-teardown-and-the-reaper">Verify Teardown and the Reaper</h3>
<p>Close the pull request. The teardown workflow fires and the namespace goes to <code>Terminating</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/336470b2-df0d-4f14-bab3-6f17f38ad4f3.png" alt="Github Actions teardown workflow job completed" style="display: block;" width="600" height="400" loading="lazy">
<p>Then test the reaper on a namespace it <em>shouldn't</em> touch. Create one manually without the label:</p>
<pre><code class="language-bash">kubectl create namespace pr-9999
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/ac2c98c2-f8fc-4cbb-b5e9-8cae679fd4cf.png" alt="Terminal showing kubectl create namespace pr-9999, creating a namespace without the pr-preview management label" style="display: block;" width="600" height="400" loading="lazy">
<p>Run the reaper manually from the Actions tab.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/4941adde-9be6-4870-8742-47920ffa8f97.png" alt="GitHub Actions interface showing the Reap Orphaned Preview Environments workflow with the Run workflow button available through workflow_dispatch" style="display: block;" width="600" height="400" loading="lazy">
<p>It should ignore <code>pr-9999</code> entirely, because that namespace carries no <code>app.kubernetes.io/managed-by=pr-preview</code> label. The log won't even mention it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/a16298d1-c63a-45f3-b117-a35ba79fc9e9.png" alt="GitHub Actions log for the reaper showing it found no preview namespaces to act on, having ignored the unlabelled pr-9999 namespace entirely" style="display: block;" width="600" height="400" loading="lazy">
<p>Clean it up yourself:</p>
<pre><code class="language-bash">kubectl delete namespace pr-9999
</code></pre>
<p>That test matters more than it looks. You've just verified that your automated deletion job can't touch things it didn't create.</p>
<h2 id="heading-what-this-costs">What This Costs</h2>
<p>We should be concrete about this, since cost control is half the argument for this pattern.</p>
<p>Running continuously in <code>europe-west2</code>, here are the approximate monthly figures:</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Approximate cost</th>
</tr>
</thead>
<tbody><tr>
<td>GKE control plane</td>
<td>~$74/month (one zonal cluster, free tier covers one cluster on some accounts)</td>
</tr>
<tr>
<td>2 × <code>e2-standard-2</code> nodes</td>
<td>~$97/month</td>
</tr>
<tr>
<td>Regional load balancer</td>
<td>~$18/month + data processing</td>
</tr>
<tr>
<td>Static IP (in use)</td>
<td>Free while attached</td>
</tr>
<tr>
<td>Artifact Registry storage</td>
<td>~$0.10/GB/month</td>
</tr>
<tr>
<td>Let's Encrypt certificates</td>
<td>Free</td>
</tr>
<tr>
<td>GitHub Actions (public repo)</td>
<td>Free</td>
</tr>
</tbody></table>
<p>Call it roughly $190/month for the always-on infrastructure, regardless of how many preview environments you run.</p>
<p>The number that matters is what <em>doesn't</em> scale with PR count. One load balancer serves every environment. One DNS record covers every hostname. Certificates are free. The marginal cost of the eleventh preview environment is only the node capacity its pods consume, a few pence a day for an application this size.</p>
<p>Compare that with the naïve approach of a <code>LoadBalancer</code> Service per environment: ten open PRs would add roughly $180/month in load balancers alone, and that's before anyone forgets to clean one up. The <code>services.loadbalancers: "0"</code> line in the ResourceQuota exists specifically to make that mistake impossible.</p>
<p>There are two ways to cut the standing cost for a personal project. Use <strong>spot nodes</strong>, add <code>spot = true</code> to the <code>node_config</code> block in Terraform for roughly a 60–70% discount on node hours. Preview environments are the ideal spot workload: if a node is reclaimed, pods reschedule, and the worst case is a preview environment briefly restarting. Or scale the node pool to zero overnight with a scheduled workflow, since nobody reviews PRs at 3 am.</p>
<h2 id="heading-cleanup">Cleanup</h2>
<p>When you're finished:</p>
<pre><code class="language-bash"># Delete every preview namespace
kubectl delete namespaces -l app.kubernetes.io/managed-by=pr-preview
# Remove the shared infrastructure
helm uninstall cert-manager --namespace cert-manager
helm uninstall ingress-nginx --namespace ingress-nginx
kubectl delete namespace cert-manager ingress-nginx
</code></pre>
<p>Uninstall the Helm releases <em>before</em> running <code>terraform destroy</code>. The Ingress controller's Service owns a Google Cloud load balancer that Terraform doesn't know about, because Kubernetes created it rather than Terraform. Destroying the cluster first can orphan that load balancer, leaving it billing quietly in a project with nothing else in it.</p>
<h3 id="heading-if-you-plan-to-rebuild-this-later">If You Plan to Rebuild This Later</h3>
<p><strong>Don't run a full</strong> <code>terraform destroy</code><strong>.</strong> Tear down only the expensive parts:</p>
<pre><code class="language-bash">cd terraform
terraform destroy
-var="project_id=YOUR_PROJECT_ID"
-var="github_repo=YOUR_USERNAME/pr-preview-envs"
</code></pre>
<p>Here's why. When you delete a Workload Identity Pool, Google doesn't remove it immediately. It marks it <code>DELETED</code> and reserves the ID for 30 days. Terraform forgets about it, but Google hasn't. Recreating the stack within that window fails with:</p>
<pre><code class="language-plaintext">Error: Error creating WorkloadIdentityPool: googleapi: Error 409: Requested entity already exists
</code></pre>
<p>Almost all of your cost is the cluster and its nodes. The identity pool, provider, service account, IAM bindings, static IP, and Artifact Registry repository cost nothing or pennies to leave in place. Rebuilding later is then just <code>terraform apply</code>, and your <code>WIF_PROVIDER</code> and <code>WIF_SERVICE_ACCOUNT</code> secrets in GitHub stay valid. No need to update them.</p>
<p>If you want to keep the cluster but stop paying for idle nodes, scale the node pool to zero instead:</p>
<pre><code class="language-bash">gcloud container clusters resize pr-preview-cluster
--node-pool pr-preview-cluster-pool
--num-nodes 0
--zone europe-west2-a
</code></pre>
<p>You still pay for the GKE control plane, so this reduces cost rather than eliminating it.</p>
<p>If you're done for good:</p>
<pre><code class="language-bash">cd terraform
terraform destroy
-var="project_id=YOUR_PROJECT_ID"
-var="github_repo=YOUR_USERNAME/pr-preview-envs"
</code></pre>
<p>Then confirm nothing was left behind:</p>
<pre><code class="language-bash">gcloud compute forwarding-rules list
gcloud compute addresses list
</code></pre>
<p>Both should be empty, or contain only resources belonging to other projects of yours. Don't forget to remove the wildcard DNS record too.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've built something that most engineering teams either don't have or pay a vendor for.</p>
<p>You wrote a Go application that reports its own build metadata, injected at compile time through linker flags, so a running environment can always tell you exactly which commit it came from. You wrapped it in a multi-stage Dockerfile that produces a small, statically linked, non-root image</p>
<p>You provisioned a GKE cluster and an Artifact Registry repository with Terraform, which means the whole environment is reproducible and destroyable with a single command. You set up Workload Identity Federation so GitHub Actions authenticates to Google Cloud with short-lived OIDC tokens instead of a long-lived service account key, scoped to exactly one repository by an attribute condition.</p>
<p>You wrote base Kubernetes manifests once and used Kustomize overlays, generated at runtime, to deploy the same application into a different namespace with a different image tag and a different hostname for every pull request. You put a single shared Ingress controller in front of every environment and gave it a stable static IP, so one load balancer serves any number of previews. You pointed one wildcard DNS record at it, which means a new environment needs no DNS work at all: routing happens on the <code>Host</code> header inside the cluster. You added cert-manager so every environment gets a valid, auto-renewing TLS certificate, and made the pipeline announce itself with a sticky PR comment that updates in place.</p>
<p>You bounded each namespace with a ResourceQuota and LimitRange, including one line that makes the most expensive possible mistake structurally impossible. And you built a reaper that catches what the happy path misses, operating only on namespaces it can prove it created.</p>
<p>The result is that an isolated, production-shaped, publicly reachable environment is the <strong>default</strong> for every change, not something a developer has to request, claim in a Slack channel, or remember to clean up.</p>
<h3 id="heading-where-to-go-from-here">Where to Go From Here</h3>
<p>There are a few directions you can explore. Here they are, roughly in order of how much they'd improve a real team's experience:</p>
<ol>
<li><p><strong>Seed data for each environment.</strong> The biggest limitation of what we built is that the application is stateless. Real applications need a database. The common patterns are a per-namespace ephemeral database seeded from a sanitised snapshot, or a shared database with per-environment schema isolation. This is the single hardest problem in preview environments and where most of the remaining engineering effort goes.</p>
</li>
<li><p><strong>Run your test suite against the preview.</strong> Once the environment has a real URL, end-to-end tests can run against it in CI, a far stronger signal than tests against mocks.</p>
</li>
<li><p><strong>Wildcard certificates via DNS-01.</strong> As covered in Step 15, this removes the Let's Encrypt rate limit ceiling once your PR volume approaches it.</p>
</li>
<li><p><strong>Move to GitOps.</strong> Rather than the workflow running <code>kubectl apply</code> directly, have it commit the rendered manifests to a repository that Argo CD or Flux reconciles. The cluster's state then has a git history, and your CI pipeline no longer needs cluster write access at all.</p>
</li>
<li><p><strong>Preview environment metrics.</strong> Hook the namespaces into the cost visibility tooling you'd use elsewhere, and you can answer "what do preview environments actually cost us per month" with a number rather than a shrug.</p>
</li>
</ol>
<h2 id="heading-project-file-structure">Project File Structure</h2>
<pre><code class="language-plaintext">pr-preview-envs/
├── app/
│ ├── main.go — Go HTTP server reporting build metadata
│ ├── go.mod — Go module definition
│ └── Dockerfile — Multi-stage build with ldflags injection
├── terraform/
│ ├── main.tf — GKE, Artifact Registry, WIF, static IP
│ ├── variables.tf — Input variables
│ └── outputs.tf — Cluster, WIF, and ingress IP values
├── k8s/
│ ├── base/
│ │ ├── deployment.yaml — Deployment with probes and Downward API
│ │ ├── service.yaml — ClusterIP Service
│ │ ├── ingress.yaml — Ingress with cert-manager annotation
│ │ └── kustomization.yaml — Base Kustomize config
│ └── cluster/
│ ├── letsencrypt-staging.yaml — Staging ClusterIssuer
│ ├── letsencrypt-prod.yaml — Production ClusterIssuer
│ └── namespace-guardrails.yaml — ResourceQuota and LimitRange
└── .github/
└── workflows/
├── preview-deploy.yml — Build, deploy, certificate, PR comment
├── preview-teardown.yml — Delete namespace on PR close
└── preview-reaper.yml — Scheduled orphan cleanup
</code></pre>
<p>All source files for this tutorial are available in the companion GitHub repository: <a href="https://github.com/Amiynarh/pr-preview-envs"><code>https://github.com/Amiynarh/pr-preview-envs</code></a>.</p>