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.

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:
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:
| 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.