Building a Production-Style WordPress Platform on GKE with Terraform (A Step-by-Step Walkthrough)
If you’ve ever wanted to go beyond “spin up a VM and install WordPress” and actually learn how real infrastructure teams ship a Kubernetes-based platform — this post is for you.
In this tutorial, I’ll walk you through a project I built: a private GKE cluster running WordPress, backed by Cloud SQL, Secret Manager, and a fully keyless GitHub Actions CI/CD pipeline — all provisioned with Terraform, split into four independent, composable projects.
By the end, you’ll understand not just what to click or what to copy-paste, but why the architecture is shaped the way it is — because honestly, that “why” is what took me the longest to actually understand.
What We’re Building
Here’s the bird’s-eye view before we touch a single command:
bootstrap/ → creates the Terraform state bucket + a keyless CI identity (once, ever)
infra/ → VPC, private GKE cluster, private Cloud SQL, IAM, Secret Manager, Artifact Registry, backups
apps/external-secrets/ → installs the External Secrets Operator into the cluster
apps/wordpress/ → namespace, deployment, service, ingress, TLS cert, and the DB secret sync
Four separate Terraform projects. Four separate state files. Four separate GitHub Actions pipelines. If that sounds like overkill for “just running WordPress,” stick with me — the reasoning behind that split is honestly one of the most useful things I learned building this.
Why Not Just One Big main.tf?
My first instinct, like most people’s, was: “why not just put the VPC, the cluster, the database, and WordPress all in one Terraform project?” Here’s what changed my mind.
- Blast radius. If I only touch the WordPress Docker image tag, I don’t want Terraform re-evaluating my entire VPC and database in the same plan. One typo shouldn’t put my cluster at risk.
- Change frequency mismatch. My VPC and cluster change maybe once a month. My WordPress deployment might change several times a week. Bundling them means every small app change forces a “review the whole platform” ritual.
- The chicken-and-egg problem. Terraform needs a remote backend (a storage bucket) to keep state safely. But you can’t create that bucket using Terraform state that’s supposed to live in that very bucket. Something has to bootstrap the process first, with local state, before anything else can rely on a remote backend.
That last point is exactly why the very first project in this repo is called bootstrap/.
Step 1 — Bootstrap: Solving the Chicken-and-Egg Problem
bootstrap/ is small on purpose. It runs once, manually, and only creates two things:
- A versioned GCS bucket to hold every other project’s Terraform state.
- A GitHub Actions service account + Workload Identity Federation (WIF) setup, so my CI pipeline never needs a downloaded JSON key.
Here’s the part that surprised me the most: WIF means GitHub can prove its identity to Google Cloud using a short-lived, cryptographically signed token — issued fresh for every workflow run — instead of a secret sitting in GitHub Secrets forever. If that token leaks, it’s already expired by the time anyone could misuse it. No keys to rotate, no keys to leak.
cd bootstrap
terraform init
terraform plan -out=tfplan
terraform apply tfplan
terraform output
The outputs from this step — the WIF provider name, the service account email — go straight into your GitHub repo’s Settings → Secrets and variables → Actions. Every other pipeline in this project depends on them.
Step 2 — The Platform: infra/
With the state bucket and CI identity in place, infra/ builds the actual platform. This is where Terraform’s module system earns its keep — instead of one giant file, the work is split into seven purpose-built modules:
module "network" {
source = "./modules/network"
# creates the VPC, subnet, Cloud NAT, and private-services peering
}
module "gke" {
source = "./modules/gke"
vpc_id = module.network.vpc_id # ← output of one module, feeding into another
subnet_id = module.network.subnet_id
}
module "cloudsql" {
source = "./modules/cloudsql"
# depends on the private-services peering the network module set up
}
That pattern — one module’s output becomes the next module’s input — is the whole trick to reading Terraform code without getting lost. network hands its VPC ID to gke. gke and cloudsql both quietly need that same peering connection to work with private IPs. Follow the outputs, and the dependency graph explains itself.
A few design decisions worth calling out:
- The GKE cluster is private. Nodes don’t get public IPs. That also means they can’t casually reach the internet to pull a Docker image — which is why Cloud NAT exists in the network module: it gives private nodes outbound-only internet access without ever exposing them to inbound traffic.
- Cloud SQL is private-IP-only too. No public endpoint at all. It only exists inside the VPC, reachable from the cluster and nowhere else.
- The database password is never typed by a human. It’s generated with Terraform’s
random_passwordresource and immediately pushed into Secret Manager — the value never touches a config file, a Slack message, or a.tfvarsfile. - IAM is one service account per workload, not one shared account for everything. If the WordPress pod’s identity is ever compromised, the blast radius is “can read one secret,” not “can touch the whole project.”
cd infra
terraform init
terraform apply
Push to main and a GitHub Actions workflow does the same thing automatically — plan-only on pull requests, apply on merge.
Step 3 — Installing the External Secrets Operator
Before WordPress can read that database password from Secret Manager, something needs to bridge GCP Secret Manager and Kubernetes. That’s the External Secrets Operator (ESO).
Why not just paste the password into a Kubernetes Secret manifest? Two reasons: a Kubernetes Secret is only base64-encoded (trivial to reverse), and if that YAML ever gets committed to Git, the password lives in your Git history forever. ESO instead runs as a controller inside the cluster and fetches the real value live from Secret Manager, creating a proper Kubernetes Secret on the fly — no manual copy-paste, ever.
resource "helm_release" "external_secrets" {
name = "external-secrets"
repository = "https://charts.external-secrets.io"
chart = "external-secrets"
namespace = "external-secrets"
create_namespace = true
}
This lives in its own Terraform project (apps/external-secrets/), with its own state file, because it’s cluster-wide infrastructure — it changes rarely and isn’t tied to any one application.
cd apps/external-secrets
terraform init && terraform apply
This step must run before WordPress — its custom resources (SecretStore, ExternalSecret) don’t exist in the cluster until the operator installs its CRDs.
Step 4 — Deploying WordPress
This is the fourth and final Terraform project, apps/wordpress/, and it’s where two ideas from earlier come together beautifully: modules, and cross-project data sharing.
WordPress needs facts from infra/ — the database’s private IP, its name, its user, the dedicated service account email. But apps/wordpress is a completely separate Terraform project with its own state. So how does it get that information?
data "terraform_remote_state" "infra" {
backend = "gcs"
config = {
bucket = "gke-prod-demo-001-tf-state"
prefix = "gke/prod"
}
}
module "wordpress" {
source = "./modules"
db_host = data.terraform_remote_state.infra.outputs.cloudsql_private_ip
db_name = data.terraform_remote_state.infra.outputs.database_name
gcp_service_account = data.terraform_remote_state.infra.outputs.wordpress_gsa_email
domain = "yourdomain.com"
}
terraform_remote_state reads another project’s state file directly, as read-only data. It’s the glue that lets four independent projects behave like one coherent platform without ever sharing code or a state file.
Inside apps/wordpress/modules/, Terraform creates, in order: a namespace, a persistent volume claim, a service account (annotated for Workload Identity), the Deployment and Service, a reserved static IP, an Ingress, health-check configs, a Google-managed TLS certificate, and finally the SecretStore/ExternalSecret pair that pulls the DB password in from Secret Manager.
cd apps/wordpress
terraform init && terraform apply
Connecting Your Domain
Once applied, grab the static IP Terraform reserved:
gcloud compute addresses describe wordpress-ip --global --format="get(address)"
Point your domain’s DNS at it — an A record for @ and one for www. Then watch the managed certificate come online:
kubectl get managedcertificate wordpress-cert -n wordpress -w
Google can only issue the certificate once your DNS actually resolves to that IP, so this step can take anywhere from a few minutes to a few hours depending on propagation. Once it flips to Active, your site is live over HTTPS.
A Real Bug I Hit (and How I Debugged It)
I want to include this because it’s a genuinely useful debugging story, not just a “happy path” tutorial.
WordPress’s Kubernetes ServiceAccount is supposed to be annotated with a dedicated wordpress-gsa service account, so it can read the DB password from Secret Manager via Workload Identity. But my apps/wordpress/main.tf was actually passing in the GKE node service account’s email instead — a completely different identity that was never granted access to Secret Manager.
The fix ended up being a single missing output block. infra/modules/iam/outputs.tf already exposed wordpress_gsa_email at the module level — but infra/outputs.tf (the root of that project) never passed it further up. Once I added:
output "wordpress_gsa_email" {
value = module.iam.wordpress_gsa_email
}
…re-applied infra/, then updated apps/wordpress/main.tf to read that new output instead of the node SA’s, a kubectl describe sa wordpress-sa -n wordpress confirmed the fix immediately — the annotation now pointed at the right identity.
The lesson: when something “downstream” looks broken, check whether the real bug is a missing output somewhere upstream. The symptom and the root cause are often in two completely different files — sometimes even two completely different Terraform projects.
Verifying Everything Actually Works
A few commands I lean on constantly:
# Terraform state, per project
terraform state list
terraform output
# GCP resources
gcloud container clusters list
gcloud sql instances list
gcloud secrets list
# Kubernetes
kubectl get pods -A
kubectl get externalsecret,secretstore -n wordpress
kubectl describe managedcertificate wordpress-cert -n wordpress
If ExternalSecret shows SecretSynced and the ManagedCertificate shows Active, you’re done — WordPress should be live at your domain.
Final Thoughts
The biggest shift in how I think about infrastructure after building this: Terraform isn’t really about resources, it’s about the wiring between them — variables in, outputs out, one project’s state becoming another project’s input. Once that clicks, reading (and debugging) any Terraform codebase gets a lot less intimidating.
If you’re building something similar, start with the boring part first — get your bootstrap and backend right before you touch a single cluster resource. Everything downstream depends on that foundation being solid.
Want the full code?
Here it is 🙂 https://github.com/linuxshakil/gke-infra-terraform
Have questions about any part of this setup Drop a comment below — happy to point you to specific modules.
Thanks, Happy Learning !