Cross-cloud DNS federation — GKE workloads assume an AWS role for Route53
Status: Accepted
Date: 2026-08-25
Deciders: Platform Team
Related Design: GCP public ingress design, in the repository at
docs/superpowers/specs/2026-08-25-gcp-public-ingress-design.md (design docs are not published
to this site, so this is a repo path rather than a link)
Context
Workstream 10 built gcp-0’s private ingress and stopped at the public boundary, recording
why: DNS-01 has nothing to solve against on GCP. opentofu/gcp/network/dns.tf creates a
private Cloud DNS zone, cloud.ogenki.io is a Route53 hosted zone
(Z002027037R5RFCG05YY6) this repository does not manage, and Let’s Encrypt must resolve the
_acme-challenge TXT record publicly. Three ways out were named and none chosen at the
time.
This design revisits that boundary because one of the three stopped being the credential
liability the earlier note assumed. cert-manager v1.21.1 — the version this repo pins —
supports spec.acme.solvers[].dns01.route53.auth.kubernetes: Kubernetes authenticates to
Route53 using AssumeRoleWithWebIdentity with a bound ServiceAccount token, the same
identity-by-token model the platform already uses on both clouds (EKS Pod Identity, GKE
Workload Identity) extended across the cloud boundary. No access key, no secret placed in GCP
Secret Manager, nothing to rotate.
Decision Drivers
- No static AWS credentials. Whatever gcp-0 uses to reach Route53 must not be a long-lived access key sitting in GCP Secret Manager.
- Cloud-agnostic public names. ADR-0017 already decided public names carry no cloud label, so a service can move between clouds without a DNS rename. Amended below: gcp-0’s own default wildcard turned out to need a per-cloud name after all, for a reason ADR-0017 did not anticipate — see the note under Decision Outcome and the Consequences.
- One Gateway implementation per cluster, consistent with ADR-0005’s reasoning against a second Gateway controller on GKE.
- No deletion permissions on stateful services the platform constitution already commits to, applied here to a Route53 zone this repository does not own.
- Minimal blast radius of whatever crosses the cloud boundary — scoped to record changes in one zone, not zone management.
Considered Options
Option A: Route53 stays authoritative, GCP federates (chosen)
One public zone, names stay cloud-agnostic. Cost: an AWS IAM OIDC provider trusting the GKE issuer, and GCP’s public certificate issuance depends on Route53 being reachable. The dependency is real, but with the OIDC/bound-token model it is a credential-less dependency on a DNS zone — not a shared secret and not a shared control plane, which is what the parent design’s earlier objection to this option (“reintroduces exactly the cross-cloud dependency option A was rejected for”) assumed it would be.
Option B: Delegate a subdomain to a public Cloud DNS zone
No AWS dependency at all, which is genuinely attractive. Rejected because the delegated name
would have to be cloud-labelled — gcp.cloud.ogenki.io — and
ADR-0017 rejects exactly that: “A public endpoint has no
such constraint, and encoding the cloud into it converts a deployment detail into a permanent
contract.” Public names are cloud-agnostic so a service can move between clouds; a per-cloud
public zone forecloses that. It also needs a parent-zone (NS delegation) change on the existing
Route53 zone.
Option C: Move cloud.ogenki.io wholesale to Cloud DNS
Keeps names cloud-agnostic and removes GCP’s dependency on AWS — but inverts it: aws-0 would
then need federated access to Cloud DNS for its own certificates and records. A symmetric
problem, a larger migration, and it moves a live public zone that this repository does not even
manage today.
Option D: Tailscale Funnel
Both private Gateways already carry tailscale.com/funnel: "false", so enabling it is one
annotation away, and it would need no load balancer, no DNS-01 and no public zone at all. It is
excluded by a hard constraint rather than a trade-off: Funnel serves a certificate for
<machine>.<tailnet>.ts.net and cannot serve one for a custom domain. A CNAME from
cloud.ogenki.io to it fails TLS. Funnel is the right tool when the hostname does not matter;
here it does.
Decision Outcome
Chosen option: Option A. Route53 stays the single authoritative public zone. AWS gets an
IAM OIDC identity provider trusting the GKE cluster’s issuer
(https://container.googleapis.com/v1/projects/<project>/locations/<location>/clusters/gcp-0)
and one role, gcp-0-route53-dns, assumable only by two named ServiceAccount subjects —
cert-manager in the security namespace, and external-dns-public in kube-system —
scoped to:
route53:ChangeResourceRecordSets/route53:ListResourceRecordSetson the one hosted zoneroute53:GetChange(required by the ACME polling flow; not scopable to a zone — an AWS API limitation, not an oversight)route53:ListHostedZonesByName/route53:ListHostedZonesfor external-dns’s zone discovery
No zone-management permission is granted. The role can change records in one zone; it cannot create, delete or reconfigure zones — consistent with the constitution’s “no deletion permissions for stateful services”.
On the GCP side there is nothing: no GCPWorkloadIdentity claim, no Google service
account, no secret. The projected ServiceAccount token is the credential, and AWS validates it
against the OIDC provider. This is the one place in the platform where a workload authenticates
to the other cloud, and it does so with no material at rest.
The federation lives in opentofu/shared/, not opentofu/aws/: it is an AWS resource that
exists solely to couple the two clouds, which is what shared/ already means here —
opentofu/shared/tailscale holds the tailnet for the same reason. Filing it under aws/ would
present a federation point as AWS’s own concern and hide it from anyone reading the GCP tree.
gcp-0 issues one certificate per hostname, never a wildcard. Each Gateway listener carries
its own certificateRefs secret, so cert-manager’s gateway-shim issues a Certificate whose SAN
list is exactly that one name. A wildcard would mean a single private key covering every service
under the subdomain: one compromised pod or leaked Secret would expose all of them at once. Per
listener, the blast radius of a key compromise is one service.
The cost is that adding a public service means adding a listener. That is the right trade here:
allowedRoutes already restricts routes to the infrastructure namespace, so publishing
something is a reviewed edit either way — and an explicit listener list is an auditable inventory
of what this cluster serves to the internet. Under a wildcard listener, any HTTPRoute in the
allowed namespace can claim any hostname with no second look.
gcp-0’s public name is gcp.cloud.ogenki.io, not cloud.ogenki.io. Caught in the
whole-branch final review, not in the original design. The original reason was a collision: as
first specified, gcp-0 requested the same *.cloud.ogenki.io identifier set that aws-0 already
holds, which would have shared Let’s Encrypt’s Duplicate Certificate limit (5 per week for an
identical hostname set, counted across accounts and not exempted for renewals) with aws-0’s
production renewal, and raced it for the single _acme-challenge.cloud.ogenki.io TXT record.
Moving to per-hostname certificates dissolves that specific collision — distinct names share no
duplicate-certificate bucket and no challenge record. The per-cloud name is kept anyway, on its
own merits: it prevents the two clusters claiming the same public hostname, it gives external-dns
a clean --domain-filter boundary per cluster, and it mirrors the split this repo already made
for private domains (priv.aws.ogenki.io vs priv.gcp.ogenki.io). It is a naming decision now,
not a rate-limit mitigation, and it should be read that way.
No delegation is created — records for gcp.cloud.ogenki.io still live in the same
cloud.ogenki.io hosted zone (Z002027037R5RFCG05YY6), so the IAM scoping above is unaffected;
only the name gcp-0 requests within that zone changed. Because the filter is then a child of
the hosted zone, external-dns needs --aws-zone-match-parent, or it discovers zero zones and
publishes nothing while reporting healthy.
Consequences
Positive
- No static AWS credential anywhere on GCP — no access key, no secret in GCP Secret Manager.
- Public names stay cloud-agnostic at the zone level —
cloud.ogenki.ioremains one shared Route53 zone, not split per cloud. See Negative for the one place this does not extend to gcp-0’s own hostnames. - No wildcard certificate exists on gcp-0: a stolen key buys one service, not the subdomain.
- The trust is minimal and explicit: two named ServiceAccount subjects, one audience, one zone, no delete permissions.
- The stack is independent of GCP stacks existing — the issuer URL is a deterministic string
from project/location/cluster name, not a value read from a live cluster, so
opentofu/shared/aws-gcp-federationhas noafterdependency on GCP.
Negative
- GCP’s public certificate issuance and public DNS records now depend on Route53 being reachable. An AWS-side Route53 outage stalls new certificate issuance and DNS record changes for gcp-0’s public services, though already-issued certificates and already-published records are unaffected until the next renewal or change.
- The OIDC issuer URL is derived, not verified, at write time.
data "tls_certificate"fetches the certificate of the sharedcontainer.googleapis.comhost, which succeeds even when the cluster-specific path in the URL is wrong — a misconfigured project, location or cluster name would not fail the apply. See the comment abovedata.tls_certificate.gke_oidcinmain.tf, and the verification step this pushes into Task 5 (deploy-time, against a livegcp-0). - Renaming
gcp-0, moving it to another zone, or moving GCP projects changes the issuer URL and requires the OIDC provider to be recreated — the federation is pinned to the cluster’s identity, not just its existence. - gcp-0’s public Gateway and Certificate carry a cloud label after all (
gcp.cloud.ogenki.io), which the “cloud-agnostic public names” driver did not anticipate needing. Any HTTPRoute attached to that Gateway inherits agcp.-prefixed hostname by construction — Gateway API requires listener/route hostname intersection — so a public service on gcp-0 cannot get a cloud-agnostic name from this Gateway alone. Accepted because the alternative (the identical*.cloud.ogenki.ioidentifier set aws-0 already holds) is a live production hazard, not a style preference — see Decision Outcome.
Neutral
aws-0is unaffected by the IAM federation itself — it already authenticates to Route53 directly (same account, no federation needed) and this ADR adds no dependency in that direction. It was not unaffected by the certificate as originally specified: requesting the identical*.cloud.ogenki.ioidentifier set on gcp-0 would have shared a Let’s Encrypt rate limit and a DNS-01 challenge record withaws-0’s live production certificate. Fixed by giving gcp-0 its own name — see Decision Outcome.
Implementation Notes
- Stack:
opentofu/shared/aws-gcp-federation/—aws_iam_openid_connect_provider.gke,aws_iam_role.route53, and the policy scoping it toZ002027037R5RFCG05YY6. State in the shared S3 backend, same asopentofu/shared/tailscale(see ADR-0018 for whyshared/stays in S3 while the GCP tree moved to GCS). - Output
route53_role_arn(arn:aws:iam::396740644681:role/gcp-0-route53-dns) is what theletsencrypt-prodClusterIssuer’sdns01.route53.roleand external-dns-public’sAWS_ROLE_ARNconsume on the GCP side. - The stack is not tagged
opt-in: nothing in it depends on GCP infrastructure existing, so there is no reason to skip it by default the way the GCP stacks are. Itsdestroyscript carries its own guard instead — seeopentofu/shared/aws-gcp-federation/workflows.tm.hcl.
References
opentofu/shared/aws-gcp-federation/main.tf— the OIDC provider, role and policy- ADR-0005 · GKE Standard with self-managed Cilium
- ADR-0017 · Multi-cloud DNS naming
- ADR-0018 · Per-cloud OpenTofu state