Skip to content

Configuration runtime du frontend (une image, trois environnements)

Comment une SPA compilée peut servir trois environnements sans être rebuildée pour chacun, et pourquoi c'était le dernier verrou avant d'exposer les fronts de dev et de staging. Issue #156.


1. Le problème : Vite compile la config dans le bundle

Vite remplace les import.meta.env.VITE_* par leur valeur au moment du build. Ce n'est pas une lecture de variable d'environnement au démarrage : c'est une substitution de texte dans le JavaScript produit.

// avant #156, dans frontend/src/api.js
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "";

devenait, dans le bundle livré :

const API_BASE_URL = "https://api.devopsyouss.com";

Le frontend/Dockerfile fixait cette valeur par un ARG dont le défaut était la prod, et la CI (build-frontend-candidate) ne passait aucun --build-arg. Une seule image frontend est buildée puis promue par digest vers les trois environnements (ADR 028, ADR 029 D2) : les trois servaient donc une SPA qui parlait à l'API de prod.

Pourquoi c'était bloquant, et pas juste inélégant

Le cloisonnement construit en #137 et #138 (une database, un secret et un rôle IRSA par env) protège ce que fait le serveur. Ici c'est le navigateur qui choisit sa cible : un front de dev exposé aurait écrit dans les données de prod depuis le poste de l'utilisateur, en contournant toute l'isolation côté cluster. C'est SEC-006 à l'identique, déplacé du backend vers le client.

Les HTTPRoute des overlays frontend/dev et frontend/staging étaient donc supprimées ($patch: delete) : la protection était une absence, pas une restriction. Voir Exposition involontaire.

2. La solution : lire la config au démarrage, pas au build

L'URL sort du bundle et devient un fichier servi à côté de lui.

flowchart LR
    subgraph img["Image unique (promue par digest)"]
        bundle["bundle JS<br/>aucune URL d'API"]
    end
    subgraph pod["Pod frontend-dev"]
        cm["ConfigMap frontend-config<br/>(overlay dev)"] -->|monté RO| cfg["/config/config.js<br/>window.__ENV"]
    end
    bundle -->|"window.__ENV?.API_BASE_URL"| cfg
    cfg -->|fetch navigateur| api["api-dev.devopsyouss.com"]

Trois pièces, aucun script de démarrage :

Le fichier, généré par overlay et donc différent par env :

// k8s/overlays/frontend/dev/config.js
window.__ENV = { API_BASE_URL: "https://api-dev.devopsyouss.com" };

Son chargement, avant le bundle dans frontend/index.html :

<script src="/config/config.js"></script>
<script type="module" src="/src/main.jsx"></script>

Un <script> classique est exécuté immédiatement, alors qu'un type="module" est différé par le navigateur : window.__ENV est donc toujours prêt quand le bundle le lit.

Sa lecture, dans frontend/src/api.js :

export const API_BASE_URL = window.__ENV?.API_BASE_URL ?? window.location.origin;

3. Pourquoi un ConfigMap monté plutôt qu'un entrypoint

L'approche classique est un entrypoint.sh qui génère config.js au démarrage depuis une variable d'environnement. Elle a été écartée :

ConfigMap monté (retenu) entrypoint.sh
Écriture au démarrage aucune il faut écrire dans html/
readOnlyRootFilesystem: true intact à contourner par un emptyDir
Image nginx-unprivileged inchangée ENTRYPOINT custom à maintenir
Rollout au changement d'URL automatique (hash kustomize) à déclencher soi-même

Le configMapGenerator de Kustomize suffixe le nom du ConfigMap par un hash de son contenu et réécrit la référence dans le Deployment. Changer l'URL d'un env change le hash, donc le nom, donc le podspec : les pods roulent tout seuls. Un ConfigMap monté à nom fixe, lui, se serait mis à jour dans le volume sans que nginx ni le navigateur ne le sachent.

# k8s/frontend/deployment.yaml — le nom est réécrit par l'overlay
volumes:
  - name: runtime-config
    configMap:
      name: frontend-config

4. Le repli, et pourquoi il ne pointe pas sur la prod

Si config.js est absent (ConfigMap non monté, npm run dev), window.__ENV est undefined et le front se rabat sur window.location.origin : il tape sur lui-même et échoue en 404, ce qui se voit tout de suite.

Un défaut sûr n'est pas un défaut qui marche

Le défaut précédent — l'URL de prod — était pratique : l'image fonctionnait toujours, partout. C'est précisément ce qui rendait la faute invisible, puisqu'un front mal configuré se connectait quand même, à la mauvaise base. Un repli qui échoue bruyamment en local vaut mieux qu'un repli silencieux vers la production.

Le location = /config/config.js de nginx.conf répond no-store : le bundle est immuable et cachable, mais un config.js en cache navigateur ferait survivre l'URL d'API précédente après un changement d'env.

5. En local, le même mécanisme

Le docker-compose-dev.yaml ne passe plus de build-arg : il monte le répertoire frontend/local-config/, qui joue le rôle du ConfigMap.

volumes:
  - ./frontend/local-config:/usr/share/nginx/html/config:ro

Le mount porte sur le répertoire, pas sur le fichier : un bind-mount de fichier unique ne suit pas les réécritures d'un éditeur (qui remplace l'inode au lieu de l'éditer). Pour viser une autre API, éditer frontend/local-config/config.js et redémarrer le conteneur, sans rebuild.

6. Ce que ça débloque

  • Les overlays frontend/dev et frontend/staging ont retrouvé leur HTTPRoute : app-dev.devopsyouss.com et app-staging.devopsyouss.com sont exposés.
  • Le CORS côté API était déjà prêt : ALLOWED_ORIGINS suit le front du même env dans les overlays fastapi/{dev,staging} depuis #138, en prévision de ce jour.
  • L'image reste unique et promue par digest : la propriété d'ADR 029 D2 (ce qui est déployé en prod est exactement ce qui a été scanné) est préservée, pas contournée.

Ce que ça ne fait pas

Rien n'empêche techniquement d'écrire l'URL de prod dans le config.js de dev : c'est un fichier du dépôt, la garantie est celle de la revue de MR. La barrière dure reste celle de #137/#138 côté serveur (database, secret et rôle IAM distincts), qui elle ne dépend d'aucun fichier de configuration du front.