Architecture¶
The customer sheet from the previous page carries the values. This page shows where they fit in: the target deployment view, the layers that make it up and their dependencies, name resolution depending on where the request comes from, and the boundary between what a script sets and what a human has to set.
Overview¶
The first figure shows who talks to whom: three actors, each by a distinct route, and the internal layers they reach. The public user goes through the Cloudflare edge then the tunnel to Traefik; the internal agent goes through WireGuard to the internal DNS resolution; an external third party (a customs system, in the repository's example) reaches a microservice directly with a signed request, without going through Traefik or through the other two routes. It does not show the detailed path of a request nor what is checked at each step: that is the subject of the next figure. Nor does it show the physical placement of the machines, already seen on the previous page.

Three routes enter the system, and none of the three looks like the other two: it is the third one, the external third party reaching a microservice directly, without going through Traefik, that breaks the idea of a single entry point and deserves to be spotted first.
The second figure follows a public request, step by step, with every point where it can stop: DNS resolution, filtering at the Cloudflare edge (blocking the administration console, rate limiting, cache), tunnel exit, checking the declared host, Traefik, the portal, then the .NET API through to its database. It also places, as a dashed line, the shortcut taken by a LAN or VPN workstation: its resolver returns the address of Traefik directly, going through neither the edge nor the tunnel. It does not show the request issued from a pod internal to the cluster, covered by the name resolution table further down.

The dashed shortcut is the most common verification trap: a request sent from the LAN or the VPN skips the first two steps of the chain, so a test that succeeds from that network proves nothing about the public exposure. Verify from an outside network, never from the LAN or the VPN.
The six layers and their dependencies¶
The target installation is organised into six layers. Each depends on the one before it: installing a layer before the one it depends on produces a component that starts without being able to work, or that does not start at all. The next page details why this order cannot be permuted; this one sets out the list and the dependencies.
| # | Layer | Contents | Depends on |
|---|---|---|---|
| 1 | Talos | Cluster bootstrap: control-plane, workers, kube-vip on the Talos side | nothing, it is the foundation |
| 2 | Secrets | Vault (secrets/vault.yaml, StatefulSet of 3 replicas in the vault namespace), External Secrets Operator, the ClusterSecretStore |
Talos only |
| 3 | Certificates | cert-manager and the wildcard certificate | Secrets, because the ClusterIssuer resolves its Cloudflare token through an ExternalSecret fed by Vault |
| 4 | Network, ingress and internal DNS | Calico, kube-vip on the Kubernetes side, Traefik and its TLSStore, CoreDNS | Certificates, because the TLSStore cannot serve a certificate that is not Ready |
| 5 | Data layer | Databases and queues internal to the cluster, access to the external data layer | Secrets, because the connection credentials are injected by ESO |
| 6 | Applications and exposure | Keycloak and its realm, .NET microservices, Next.js portals, Cloudflare tunnel, VPN | Data layer, Secrets, and the internal DNS for resolving *.${TV_DOMAIN} |
The point that deserves detail is the move from layer 2 to layer 3: at first sight,
the secrets and the certificates seem to reference each other, since Vault's own UI
is exposed under the wildcard domain, by an Ingress laid down at layer 4. The loop does not
close, however, because Vault never needs that Ingress to work: its
Service vault.vault.svc:8200, in the vault namespace, is covered by kubectl kustomize .
and resolves through Kubernetes' standard internal DNS as soon as Talos and the CNI are in place,
without depending on layer 4's customised CoreDNS or on any certificate. It is this Service,
reachable from the operator's workstation by kubectl port-forward with no public DNS and no
certificate, that is used to unseal Vault and to write into it the Cloudflare token that the ClusterIssuer
of layer 3 expects. Layer 4's Ingress only adds a more comfortable web access
to an already working UI.
Name resolution: the split-horizon¶
The same name *.${TV_DOMAIN} does not resolve the same way depending on where the request comes from.
| Origin | Resolution | By what |
|---|---|---|
| Inside the cluster | to the Traefik service, ${TV_TRAEFIK_CLUSTERIP} |
CoreDNS, by a rewrite rule |
| On the VPN or the LAN | to ${TV_VIP} |
the LAN DNS resolver, pushed to WireGuard clients or delegated by the site's DHCP |
| From the internet | to the Cloudflare edge, never directly to the cluster | three public records only (hub, portal, auth), via the Cloudflare tunnel |
What is fixed, what is parameterisable¶
scripts/render-config.sh substitutes ten families of values in the repository's manifests. It
announces their number itself at launch, on the line Substitutions actives. Outside
that list, an operation is either fixed (the same at every customer), or to be set by
hand, without any text substitution being able to do it.
Parameterisable by render-config.sh |
Set by hand by the operator |
|---|---|
The domain ${TV_DOMAIN}, including its escaped form in the CoreDNS rule |
The Cloudflare zone and API token (Zone:DNS:Edit and Zone:Zone:Read) for the DNS-01 challenge |
The kube-vip range ${TV_VIP_RANGE} |
The Vault keys that carry the OIDC issuer and the API URLs of the .NET services |
The VIP ${TV_VIP} |
The Keycloak realm, its Valid Redirect URIs and its Web Origins |
Traefik's ClusterIP ${TV_TRAEFIK_CLUSTERIP} |
Rebuilding the frontend images for the NEXT_PUBLIC_* variables, frozen at build time |
The host of the application registry ${TV_REGISTRY} |
Publishing the eleven application images into the customer's registry |
The address of the LAN DNS ${TV_LAN_DNS_IP} |
The Cloudflare tunnel UUID, to be replaced by hand after its creation |
The address of the WireGuard concentrator ${TV_VPN_IP} |
|
The ACME contact address ${TV_ACME_EMAIL} |
The name of kube-vip's network interface, vip_interface, frozen to ens34 in core/kube-vip/kube-vip-ds.yaml |
The pod subnet ${TV_POD_SUBNET}, in the WireGuard tunnel configuration |
|
The service subnet ${TV_SVC_SUBNET}, in the same configuration |
Each image's tag follows a rule of its own, with no dedicated variable in the customer sheet:
the repository's ten application images carry an immutable per-build tag, and keycloak-tradeval carries
prod pinned by its digest, specific to the registry that built the image. A single variable
applied to the whole set would build references that exist in no registry. Each image's tag is therefore
set at the moment the operator publishes their own images, at the same time as their name.
${TV_CP_ENDPOINT} stays out of reach of render-config.sh for a different reason: it
does not live in a manifest to be substituted, but in the Talos machine configuration, generated by
talosctl. ${TV_POD_SUBNET} and ${TV_SVC_SUBNET} live in that same configuration, and
in addition in core/vpn/peer-officer.template.conf, where the script substitutes them: both
places must carry the same value, failing which the tunnel comes up and routes nothing, with no
error. See Prerequisites.