Environments
Every app moves through the same ladder: preview environments per pull request, then dev → qa → prod. dev, qa, and preview all live on the shared nonprod cluster; prod is a separate cluster reached only by a manually-approved promotion.
At a glance
| Env | Trigger | Cluster / project | Namespace | Hostname | Gateway / TLS |
|---|---|---|---|---|---|
| dev | push to main | u2i-nonprod / c-u2i-nonprod | {app}-dev | dev.{app}.u2i.dev | eg (Envoy) + Let's Encrypt |
| qa | version tag v* | u2i-nonprod / c-u2i-nonprod | {app}-qa | qa.{app}.u2i.dev | eg (Envoy) + Let's Encrypt |
| preview | pull request | u2i-nonprod / c-u2i-nonprod | {app}-pr-{N} | pr-{N}.{app}.u2i.dev | eg (Envoy) + Let's Encrypt |
| prod | manual promotion (approval) | u2i-prod / c-u2i-prod | {app}-prod | {app}.u2i.dev | managed-lb + Certificate Manager |
dev
Continuous integration target — always reflects main.
- Fires on every push to main via the {app}-dev-trigger Cloud Build trigger.
- Builds the image (tag dev-{SHA}), packages the Helm chart, pushes it to the config-sync OCI registry, and updates the RepoSync — Config Sync then reconciles it into the {app}-dev namespace.
- Rolls out automatically; no approval gate.
- Served in-cluster via the Envoy Gateway (gatewayMode: eg) with a cert-manager Let's Encrypt certificate; external-dns publishes the A record automatically.
qa
Release candidate — what you cut a version tag for.
- Fires when a version tag (v*) is pushed, via the {app}-qa-deployment trigger.
- Creates a release in the {app}-qa-prod-pipeline; the qa target deploys automatically.
- Runs on the same nonprod cluster as dev, in the {app}-qa namespace, with prod-like resource limits for realistic testing.
- Same eg gateway + Let's Encrypt TLS as dev.
preview (per-PR)
Ephemeral environment for reviewing a pull request.
- Fires when a PR is opened, via the {app}-preview-deployment trigger.
- Spins up a dynamic {app}-pr-{N} namespace and applies the manifests directly (bypassing Config Sync) so the review URL comes up fast.
- Runs on spot nodes with smaller resource and cache limits to keep previews cheap.
- Reachable at pr-{N}.{app}.u2i.dev; the namespace is torn down automatically when the PR is closed.
prod
Production — promoted deliberately, never on a push.
- Reached by promoting a qa release through the {app}-qa-prod-pipeline; the prod target is gated behind a manual approval from the app's approver_group.
- Runs on the separate u2i-prod cluster / c-u2i-prod project, in the {app}-prod namespace.
- The prod cluster has no in-cluster Envoy gateway, so it uses gatewayMode: managed-lb — a Google Cloud load balancer with a global static IP and a Certificate Manager cert-map.
- DNS A-records for the apex hostname are declared explicitly (no external-dns auto-publish in prod).
Domains & DNS conventions
Which domain an app lives on depends on whether it is internal or customer-facing. The rule of thumb: u2i.dev is our internal / nonprod world; customer-facing production lives on its own brand domain.
Internal projects → u2i.dev
Every internal project gets a subdomain under u2i.dev. The apex is the internal-prod tier; deeper subdomains are the nonprod tiers. For a project titra: prod is titra.u2i.dev, and dev / qa / preview are dev.titra.u2i.dev, qa.titra.u2i.dev, pr-N.titra.u2i.dev. All of it — including the prod apex — can sit in one nonprod-hosted DNS zone; u2i.dev is internal, so there is no need to split it across projects.
Customer-facing production → its own brand domain
When an app has a real production domain (e.g. retrotool.io), production lives there and nowhere else — a prod-only DNS zone in the prod project, records managed automatically. Its nonprod tiers still live under u2i.dev (retrotool.u2i.dev, dev.retrotool.u2i.dev, …). Do not put dev / qa / preview under the brand domain: keeping pre-release off the customer domain avoids search-engine indexing of staging, brand confusion, and cert/DNS sprawl on the brand domain.
How the records actually get written
Nonprod hostnames are published automatically by external-dns — an add-on (not native GKE) that watches each app's HTTPRoute, reads the gateway's IP, and writes the matching A-record. Each environment's external-dns can only write to DNS zones in its own GCP project. That is why an internal-prod apex whose zone lives in the nonprod project (e.g. retrotool.u2i.dev today) is declared as an explicit in-code record instead: the prod cluster's external-dns cannot reach a nonprod-hosted zone. A genuine customer-prod domain avoids this entirely — its zone lives in the prod project, so its own external-dns manages it automatically.
Two layers: DNS lives in one place, serving in another
For an internal-domain prod apex (e.g. gcp-platform-docs.u2i.dev), it's worth separating two things. The DNS — the zone and its A-record — lives entirely in the nonprod project (that's where the u2i.dev subzone was created). The serving stack — the pod, the HTTPRoute, the TLS cert, the Gateway, and the static IP the record points at — lives entirely in prod, on the shared managed-lb cluster-gateway. So the record in nonprod is just a pointer to the prod Gateway's IP; the traffic goes to prod. A real customer domain collapses this — its zone sits in prod too, so both layers are in the same place and no cross-project declarative record is needed.