Internal ingress
The components/internal-ingress kustomize component serves the web console
in-cluster and exposes it plus the four services over an internal-only
ingress-nginx, so operators reach an environment by browsing a private URL over
the VPN. Both the staging and prod overlays include it, with
*.staging.tcab.testcabinet.ai and *.tcab.testcabinet.ai hostnames.
The boundary is private. ingress-nginx is installed with the Azure internal-LB
annotation, so its Service holds a private VNet IP. The hostnames resolve
through an Azure Private DNS zone that VPN clients see. The public
gallery and docs stay
on Cloudflare Pages and are unaffected.
Component contents
Section titled “Component contents”The component carries app-level resources only; the controllers are a cluster prerequisite.
tcab-web, aDeploymentandClusterIPServiceserving the console as a static SPA from thetcab-webimage (nginx, port8080). The image is environment-agnostic, one build per git sha, so the backend and auth URLs are injected at runtime: the entrypoint renders a/config.jsfromTCAB_WEB_BACKEND_URLandTCAB_WEB_AUTH_URL, and the SPA prefers thatwindow.__TCAB_CONFIG__over its build-timeVITE_*defaults. The workload itself lives in a nestedcomponents/webcomponent, so an overlay can serve the console in-cluster without this component’s ingress, certificate, andNetworkPolicywiring. The local k3d overlay omits both: locally the console runs from source against akubectl port-forwarded backend, so a UI edit hot-reloads.- Six host-per-service
Ingressroutes, oneIngresseach withingressClassName: nginx:consoletotcab-web,apitotcab-backend:8787,authtotcab-auth:8789,artifactstotcab-artifacts:8790,arenatotcab-arena:8791, andgrafanatotcab-lgtm:3000. Each carries the nginx annotations the data plane needs:proxy-body-size: "0", because the artifact service streams run-tree tars and accepts uploads that the default 1 MB cap would truncate;proxy-read-timeoutandproxy-send-timeout: "3600", because the backend and arena serve long-lived NDJSON streams that the default 60 s timeout would sever; andproxy-buffering: "off", so stream chunks flush straight through. - Each
Ingressnames the cert-managerClusterIssuerletsencrypt-internal, which issues each host a Let’s Encrypt certificate over the ACME production directory, solved by DNS-01 over Cloudflare. DNS-01 is required because the hosts are internal-only: Let’s Encrypt cannot reach an HTTP-01 token, while proving control of thetestcabinet.aizone with a TXT record needs no inbound path. The solver reads a Cloudflare API token withZone:DNS:Editfrom thecert-manager-cloudflareSecret, keyapi-token. TheClusterIssueris cluster-scoped, so it lives indeployments/k8s/cluster/internal-ingressand is applied with the cluster prerequisites rather than by this component. - Three additive
NetworkPolicyrules admitting theingress-nginxnamespace through the base default-deny, covering the four services,tcab-web, andtcab-lgtm; see NetworkPolicy.
Client-facing URL repointing
Section titled “Client-facing URL repointing”The backend advertises the artifact and arena base URLs to the console through
GET /config, and the base sets those to cluster-internal DNS, which a machine
on the VPN cannot resolve. Each overlay therefore patches:
- the backend’s
TCAB_ARTIFACTS_PUBLIC_URLandTCAB_ARENA_PUBLIC_URLto theartifacts.andarena.hostnames, and - the
tcab-webpod’sTCAB_WEB_BACKEND_URLandTCAB_WEB_AUTH_URLto theapi.andauth.hostnames.
TCAB_BACKEND_AUTH_URL and TCAB_ARTIFACTS_URL are the backend’s own
server-side call URLs and stay in-cluster, at http://tcab-auth:8789 and
http://tcab-artifacts:8790. Only the client-facing URLs move to the https
hostnames.
Prerequisites
Section titled “Prerequisites”The controllers and the cloud-side plumbing are a one-time cluster prerequisite, installed out of band. Order matters: the DNS records can only be created once the ingress controller has its internal LB IP.
-
Install ingress-nginx with an internal LB through Helm into its own
ingress-nginxnamespace (prod pins chart4.15.1), forcing an Azure internal LB so it receives a private VNet IP:controller:service:annotations:service.beta.kubernetes.io/azure-load-balancer-internal: "true"externalTrafficPolicy: LocalingressClassResource: { name: nginx, default: false }Once it settles, read the assigned private IP, which the DNS records point at:
Terminal window kubectl -n ingress-nginx get svc ingress-nginx-controller \-o jsonpath='{.status.loadBalancer.ingress[0].ip}' # prod: 10.224.0.9The LB IP lives in the AKS node VNet (
aks-vnet-*,10.224.0.0/12), not the app VNet. -
Create the Azure Private DNS zone and records. Prod uses a dedicated
tcab.testcabinet.aisub-zone. A privatetestcabinet.aizone would shadow the public zone for VPN clients and stop them resolving the public gallery and docs. Create the zone, link it to both the AKS VNet and the app/VPN VNet with registration disabled, and add A records forconsole,api,auth,artifacts,arena, andgrafanapointing at the LB IP from step 1. The_acme-challengeTXT records from step 5 live in the public Cloudflaretestcabinet.aizone, which theZone:DNS:Edittoken covers. -
Install cert-manager with its CRDs through Helm into the
cert-managernamespace (prod pinsv1.20.3). Two non-default flags are load-bearing:Terminal window helm upgrade --install cert-manager jetstack/cert-manager \--namespace cert-manager --create-namespace --version v1.20.3 \--set crds.enabled=true \--set clusterResourceNamespace=tcab-prod \--set "extraArgs={--dns01-recursive-nameservers-only=true,--dns01-recursive-nameservers=1.1.1.1:53,1.0.0.1:53}"clusterResourceNamespacemakes the cluster-scopedClusterIssuerresolve thecert-manager-cloudflareSecret from the environment’s namespace, where keyvault-csi syncs it. The--dns01-recursive-nameserversflags point the DNS-01 self-check at public resolvers, which is required because the private zone is linked to the AKS VNet, so in-cluster DNS resolves those names to the private LB IP and sees no public NS records. Without them cert-manager loops on “Could not determine authoritative nameservers” and issues no certificate. The CRDs must exist before theClusterIssuerapplies. -
Provision the Cloudflare DNS-01 token. Mint a Cloudflare API token with
Zone:DNS:Editscoped totestcabinet.ai; a Pages-scoped publishing token cannot edit DNS records. Store it for cert-manager as thecert-manager-cloudflareSecret, keyapi-token. Prod adds it as acloudflare-dns-tokenKey Vault secret synced by thecomponents/keyvault-csiSecretProviderClass.The CSI driver does not reconcile an existing synced Secret on a plain remount. Secret auto-rotation closes that gap for changed values: with it enabled on the AKS
azure-keyvault-secrets-provideradd-on the driver polls Key Vault and reconciles updated values into the synced Secrets on its own, so a refreshed credential reaches the cluster without a restart. Enable it once per cluster withscripts/enable-secret-rotation.sh --env <prod|staging>, which is add-on configuration rather than a Kubernetes object. Adding a brand-new key to a Secret still needskubectl delete secret <name> && kubectl rollout restart deploy/tcab-keyvault-sync. -
Upload the Grafana admin credentials. The overlay exposes Grafana at the
grafana.hostname, and itspatch-grafana-auth.yamldisables theotel-lgtmimage’s anonymous-admin default, reading the admin user and password from thetcab-grafana-adminSecret. Add bothgrafana-admin-userandgrafana-admin-passwordas Key Vault secrets. They are listed in the keyvault-csiSecretProviderClass, and the Azure provider fails the whole mount if any listed object is absent:Terminal window az keyvault secret set --vault-name testcabinet-clockwyrks \--name grafana-admin-user --value admin --output noneaz keyvault secret set --vault-name testcabinet-clockwyrks \--name grafana-admin-password --value "$(openssl rand -base64 24)" --output noneThe vault has an IP firewall, so run these from an allow-listed host or add your IP with
az keyvault network-rule add. With either secret missing thetcab-lgtmpod stays inCreateContainerConfigError, which is deliberately fail-closed. -
Apply the cluster-scoped objects with
kubectl apply -k deployments/k8s/cluster/azure-<env>, let the pipeline deploy the overlay, thenkubectl rollout restart deploy/tcab-keyvault-sync -n <namespace>so the newcert-manager-cloudflareandtcab-grafana-adminSecrets materialize. cert-manager completes the DNS-01 challenge with the Cloudflare token and issues the six certificates; confirm withkubectl -n <namespace> get certificate, which should reportReady=Truefor each. -
Confirm VPN DNS resolution. The OpenVPN configuration must make clients resolve the private zone, by pushing Azure DNS
168.63.129.16or a resolver that sees the Private DNS zone. From a connected client,nslookup console.tcab.testcabinet.aireturns the internal LB IP.
The Cloudflare token in step 4 and the Azure DNS zone in step 2 are independent and can be prepared in parallel.