~9 min de lecture
Un build, tous les environnements : arrête de recompiler ton app Angular pour changer une URL d'API
TL;DR
environment.ts + fileReplacements compile ta configuration dans le bundle : un environnement = un build, et l'artefact que tu déploies en prod n'est pas celui que tu as validé en staging. La solution : un config.json servi à côté de l'app, chargé au démarrage, exposé via un InjectionToken<AppConfig> typé. Même bundle partout, la config devient une responsabilité du déploiement, et non plus de la compilation.
Ton pipeline CI tourne trois fois pour livrer trois fois le même code : staging, préprod, prod, 6 minutes chaque fois. Le seul diff entre les trois artefacts ? Une URL d'API et deux booléens. Dix-huit minutes de CI pour 200 octets de données.
Le pire n'est pas le temps perdu : le bundle que la QA a validé en staging n'est pas celui qui part en prod. Le jour où un build prod sort différent (cache CI corrompu, option qui diverge), tu découvres en prod un comportement que staging ne pouvait pas te montrer.
L'inverse a un nom chez les gens qui font du déploiement sérieusement : build once, deploy everywhere. Un artefact unique, promu d'environnement en environnement. Mais l'habitude joue contre toi : ng new générait environment.ts jusqu'à Angular 14, et depuis la v15.1 ng generate environments te le repose en une commande, fileReplacements compris. Le réflexe est resté.
Le problème : ta config est compilée dans le bundle
Le mécanisme classique, tu le connais :
// src/environments/environment.ts
export const environment = {
production: false,
apiUrl: 'http://localhost:3000/api',
};
// angular.json, dans build > configurations > production
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
Au build, le fichier est substitué, importé statiquement, inliné, minifié. Ta config devient du code. Trois conséquences :
1. Un environnement = un build. Ajoute un environnement de démo pour un client : une entrée configurations de plus, un fichier environment.demo.ts de plus, une passe de CI de plus.
2. Tu ne déploies pas ce que tu as testé. La promesse d'un pipeline de promotion (staging valide, donc prod reçoit la même chose) est cassée. Tu promeus un commit, pas un artefact.
3. La tentation du secret. environment.prod.ts ressemble à un fichier de config serveur, alors un jour quelqu'un y pose une clé d'API tierce. Sauf que ce fichier finit dans le JavaScript téléchargé par le navigateur. Fais le test : grep ton dist/ avec une valeur de ton environment.prod.ts. Elle y est, en clair. Une config runtime ne règle pas ça (le navigateur finira toujours par voir ce que le front utilise), mais elle rend la frontière visible : un config.json public ne ressemble jamais à un coffre-fort.
fileReplacements n'a rien d'obsolète : le builder application moderne le supporte. Le problème n'est pas l'API, c'est ce qu'elle t'incite à en faire.
La solution : la config est une donnée, sers-la comme une donnée
L'idée : ce qui varie par environnement sort du bundle et atterrit dans un fichier statique servi à côté de l'app, chargé au démarrage.
Étape 1 : le contrat typé
La config runtime perd ce qu'un import TypeScript donnait gratuitement : le typage. On le récupère avec une interface et un InjectionToken.
// src/app/core/app-config.ts
import { InjectionToken } from '@angular/core';
export interface AppConfig {
apiUrl: string;
featureFlags: {
newCheckout: boolean;
};
}
export const APP_CONFIG = new InjectionToken<AppConfig>('app.config');
Et le fichier public/config.json, servi statiquement (le dossier d'assets des projets générés depuis Angular 18 ; src/assets/ sur les layouts plus anciens, où le fichier est alors servi à /assets/config.json : adapte l'URL du fetch) :
{
"apiUrl": "https://api.staging.example.com",
"featureFlags": {
"newCheckout": true
}
}
Ce fichier n'est pas dans le bundle. C'est le déploiement qui décide de son contenu : chaque environnement sert le sien.
Étape 2 : charger avant de booter
L'approche la plus robuste : fetcher la config avant bootstrapApplication et fournir le token en useValue.
// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';
import { APP_CONFIG, type AppConfig } from './app/core/app-config';
async function main(): Promise<void> {
const response = await fetch('/config.json');
if (!response.ok) {
throw new Error(`config.json unreachable (HTTP ${response.status})`);
}
const config: AppConfig = await response.json();
await bootstrapApplication(App, {
...appConfig,
providers: [{ provide: APP_CONFIG, useValue: config }, ...appConfig.providers],
});
}
main().catch((err) => console.error('Bootstrap failed', err));
Pourquoi avant le bootstrap ? Parce qu'au moment où Angular construit son injecteur, la config existe déjà. N'importe quel provider peut la consommer, factory comprise. Zéro fenêtre où un inject(APP_CONFIG) rendrait « pas encore chargé » : toute une classe de bugs de timing disparaît.
La consommation est banale :
// src/app/core/products-api.ts
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { APP_CONFIG } from './app-config';
interface Product {
id: string;
name: string;
}
@Injectable({ providedIn: 'root' })
export class ProductsApi {
readonly #http = inject(HttpClient);
readonly #config = inject(APP_CONFIG);
getProducts() {
return this.#http.get<Product[]>(`${this.#config.apiUrl}/products`);
}
}
La variante initializer, si tu tiens au bootstrap standard
Si tu préfères garder main.ts intact, la même chose se fait avec provideAppInitializer (Angular 19+, et sur les versions antérieures avec l'ancien token APP_INITIALIZER ; la migration, piège de l'ordre d'exécution compris, est couverte dans l'article sur provideAppInitializer) :
// src/app/app.config.ts
import { ApplicationConfig, provideAppInitializer, inject } from '@angular/core';
import { ConfigStore } from './core/config-store';
export const appConfig: ApplicationConfig = {
providers: [
provideAppInitializer(() => inject(ConfigStore).load()),
],
};
// src/app/core/config-store.ts
import { Injectable, signal } from '@angular/core';
import type { AppConfig } from './app-config';
@Injectable({ providedIn: 'root' })
export class ConfigStore {
readonly #config = signal<AppConfig | null>(null);
async load(): Promise<void> {
const response = await fetch('/config.json');
if (!response.ok) {
throw new Error(`config.json unreachable (HTTP ${response.status})`);
}
this.#config.set(await response.json());
}
get config(): AppConfig {
const value = this.#config();
if (value === null) {
throw new Error('AppConfig accessed before initialization');
}
return value;
}
}
Ça marche, mais mesure le compromis : la config n'existe pas pendant la construction des providers, et sa présence n'est garantie qu'une fois tous les initializers terminés. Un autre initializer qui tourne en parallèle (ils ne sont pas séquencés) et qui lit la config peut la voir absente ou déjà là, selon le timing. Le throw explicite dans le getter n'est pas de la parano : il remplace un undefined silencieux qui se balade dans l'app par un crash net qui dit quoi corriger.
Deuxième subtilité : le fetch natif plutôt que HttpClient, et c'est un choix. Si un de tes interceptors a besoin de la config (un interceptor d'auth qui lit apiUrl), charger la config via HttpClient crée une dépendance circulaire temporelle : l'interceptor s'exécute pendant la requête qui doit justement produire ce qu'il attend. Le fetch natif court-circuite toute la chaîne d'interception, et le problème avec.
Docker : le même conteneur partout
C'est là que le pattern paie. Ton image Docker embarque le dist/ unique, et le point d'entrée génère config.json depuis les variables d'environnement du conteneur, avec envsubst, l'utilitaire GNU gettext qui remplace chaque ${VAR} d'un fichier par la variable d'environnement du même nom :
# docker-entrypoint.sh
set -eu
: "${API_URL:?}" "${FEATURE_NEW_CHECKOUT:?}"
envsubst < /usr/share/nginx/html/config.template.json \
> /usr/share/nginx/html/config.json
exec nginx -g 'daemon off;'
Le gabarit config.template.json :
{
"apiUrl": "${API_URL}",
"featureFlags": {
"newCheckout": ${FEATURE_NEW_CHECKOUT}
}
}
Deux précautions : chaque valeur substituée doit être un littéral JSON valide (true, pas True), et vérifie explicitement tes variables (: "${API_URL:?}") avant envsubst, qui remplace une variable absente par une chaîne vide, sans erreur (set -u n'y voit rien) : tu servirais un JSON invalide, découvert au bootstrap.
Une seule image, promue de staging en prod en changeant deux variables d'environnement. Ton front rejoint enfin le reste de l'infra.
Les trois pièges qui t'attendent
Le cache. config.json a une URL stable, donc navigateurs et CDN vont le mettre en cache. Après un changement de config, une partie de tes utilisateurs tourne sur l'ancienne pendant des heures. Sers-le avec Cache-Control: no-cache : le navigateur revalide à chaque chargement de l'app (304 si rien n'a bougé), et une config modifiée est visible au refresh suivant. C'est d'abord une config serveur (nginx, CDN), pas Angular : le premier truc à vérifier quand « la config ne se met pas à jour ».
La validation. Un import TypeScript était vérifié à la compilation ; un response.json() est un any que tu crois AppConfig sur parole. Une clé renommée dans config.template.json mais pas dans l'interface, et tu as un undefined qui se propage sans bruit. Valide au chargement : une fonction de validation (un type guard TypeScript) de dix lignes suffit pour trois clés, et au-delà c'est le boulot d'un schéma Zod. Échouer au bootstrap avec un message clair coûte moins cher qu'un flag undefined en prod.
Le SSR. Si ton app fait du rendu côté serveur, ce point est structurant. Variante main.ts : le serveur passe par main.server.ts, jamais par main.ts, donc APP_CONFIG manque et le rendu échoue en NG0201 (aucun provider). Variante initializer : en SSR à l'exécution, le fetch relatif explose, le fetch de Node exigeant une URL absolue (TypeError: Failed to parse URL from /config.json). Le remède, pour le SSR à l'exécution : fournir le token dans app.config.server.ts, config lue en direct (variables d'environnement ou fichier), pas par HTTP. Reste le prérendu (SSG), le plus vicieux : une fois le token fourni, rien n'explose, mais aucun remède serveur ne tient, parce que toute config lue côté serveur l'est au moment du build, quelles que soient variante et source (fetch, environnement, fichier). Ce qui en dépend dans une route prérendue est figé dans le HTML : « un build par environnement », recréé sans aucune erreur pour te le signaler. Rends ce contenu côté client uniquement, ou ne prérends pas ces routes.
Ce qui reste légitimement build-time
Ne jette pas tout dans config.json pour autant. La ligne de partage : runtime si ça varie par environnement, build-time si ça change le code produit.
Restent au build : options du compilateur, budgets, sourcemaps, optimisations. Et si un jour tu veux qu'un flag retire réellement du code du bundle (une feature entière absente du JavaScript livré, pas juste masquée à l'exécution), il te faut une constante connue du bundler, via l'option define du builder application : un environment.ts, même avec un const à false, laisse le code dans le bundle. C'est un choix explicite : tu échanges le build unique contre l'élimination du code mort, mais en le sachant.
Before / after
Avant :
- 1 environnement = 1 entrée
configurations+ 1 fichierenvironment.X.ts+ 1 passe de CI - l'artefact prod n'est pas l'artefact validé en staging
- changer une URL d'API = commit + build + redéploiement complet
- la config est noyée dans le JavaScript minifié : pour savoir ce qui tourne en prod, il faut fouiller le bundle
Après :
- 1 build, N environnements ; ajouter un environnement = ajouter un
config.json - l'artefact promu en prod est octet pour octet celui que la QA a validé
- changer une URL d'API = éditer un fichier statique (ou une variable du conteneur) + refresh
curl https://ton-app/config.jsonte dit exactement ce qui tourne
Récap actionnable
fileReplacementscompile ta config dans le bundle. Un environnement par build, artefact non promu, tentation du secret. L'API n'est pas cassée, le pattern l'est.- Sors ce qui varie par environnement dans un
config.jsonservi à côté de l'app, généré par le déploiement (envsubstdans le point d'entrée Docker). - Type le contrat avec une interface +
InjectionToken<AppConfig>, et valide au chargement : un JSON runtime n'a aucune garantie compilateur. - Charge avant
bootstrapApplicationquand tu peux : la config existe avant l'injecteur, zéro bug de timing. La varianteprovideAppInitializermarche aussi, mais impose de gérer « pas encore chargé » et le parallélisme. - Charge en
fetchnatif, pas enHttpClient, pour ne pas passer dans tes propres interceptors. - Sers
config.jsonenCache-Control: no-cache, sinon tes changements de config attendront l'expiration du cache. - En SSR à l'exécution, fournis la config côté serveur toi-même (
app.config.server.ts, lue en direct depuis l'environnement) :main.tsn'y tourne pas et lefetchrelatif y explose. Le prérendu, lui, fige la config du build dans le HTML quoi que tu fasses côté serveur : contenu dépendant de la config rendu côté client, ou pas de prérendu sur ces routes. - Garde au build ce qui change le code produit (optimisations, code à éliminer du bundle). Le reste est de la donnée de déploiement.
Le test de réussite : si demain on te demande un environnement de démo, la réponse doit être « je pose un config.json », pas « je relance un pipeline ».