Skip to content

Configuration

The step by step populated Vault and laid down the ClusterSecretStore. This page comes back to what happens next: the shape every ExternalSecret must take, what distinguishes a Vault secret from a ConfigMap variable and from a variable frozen into an image, and the inventory of sources per service. It does not repeat the installation of External Secrets Operator nor the unsealing of Vault, detailed at step 6 of the Step by step.

The path of a secret, from Vault to the pod

operator ─vault kv patch─▶ Vault KV v2, mount "secrets", path prod/<service>
   ─kubernetes auth, role eso─▶ ClusterSecretStore vault-store
   ─ExternalSecret (dataFrom: extract)─▶ Kubernetes Secret ─envFrom─▶ pod

This repository only carries the middle link, the ExternalSecret. Both ends, the value in Vault and the variable in the pod, are never written in plaintext in the manifests.

The OIDC authentication chain and the single-issuer invariant

A single issuer must appear everywhere: in KC_HOSTNAME, in the Vault keys that carry the issuer on the portals and APIs side, and in the Keycloak realm. The figure follows that path from end to end; it is the place to spot a link that would still carry the old form of the domain.

The mandatory shape of an ExternalSecret

Every ExternalSecret that feeds environment variables must take this shape:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: trade-classification-svc-secret
  namespace: api
spec:
  refreshInterval: 1m
  secretStoreRef:
    kind: ClusterSecretStore
    name: vault-store
  target:
    name: trade-classification-svc-secret
    creationPolicy: Owner
    deletionPolicy: Retain
  dataFrom:
    - extract:
        key: prod/classification

dataFrom: extract injects into the Secret every key present at the Vault path, as they stand. An enumerated data: list, by contrast, forces each key to be declared one by one in the manifest, and forgetting a single one is silent: the pod starts normally, the variable is merely missing at runtime. The case has already happened on CONTENT_SERVICE_URL, with a media proxy returning 404 as the symptom. With extract, adding a key on the Vault side is enough; it remains to restart the pods so that they read it.

creationPolicy: Owner makes the ExternalSecret the owner of the produced Secret. It is the property that most surprises an operator discovering this repository: it means that a Secret laid down by hand, for instance with kubectl create secret or kubectl edit, is overwritten at the next synchronisation, silently. The only valid write point is Vault.

deletionPolicy: Retain decides what happens to the Secret when a key disappears on the Vault side: it keeps its last known value instead of being emptied. A mishandling in Vault thus becomes a silent drift rather than an immediate outage: the Secret survives the disappearance of its source, and the gap only shows in the status of the ExternalSecret.

apps/keycloak/keycloak-manifests.yaml does not declare deletionPolicy: it then takes the API default Retain, the right behaviour, but without the intent being written.

Writing into Vault is done with vault kv patch, never vault kv put, on any path shared by several keys. put replaces the entire content of the path: any key absent from the command disappears, hence disappears from the Secret at the next synchronisation, with no error and no alert. put remains acceptable on a path dedicated to a single key, such as the one for the Cloudflare tunnel.

One ExternalSecret in the repository departs from this shape without being forced to, and it is exactly the defect just described: keycloak-db-external-secret (apps/keycloak/keycloak-manifests.yaml) enumerates username and password in data:, not in extract, to produce the Secret keycloak-db-secret. A key added later to the Vault path prod/keycloak-db-secret will therefore never reach that Secret as long as the manifest is not modified to enumerate it explicitly.

One object departs legitimately, for a precise reason and not for a preference: the Secret of the Cloudflare tunnel, cloudflared-credentials, materialises a file, not a set of environment variables. A dot in a Vault key name reads badly with extract, so that object declares a data: entry with property, then a target.template that recomposes the file key expected by the container. core/vpn/externalsecret-wireguard.example.yaml shows the same construction for a future WireGuard Secret, but carries the .example.yaml suffix: it is not applied on the reference instance, it is only a template. That exception holds only for a single-key file secret.

Rotating a secret

External Secrets Operator updates the Kubernetes Secret at every refreshInterval, one minute for almost every object in the repository. It updates no pod already started: environment variables are frozen at the moment the container starts, exactly as the NEXT_PUBLIC_* variables are frozen at the moment the image is built, further down this page. A value modified in Vault therefore only takes effect after:

kubectl rollout restart deployment/<nom> -n <namespace>

Without that restart, the Kubernetes Secret carries the new value, but the running pods keep serving with the old one, with no error to signal it.

Inventory of sources per service

Each application service combines up to three sources: a Vault ExternalSecret, a CA trust ConfigMap mounted as a file, and for the portals variables frozen at image build time. None of those three sources substitutes for the other two. Nowhere does this repository version the list of the variable names each service reads from its Secret: the table below gives the ExternalSecret and the Vault path of each service, not the keys it contains. To obtain those keys on an existing installation, without ever displaying their value:

kubectl get secret <nom-du-secret> -n <namespace> -o jsonpath='{.data}' | tr ',' '\n' | cut -d'"' -f2

Before the Secret exists, the same inventory is read directly on the Vault path, without displaying the values:

