Nobody Wants to Run etcd#
You can run Kubernetes yourself. Install kubeadm, set up etcd, configure the API server, manage certificate rotation, handle control plane upgrades, set up HA for the masters, and back everything up regularly. Or — and hear me out — you can let someone else do all of that and focus on actually deploying your applications.
That’s what managed Kubernetes is. Google handles the control plane, you handle the workloads. We use Google Kubernetes Engine (GKE), and this post covers the GKE-specific bits that sit between your Kubernetes manifests and the actual running cluster.
Standard vs Autopilot (The 60% Rule)#
GKE has two modes. Standard gives you control over the underlying nodes — you pick machine types, configure node pools, manage scaling. Autopilot takes all of that away and bills you per-pod instead of per-VM.
| Aspect | Standard | Autopilot |
|---|---|---|
| You manage nodes | Yes | No |
| Billing | Per VM (regardless of usage) | Per pod (CPU + memory requests) |
| SSH into nodes | Yes | No |
| Privileged containers | Yes | No |
| Control | Full | Limited |
The rule of thumb: if your cluster runs above 60-70% utilization, Standard is cheaper because you’re efficiently using the VMs you’re paying for. Below that, Autopilot saves money because you’re not paying for idle capacity.
We use Standard because we need the flexibility — different node pools for different workload types, and the ability to SSH when debugging gets desperate (it happens more often than you’d think).
The Google Cloud Load Balancer (Not What You’d Expect)#
If you’re coming from a homelab with Traefik
or nginx, GKE’s ingress system will surprise you. When you create an Ingress with the gce class, GKE doesn’t spin up an nginx pod inside your cluster. It provisions an actual Google Cloud HTTP(S) Load Balancer — a global, anycast L7 load balancer that lives outside your cluster entirely.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
kubernetes.io/ingress.class: "gce"
kubernetes.io/ingress.global-static-ip-name: "my-static-ip"
kubernetes.io/ingress.allow-http: "false" # HTTPS only
spec:
tls:
- secretName: tls-cert
rules:
- host: api.example.com
http:
paths:
- path: /*
pathType: ImplementationSpecific
backend:
service:
name: api-service
port:
number: 80
The static IP is reserved in GCP (not Kubernetes), and the load balancer is managed by Google’s infrastructure. This means SSL termination happens at Google’s edge, not inside your cluster. It also means the ingress takes a few minutes to provision (it’s creating real cloud infrastructure, not just starting a pod).
BackendConfig: The GKE Superpower#
Standard Kubernetes ingress doesn’t support things like CORS headers at the load balancer level, custom health checks, or CDN. GKE has a CRD called BackendConfig that plugs into the Google Cloud Load Balancer:
apiVersion: cloud.google.com/v1
kind: BackendConfig
metadata:
name: api-backend-config
spec:
# CORS at the load balancer — not in your app code
customResponseHeaders:
headers:
- "Access-Control-Allow-Origin: *"
- "Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS"
# Health checks the LB uses to determine if pods are healthy
healthCheck:
checkIntervalSec: 15
timeoutSec: 5
requestPath: /health
port: 8080
CORS at the load balancer level means your application code doesn’t need to handle it. The LB adds the headers before the response reaches the client. One less thing to maintain in every service.
The Sidecar That Talks to Your Database#
Our databases run on Cloud SQL (Google’s managed PostgreSQL/MySQL). Rather than giving the database a public IP and allowlisting cluster IPs, we use the Cloud SQL Proxy as a sidecar container:
containers:
- name: app
image: registry.example.com/my-service:v1.2.3
env:
- name: DATABASE_URL
value: "postgresql://user:pass@localhost:5432/mydb" # localhost!
- name: cloud-sql-proxy
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:latest
args:
- "--structured-logs"
- "--port=5432"
- "my-project:my-region:my-instance" # Cloud SQL connection name
securityContext:
runAsNonRoot: true # no root access
resources:
requests:
cpu: 50m # minimal — it's just proxying TCP
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
The app connects to localhost:5432 — the proxy running in the same pod handles authentication via the service account, encrypts the connection, and routes it to the Cloud SQL instance. No public IP on the database, no credentials in the connection string (beyond the standard user/pass), no manual TLS configuration.
Cloudflare in Front of Everything#
The full traffic flow looks like this:
User → Cloudflare (DNS + CDN + WAF)
→ Google Cloud Load Balancer (static IP)
→ GKE Service → Pods
Cloudflare handles DNS, DDoS protection, and caching. The GCE load balancer handles TLS termination and routing. We use a Cloudflare origin certificate as the TLS secret in Kubernetes — it’s trusted by Cloudflare’s edge servers but not by browsers directly, which is fine because traffic always goes through Cloudflare first.
The Registry#
Container images are stored in GCP Artifact Registry, which replaced the older Container Registry. It’s regional (we use asia-southeast1), integrates with IAM for access control, and has built-in vulnerability scanning. ArgoCD and the CI pipeline both pull from it. But speaking of ArgoCD — that’s how we actually deploy to this cluster, and it’s the next post
.