vault kv get -mount=secrets -format=json prod/<service> | jq -r '.data.data | keys[]'
Service Namespace Vault secret (ExternalSecret, extract) Notes
hub-portail web hub-portail-secret, path prod/hub-portail-secret NEXT_PUBLIC_* variables frozen at build time, see further down
portal-portail web portal-portail-secret, path prod/portal-portail-secret NEXT_PUBLIC_* variables frozen at build time
internal-portail internal internal-portail-secret, path prod/internal-portail-secret NEXT_PUBLIC_* variables frozen at build time
trade-classification-svc api trade-classification-svc-secret, path prod/classification .NET runtime
trade-declaration-svc api trade-declaration-svc-secret, path prod/declaration .NET runtime
trade-declaration-admin-svc api trade-declaration-admin-svc-secret, path prod/declaration-admin .NET runtime
trade-notification-svc api trade-notification-svc-secret, path prod/notification .NET runtime
trade-registration-svc api trade-registration-svc-secret, path prod/registration .NET runtime
trade-valuation-svc api trade-valuation-svc-secret, path prod/valuation .NET runtime
trade-content-service-svc api trade-content-service-svc-secret, path prod/content-service Node runtime, PORT=3000 fixed in the manifest; two specific traps, see further down
keycloak auth keycloak-db-external-secret, path prod/keycloak-db-secret, data mode (username, password), not extract, see further up KC_HOSTNAME fixed in apps/keycloak/keycloak.yaml to the full URL form of the auth host, substituted by render-config.sh with ${TV_DOMAIN}
cloudflare-dns-token (cert-manager) cert-manager cloudflare-dns-token, path prod/cloudflare-dns Consumed by the Let's Encrypt ClusterIssuer
cloudflared-credentials (cloudflare) cloudflare cloudflared-credentials, path prod/cloudflared, data + template mode File secret, exception to extract documented further up

A "Mounted CA ConfigMap" column stood here until 2026-09-10. It went away with the internal CA: there is nothing left to mount in order to trust *.${TV_DOMAIN}.

The NEXT_PUBLIC_* variables, frozen at build time

The Next.js portals (hub-portail, portal-portail, internal-portail) read their NEXT_PUBLIC_* variables at the moment the image is built, never at container startup. That is the case for instance of NEXT_PUBLIC_CONTENT_SERVICE_URL, whose canonical value is written in apps/api/content-service/content-ingress.yaml: https://content.${TV_DOMAIN}. That host is however not part of the three authorised public DNS records (hub, portal, auth, see step 13 of the Step by step), whereas the portals that call it are themselves public: this repository says nowhere how a browser located outside the internal network is meant to reach that host. Confirm this point before fixing the variable and launching the build, rather than assuming that an additional public record is the answer. A wrong or stale value cannot be caught up by changing an environment variable of the Deployment, nor by redeploying: the only possible fix is to rebuild the image with the right value, then redeploy that new image. A domain change (${TV_DOMAIN}) that is not accompanied by that rebuild leaves the portals calling the old domain, with no Kubernetes error to signal it: the pods start, the readiness probe passes, only the requests issued from the browser fail.

This point joins the Vault keys AUTH_KEYCLOAK_ISSUER, NEXTAUTH_URL and NEXTAUTH_URL_INTERNAL already flagged in Parameters: they all change together with KC_HOSTNAME, failing which every token is rejected with the error IDX10205.

The two traps of the content-service

The content-service (Payload) carries two variables that must never be defined, each for a distinct reason, both already met in real conditions.

SERVER_URL (and PAYLOAD_PUBLIC_SERVER_URL) must stay absent from the Vault secret prod/content-service. When it is defined, Payload returns absolute media URLs, which the browser then calls directly on the content-service, without going through the /api/media proxy of the portals. That direct call is anonymous, without API key or cookie, and access to the media requires an authorisation: the read fails with 403, intermittently depending on the workstation displaying the page. Absent, Payload emits relative URLs, which the browser resolves from the origin of the portal, hence through the proxy that adds the API key on the server side.

PAYLOAD_AUTO_SEED must not be defined. Its presence relaunches a full seed of the exchange rates at every pod start. That seed conflicted with the rates already active and marked them expired while their validity period was still open, an incident that has already occurred: the exchange rate section became empty on both portals at once. Absent, the default behaviour is safe. Should the case occur again nonetheless, only the lines expired without human intervention (expired_by_id empty) must be reactivated; an expiry decided by an administrator is never reactivated automatically.

CA trust: nothing left to set

There is no CA trust variable left to set. *.${TV_DOMAIN} is served by a Let's Encrypt certificate, whose chain is already in the default store of every application image.

That was not always the case, and the history is worth knowing before reintroducing one: until 2026-09-10, an internal CA was distributed by trust-manager, with two different variables for two runtimes, SSL_CERT_FILE on the .NET and OpenSSL side, NODE_EXTRA_CA_CERTS on the Node.js side. Node reads neither the former nor the OpenSSL store, which made the two non-interchangeable, and confusing them produced no visible effect before a TLS connection failed.

They had become redundant as soon as the domain moved to Let's Encrypt, and survived several weeks in that state, "as a precaution". A trust nobody uses goes unnoticed: that is what makes this kind of leftover durable.

ATTENTION: should a component one day come back to an internal certificate, the check to run before setting SSL_CERT_FILE is that the image does own a default store. Measured here: the .NET images carry 150, against 151 in the mounted bundle. Pointing SSL_CERT_FILE at a file containing only the internal CA would lose all public trust at once, with no error at startup.

What this parameterisation does not cover

The exact name of the keys expected by each service, beyond the variables already quoted on this page and the command given further up to find them, lives in the application code, outside the scope of this repository. Confirm those names with the vendor before writing into Vault: a missing key reproduces the same symptom as CONTENT_SERVICE_URL further up, a service that starts normally and fails only in use.