Déploiement Angular en production
TL;DR — Un déploiement Angular « propre » repose sur trois piliers : un build production (esbuild builder, tree-shaking, minification, hash sur les noms de fichiers), une stratégie de cache HTTP (assets
immutable1 an, HTMLno-store), et des secrets / configs gérés correctement (build-time pour les défauts, runtime pour ce qui change par environnement). Pour les apps SPA pures : déploiement statique (Vercel, Netlify, Cloudflare Pages, S3+CloudFront, nginx Docker). Pour les apps SSR : Node host (Render, Railway, Cloud Run, Lambda) ou edge (Cloudflare Workers, Vercel Edge). Toujours uploader les source maps à Sentry sans les servir publiquement. Désactiver Angular DevTools en prod (ou les rendre opt-in). Documenter le feature flag pipeline et l'A/B testing pour éviter le couplage code/déploiement.
🧠 Mental model — ASCII + analogie
L'analogie : déployer une SPA Angular = livrer un colis scellé avec étiquette de version. Le navigateur télécharge le colis (bundle JS), le décompresse (parse + execute) et l'exécute dans son sandbox. Tout ce qui change par environnement (URL API, feature flags, tenant) doit être livré séparément du colis principal, sinon il faut refabriquer le colis pour chaque environnement.
┌───────────────────────────────────────────────────────────────┐
│ ng build --configuration production │
│ ────────────────────────────────────── │
│ esbuild builder │
│ ├─ TypeScript → ES2022 │
│ ├─ Tree-shaking (drop code non utilisé) │
│ ├─ Minification (terser/esbuild) │
│ ├─ Code splitting (lazy routes, @defer) │
│ ├─ Hash dans noms de fichiers (main.A1B2C3.js) │
│ ├─ Critical CSS inlined dans index.html │
│ └─ Source maps séparées (.map non servies publiquement) │
│ │
│ dist/mon-app/ │
│ browser/ │
│ index.html ← HTML, < 5 KB, no-cache │
│ main.A1B2C3.js ← bundle principal, immutable 1 an │
│ polyfills.js, runtime.js, chunks/... │
│ assets/ │
│ *.map ← upload Sentry, NE PAS servir │
│ server/ (si SSR) │
│ server.mjs │
└───────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────┐
│ CDN / Edge / Origin │
│ │
│ GET /index.html │
│ ├─ Cache-Control: no-store │
│ └─ ETag │
│ │
│ GET /main.A1B2C3.js │
│ └─ Cache-Control: │
│ public, max-age=31536000│
│ immutable │
└──────────────────────────────┘🛠️ Code minimal (ts + html)
Build de production
# Build par défaut (esbuild depuis v17, application builder)
ng build
# Avec config explicite
ng build --configuration=production --base-href=/
# Pour SSR (génère browser/ + server/)
ng build # si "outputMode": "server" dans angular.json// angular.json — section configurations
{
"configurations": {
"production": {
"budgets": [
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
{ "type": "anyComponentStyle", "maximumWarning": "4kB" }
],
"outputHashing": "all",
"sourceMap": { "scripts": true, "styles": true, "hidden": true, "vendor": false },
"optimization": {
"scripts": true,
"styles": { "minify": true, "inlineCritical": true },
"fonts": { "inline": true }
},
"namedChunks": false,
"fileReplacements": [
{ "replace": "src/environments/environment.ts", "with": "src/environments/environment.prod.ts" }
]
}
}
}sourceMap.hidden: true génère les .map mais sans référence dans le bundle (pas de //# sourceMappingURL). Cela permet de les uploader à Sentry pour symboliser les stack traces sans les exposer aux utilisateurs.
Dockerfile multi-stage (SPA + nginx)
# ----- Stage 1 : build -----
FROM node:22-alpine AS build
WORKDIR /app
# Copier d'abord les fichiers de dépendances pour profiter du cache Docker
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build -- --configuration=production
# ----- Stage 2 : runtime -----
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/mon-app/browser /usr/share/nginx/html
# Healthcheck simple
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -q -O /dev/null http://localhost/ || exit 1
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]# nginx.conf
server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Compression
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
brotli on; # si module disponible
brotli_types text/plain text/css application/javascript application/json;
# Assets versionnés : cache 1 an immutable
location ~* \.(js|css|woff2|woff|ttf|otf|svg|png|jpg|jpeg|webp|avif|ico)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# index.html : jamais en cache
location = /index.html {
add_header Cache-Control "no-store, must-revalidate";
expires 0;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Healthcheck
location /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
# Sécurité de base
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# CSP : à personnaliser selon les sources autorisées
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.monsite.com" always;
}Déploiement Vercel / Netlify / Cloudflare Pages
Pour une SPA pure, ces plateformes hébergent gratuitement le dossier dist/mon-app/browser avec CDN mondial.
// vercel.json
{
"buildCommand": "ng build --configuration=production",
"outputDirectory": "dist/mon-app/browser",
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
],
"headers": [
{
"source": "/(.*\\.(js|css|woff2|png|jpg|webp|svg))",
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
},
{
"source": "/index.html",
"headers": [{ "key": "Cache-Control", "value": "no-store" }]
}
]
}# netlify.toml
[build]
command = "ng build --configuration=production"
publish = "dist/mon-app/browser"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
[[headers]]
for = "/*.js"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
[[headers]]
for = "/index.html"
[headers.values]
Cache-Control = "no-store"SSR sur Node host (Cloud Run)
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/dist/mon-app ./dist/mon-app
COPY --from=build /app/node_modules ./node_modules
ENV NODE_ENV=production
ENV PORT=8080
EXPOSE 8080
CMD ["node", "dist/mon-app/server/server.mjs"]gcloud run deploy mon-app \
--image gcr.io/PROJECT/mon-app:v1 \
--region europe-west1 \
--allow-unauthenticated \
--cpu 1 --memory 512Mi \
--min-instances 0 --max-instances 20 \
--set-env-vars NODE_ENV=productionVariables d'environnement : build-time vs runtime
Build-time — figées dans le bundle au build (via environment.ts ou process.env injecté par esbuild) :
// src/environments/environment.prod.ts
export const environment = {
production: true,
apiUrl: 'https://api.monsite.com',
sentryDsn: 'https://[email protected]/123',
};Inconvénient : il faut rebuilder pour chaque environnement (dev/staging/prod).
Runtime — chargées au démarrage de l'app, partagées entre environnements :
// public/config.json (servi statiquement, modifiable par env)
{ "apiUrl": "https://api.monsite.com", "tenantId": "acme" }// app.config.ts — APP_INITIALIZER (ou provideAppInitializer en v19+)
import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core';
import { HttpClient, provideHttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { AppConfigService } from './app-config.service';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideAppInitializer(async () => {
const http = inject(HttpClient);
const cfg = await firstValueFrom(http.get<any>('/config.json'));
inject(AppConfigService).set(cfg);
}),
],
};// app-config.service.ts
import { Injectable, signal } from '@angular/core';
export interface AppConfig { apiUrl: string; tenantId: string; }
@Injectable({ providedIn: 'root' })
export class AppConfigService {
private cfg = signal<AppConfig | null>(null);
readonly config = this.cfg.asReadonly();
set(c: AppConfig) { this.cfg.set(c); }
}Variante : injection via window.__APP_CONFIG__ dans index.html (rapide, pas de fetch supplémentaire). Pratique pour Docker : on remplace index.html au démarrage avec envsubst.
Piège staff sur
provideAppInitializer: la factory doit retourner laPromise(ou unObservableque tu convertis) — sinon Angular bootstrappe l'app avant que la config soit chargée, et le premier composant qui litRUNTIME_CONFIGvoitnull. AvecfirstValueFrom(...), retourne bien le résultat du.then(). Et garde un fallback : si le fetch/config.jsonéchoue (CDN down, 503), ne bloque pas le bootstrap indéfiniment — applique une config par défaut hard-codée et logge l'incident. UnAPP_INITIALIZERqui ne résout jamais = écran blanc permanent, le pire mode de défaillance front.
🎯 Patterns courants
1. Feature flags
Centraliser les flags dans un service. Source possible : LaunchDarkly, Unleash, ConfigCat, ou un simple endpoint JSON.
@Injectable({ providedIn: 'root' })
export class FeatureFlagService {
private flags = signal<Record<string, boolean>>({});
readonly is = (key: string) => computed(() => this.flags()[key] === true);
load(flags: Record<string, boolean>) { this.flags.set(flags); }
}@if (ff.is('newCheckout')()) {
<app-new-checkout />
} @else {
<app-legacy-checkout />
}Le flag est récupéré au runtime (via provideAppInitializer) avec un fallback hard-codé en cas d'échec réseau. Ne jamais déployer un flag activé pour 100 % sans rollout progressif.
2. A/B testing
Deux approches :
- Côté client : SDK (Optimizely, GrowthBook) qui décide en JS quel variant montrer. Inconvénient : flicker possible au load.
- Côté edge (Cloudflare Workers, Vercel Edge) : décision serveur avant servir l'HTML, basée sur cookie utilisateur. Pas de flicker.
@if (variant() === 'B') {
<app-cta-version-b />
} @else {
<app-cta-version-a />
}3. Source maps + Sentry
# Build avec source maps cachées
ng build --configuration=production
# Upload à Sentry
sentry-cli sourcemaps inject ./dist/mon-app/browser
sentry-cli sourcemaps upload \
--org mon-org --project mon-app \
--release "$(git rev-parse HEAD)" \
./dist/mon-app/browser
# Supprimer les .map avant déploiement (les utilisateurs n'y ont pas besoin)
find ./dist/mon-app/browser -name "*.map" -delete// sentry init côté app
import * as Sentry from '@sentry/angular';
Sentry.init({
dsn: environment.sentryDsn,
release: environment.commitSha,
environment: environment.production ? 'production' : 'staging',
tracesSampleRate: 0.1,
integrations: [Sentry.browserTracingIntegration()],
});4. Angular DevTools en production
Par défaut, Angular DevTools ne fonctionne pas en mode production. C'est voulu : pas de surface d'attaque, pas d'overhead.
Précision importante (v17+) : avec le bootstrap standalone (bootstrapApplication), on n'appelle plus enableProdMode() manuellement. Le mode dev/prod est déterminé par le builder : ng build (sans --configuration=development) supprime via tree-shaking tout le code derrière ngDevMode, ce qui désactive DevTools et les vérifications de dev. Le vieux pattern if (environment.production) enableProdMode() appartient à bootstrapModule (NgModules legacy). Donc « débuger en prod » ne se fait pas en désactivant enableProdMode — il faut un build dédié en configuration development ou un build prod avec une cible spéciale.
Approche correcte pour un incident critique : produire un artefact de debug séparé, gardé derrière une route/IP whitelist, jamais le bundle prod par défaut :
# Build de debug : garde ngDevMode → DevTools + assertions actives, source maps inline
ng build --configuration=development --output-path=dist/debug// Servir ce bundle uniquement derrière un garde côté infra (nginx/Cloudflare Access)
// location /__debug/ { ... auth_request /admin-auth; root /usr/share/nginx/debug; }À utiliser avec parcimonie et derrière une condition stricte (cookie admin, IP whitelist, header signé). Ne jamais exposer le bundle de debug publiquement : il contient les assertions de dev, est plus lent, et expose la structure interne.
5. CDN cache headers — règles d'or
| Type de fichier | Cache-Control |
|---|---|
index.html | no-store ou max-age=0, must-revalidate |
*.js, *.css (hashés) | public, max-age=31536000, immutable |
assets/img/*.png | public, max-age=31536000, immutable (si hashés) |
manifest.webmanifest | public, max-age=3600 (1 h) |
ngsw-worker.js | no-cache (sinon mises à jour bloquées) |
ngsw.json | no-cache |
| API JSON | private, no-cache (sauf cas spéciaux) |
6. Pipeline CI/CD type
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run test -- --watch=false --browsers=ChromeHeadless
- run: npm run build -- --configuration=production
- name: Upload source maps to Sentry
run: |
npx sentry-cli sourcemaps upload \
--org ${{ secrets.SENTRY_ORG }} \
--project mon-app \
--release $GITHUB_SHA \
./dist/mon-app/browser
- name: Remove .map files
run: find ./dist/mon-app/browser -name "*.map" -delete
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
projectName: mon-app
directory: dist/mon-app/browser🔄 Versions — Angular 16 → 20
- v16 —
esbuildbuilder en preview (@angular-devkit/build-angular:browser-esbuild). Webpack reste par défaut. - v17 — Application builder (esbuild + vite dev server) devient la valeur par défaut pour nouveaux projets. Builds 2-4 × plus rapides. Output split en
browser/etserver/. - v18 — Stabilisation de l'application builder. Plus de bugs lors du
ng update --next. Support natif des dépendances ESM-only. - v19 —
outputModeremplace les anciens flagsprerender(optionstatic,server).provideAppInitializerremplaceAPP_INITIALIZER(avec inject). - v20 — Application builder seul (webpack builder officiellement déprécié, retiré du nouveau template). Optimisations Brotli/AVIF natives. Meilleurs warnings sur les budgets. Zoneless stable (
provideZonelessChangeDetection) : impacte le déploiement car le rendu d'un flux (tokens IA, WebSocket) ne déclenche plus de CD implicite — il faut écrire dans des signals, ce qui rend les UI streaming plus prévisibles en prod (pas detick()global non maîtrisé). - v21+ — Zoneless par défaut sur les nouveaux projets (fin du polyfill
zone.js→ bundle plus léger, démarrage plus rapide). Vérifie en migration que toute mutation hors signal (callbacks tiers,setInterval, handlers SSE) passe bien par un signal ou unChangeDetectorRef, sinon le DOM ne se met plus à jour en prod alors qu'il marchait avec zone.js.
⚠️ Pitfalls — 6-10
- Source maps servies en prod — un attaquant peut reconstituer votre code. Toujours
sourceMap.hidden: trueou supprimer les.mapavant déploiement. index.htmlmis en cache long terme — les utilisateurs ne reçoivent jamais les nouvelles versions. Toujoursno-storesur HTML.- Assets hashés mais cache court — perte de performance, le CDN refetch sans raison. Toujours
immutable1 an sur les assets hashés. - Configs en
environment.tspour tous les envs — il faut rebuilder pour chaque, impossible de promouvoir un build de staging vers prod sans risque. Préférer le runtime config. - Builder mal configuré → bundle resté en mode dev — avec le bootstrap standalone (
bootstrapApplication, v17+), on n'appelle plusenableProdMode(): c'est le builder qui tree-shake le codengDevModequand on build sans--configuration=development. Le piège moderne n'est donc pas unenableProdMode()oublié, mais un build lancé avec la mauvaise configuration (ou unfileReplacements/optimizationdésactivé), qui laisse les assertions de dev et les warnings en prod. Vérifier que la CI build bien en--configuration=production. - SPA fallback manquant —
nginxne renvoie pasindex.htmlpour/products/42, donnant un 404 au F5. Toujours configurertry_files $uri /index.html. - CORS oublié pour les fonts — si on sert les fonts depuis un sous-domaine CDN, ajouter
Access-Control-Allow-Origin: *sur les fichiers.woff2. - Secrets en bundle — ne jamais mettre une clé API privée dans
environment.ts. Tout ce qui est dans le bundle est public. Backend uniquement. - Pas de healthcheck — orchestrateur (K8s, Cloud Run) sait pas si l'app vit. Toujours exposer
/healthz(statique) ou/api/health(SSR). - CSP trop laxe ou trop strict —
unsafe-inlinepartout = inutile. Bloquerscript-src 'self'casse les analytics. Bien itérer enreport-onlyavant d'enforcer.
7. Déployer une UI Angular qui consomme des agents IA en streaming
Si l'app Angular consomme un backend qui stream des tokens LLM (chat, copilote, agent tool-use), le déploiement introduit une classe de bugs invisible en SPA classique : le buffering au niveau du proxy/CDN casse le streaming. Un nginx ou un CDN qui bufferise la réponse va attendre la fin du flux avant de le relayer — l'utilisateur voit un spinner pendant 20 s puis tout le texte d'un coup, au lieu d'un rendu token par token. C'est le défaut #1 en prod sur ce type d'UI.
Côté infra (proxy / edge), pour un endpoint SSE text/event-stream :
# Endpoint de streaming agent : NE PAS bufferiser
location /api/agent/stream {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off; # critique : relai immédiat des chunks
proxy_cache off;
proxy_read_timeout 3600s; # un agent tool-use peut durer minutes
chunked_transfer_encoding off;
add_header X-Accel-Buffering no; # désactive le buffering même si un proxy amont l'ignore
}Pièges de déploiement spécifiques aux UI agentiques :
| Piège | Symptôme en prod | Fix |
|---|---|---|
| Proxy/CDN bufferise SSE | Tokens arrivent en bloc, pas en flux | proxy_buffering off + X-Accel-Buffering: no, ou WebSocket |
| Timeout edge trop court | L'agent est coupé à 30 s en plein raisonnement | Cloudflare Workers: limites CPU; Vercel: maxDuration; préférer un Node host pour les longs streams |
Pas d'AbortController | L'utilisateur quitte la page, le backend continue à brûler des tokens (= du cash) | AbortController côté client ET propagation du cancel côté serveur (voir ci-dessous) |
| Gzip sur SSE | Le proxy attend un buffer minimal avant de compresser → latence | Exclure text/event-stream du gzip_types |
| HTML SSR cache un flux | Une page SSR qui inline une réponse agent se fait cacher avec un contenu obsolète | Cache-Control: no-store sur toute route qui contient une sortie IA |
Côté Angular (zoneless, signals), rendu token-par-token avec Stop :
// agent-chat.service.ts — stream via fetch ReadableStream, AbortController, buffer rAF-coalescé
import { Injectable, signal } from '@angular/core';
interface AgentMessage { role: 'user' | 'assistant'; text: string; done: boolean; }
@Injectable({ providedIn: 'root' })
export class AgentChatService {
readonly messages = signal<AgentMessage[]>([]);
private controller: AbortController | null = null;
async send(prompt: string) {
this.controller?.abort(); // annule un stream précédent en cours
this.controller = new AbortController();
this.messages.update((m) => [...m, { role: 'user', text: prompt, done: true },
{ role: 'assistant', text: '', done: false }]);
const res = await fetch('/api/agent/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
signal: this.controller.signal, // disconnect → le backend reçoit l'abort
});
if (!res.body) throw new Error('no stream');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let pending = '';
let frame: number | null = null;
// rAF-coalescing : on accumule les tokens et on flush 1×/frame (évite N writes de signal/frame sous zoneless)
const flush = () => {
frame = null;
if (!pending) return;
const chunk = pending; pending = '';
this.messages.update((m) => {
const last = m[m.length - 1];
return [...m.slice(0, -1), { ...last, text: last.text + chunk }];
});
};
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
pending += decoder.decode(value, { stream: true });
if (frame === null) frame = requestAnimationFrame(flush);
}
} finally {
flush();
this.messages.update((m) => {
const last = m[m.length - 1];
return [...m.slice(0, -1), { ...last, done: true }];
});
}
}
stop() { this.controller?.abort(); } // bouton Stop → annule client + serveur
}Le AbortController est le point clé en prod : quand l'utilisateur clique Stop ou ferme l'onglet, fetch annule la connexion, le backend (NestJS) reçoit l'événement close/aborted sur la requête et coupe son propre appel SDK (anthropic.messages.stream({ ... }, { signal })). Sans ce câblage de bout en bout, chaque abandon utilisateur laisse une génération orpheline qui continue de consommer des tokens facturés. Côté modèles, l'app vise typiquement claude-haiku-4-5 pour les interactions à faible latence (autocomplete, classification) et claude-sonnet-4-6 / claude-opus-4-8 pour le raisonnement agentique — un détail de déploiement : route ces appels via un client LLM injecté côté backend (jamais une clé dans le bundle Angular, cf. pitfall #8).
Règle staff : une UI agentique change deux invariants de déploiement. (1) Toute route portant une sortie IA est
no-store(le contenu est non déterministe, le cacher = bug). (2) Le streaming exige un chemin réseau non bufferisé de bout en bout — teste-le en prod réelle via le CDN, pas en local où il n'y a pas de proxy. Un stream qui marche enng serveet arrive en bloc en prod = un proxy qui bufferise.
🏛️ Comment un staff engineer raisonne le déploiement
Le piège invisible : le cache de l'index et la fenêtre de skew
Le défaut #1 d'une SPA hashée n'est pas le build, c'est la fenêtre de version skew entre deux déploiements. Scénario : un user a chargé index.html à la version N (qui référence main.AAA.js). Vous déployez la version N+1. Si le CDN a purgé main.AAA.js, son prochain lazy-chunk (@defer, loadComponent) renvoie un 404 → ChunkLoadError, et l'app crash en plein parcours.
Trois lignes de défense, par ordre de robustesse :
| Stratégie | Coût | Robustesse | Quand |
|---|---|---|---|
| Garder N anciennes versions d'assets sur le CDN (TTL long, pas de purge) | Stockage | Bonne | Toujours, baseline |
Intercepter ChunkLoadError → location.reload() une fois | 10 lignes | Très bonne | SPA avec lazy routes |
SSR/SWR + app version header → bannière « nouvelle version dispo » | Moyen | Excellente | UX longue session |
// Garde-fou ChunkLoadError : recharge une seule fois pour récupérer le bon index
window.addEventListener('error', (e) => {
const isChunkError = /ChunkLoadError|Loading chunk \d+ failed/.test(e.message ?? '');
const alreadyReloaded = sessionStorage.getItem('chunk-reload') === '1';
if (isChunkError && !alreadyReloaded) {
sessionStorage.setItem('chunk-reload', '1');
location.reload();
}
});Règle staff : sur une SPA hashée, n'effacez jamais immédiatement les assets de la version précédente. Gardez au minimum les N-2 dernières releases accessibles. C'est gratuit (stockage objet) et ça élimine la classe entière des
ChunkLoadErrorpost-deploy.
Stratégies de release : tradeoffs
| Stratégie | Rollback | Risque blast radius | Coût infra | Idéal pour |
|---|---|---|---|---|
| Recreate (stop/start) | Lent, downtime | Total | Nul | Tooling interne |
| Rolling (K8s default) | Moyen (rollout undo) | Partiel pendant la transition | Faible | API/SSR Node |
| Blue/Green | Instantané (swap DNS/LB) | Nul si testé | 2× pendant le swap | Releases critiques |
| Canary (5% → 50% → 100%) | Rapide (couper le canary) | Limité au % exposé | Routing edge | Haut trafic, métriques fiables |
Pour une SPA statique sur Vercel/Cloudflare/Netlify, chaque déploiement est un artefact immuable adressable : le « rollback » = re-pointer l'alias de prod vers le déploiement précédent (1 commande, < 30 s, zéro rebuild). C'est le luxe du statique — exploitez-le : votre stratégie de rollback est « promouvoir l'ancien deployment id », pas « revert + rebuild + redeploy » (qui prend 5-10 min, pile au pire moment).
Observabilité front : les 4 signaux à instrumenter
Un déploiement n'est « fini » que quand vous pouvez répondre à « est-ce que ça marche pour les vrais users ? » sans SSH.
- Erreurs — Sentry avec
release= SHA git (sans ça, impossible d'attribuer une régression à un déploiement). - Web Vitals (RUM) — LCP, INP, CLS du terrain réel, pas Lighthouse en labo.
web-vitals→ Sentry/analytics, segmenté parrelease. - Adoption de version — quel % du trafic tourne sur la nouvelle release ? (header
x-app-versionloggé côté edge). Sans ça, vous ne savez pas si votre canary est réellement servi. - Health/uptime synthétique — un check externe (UptimeRobot, Checkly) qui charge la home et un parcours clé toutes les minutes.
// Reporter les Web Vitals réels, taggés par release — instrumentation runtime
import { onLCP, onINP, onCLS } from 'web-vitals';
const release = (window as any).__APP_VERSION__ ?? 'unknown';
const report = (metric: { name: string; value: number }) =>
navigator.sendBeacon('/rum', JSON.stringify({ ...metric, release }));
onLCP(report);
onINP(report);
onCLS(report);Le SEUL critère qui compte : « puis-je rollback en < 1 min sans rebuild ? »
Si la réponse est non, votre pipeline est fragile, peu importe sa sophistication. Tout le reste (canary, feature flags, observabilité) sert à détecter vite ; le rollback instantané sert à réparer vite. Optimisez le MTTR (mean time to recovery) avant le MTBF.
🧪 Testing
- Smoke tests post-déploiement : Playwright ou curl + assertions sur le HTML de la home (présence du
<title>, du bundle main). - Bundle size monitoring :
bundlewatchousize-limiten CI, échec si dépasse seuil. - Lighthouse CI : score Performance, SEO, PWA, A11y.
# Smoke test minimal
curl -fsS https://monsite.com | grep -q 'Mon App' || exit 1
curl -fsS https://monsite.com/healthz | grep -q 'ok' || exit 1# .lighthouserc.yml
ci:
collect:
url: ['https://staging.monsite.com/', 'https://staging.monsite.com/pricing']
assert:
assertions:
categories:performance: ['warn', { minScore: 0.9 }]
categories:accessibility: ['error', { minScore: 0.95 }]🎬 Cas d'usage concrets
Scénario 1 — SaaS RH sur Vercel
Contexte : éditeur SaaS RH multi-tenant, équipe de 12 devs Angular, monorepo Nx avec apps/web (SPA) + apps/portail-public (SSR Angular pour offres indexables). Cible : déploiement preview par PR, prod sur main, rollback en 30s. Approche Vercel : repo Git connecté, build command nx build web côté SPA et nx build portail-public --configuration=production côté SSR. Chaque PR génère une URL preview <branch>.<projet>.vercel.app partagée aux PO via le commentaire bot. Le portail SSR tourne en Vercel Edge Function (Angular SSR avec adapter Vercel) — TTFB médian 80ms en Europe. Les secrets (API tokens, DSN Sentry) sont configurés dans le dashboard Vercel par environnement (Preview / Production). Les redirections legacy /jobs/* → /offres/* configurées dans vercel.json. Rollback en un clic sur l'UI (chaque déploiement est conservé immuable). Cache: HTML SSR avec Cache-Control: s-maxage=60, stale-while-revalidate=300, assets hashés immutable, max-age=31536000.
Scénario 2 — Cabinet juridique sur Kubernetes souverain
Contexte : cabinet juridique grand compte sous contrainte légale de souveraineté des données (RGPD strict, données sensibles client). Hébergement obligatoire chez un cloud français (OVHcloud, Outscale, ou cloud privé interne) avec certification SecNumCloud. Approche Kubernetes : cluster managé OVHcloud + Helm chart custom pour l'app. Image Docker multi-stage : étape builder (Node 20 + npm ci && ng build) puis étape runtime (nginx-unprivileged servant le dist/). Dockerfile minimal (50 lignes), image finale ~25 MB. Le manifest k8s définit deploy + service + ingress (nginx-ingress) + HPA (autoscale 2-10 pods selon CPU). Secrets gérés via Sealed Secrets (chiffrés dans le repo Git, déchiffrés par le controller). Pipeline GitLab CI : lint → test → build image → trivy scan → push registry interne → helm upgrade --install staging (auto sur main) et helm upgrade prod (manuel avec approbation). Observabilité : Prometheus + Grafana + Loki pour les logs nginx + Sentry self-hosted pour les erreurs front. Conformité SecNumCloud documentée et auditée annuellement.
Scénario 3 — E-commerce mode sur Cloudflare CDN
Contexte : retailer mode multi-pays UE, trafic 5M visites/mois avec pics Black Friday × 10. Objectifs : latence < 100ms partout, coût marginal proche zéro sur les pics. Approche Cloudflare : SPA Angular SSR déployée en Cloudflare Workers (adapter Angular SSR vers Workers, runtime V8 isolates). Les assets statiques sont servis depuis Cloudflare Pages avec cache CDN edge dans 300+ POPs. Chaque page produit a un TTL CDN de 5 minutes avec stale-while-revalidate=3600 qui fait que 99% des requêtes ne touchent jamais l'origin. Les images produits passent par Cloudflare Images (resize on the fly, WebP/AVIF auto, polish). Le runtime Worker exécute le SSR Angular pour les pages dynamiques (mon-compte, recherche) — coût ~$5/M requêtes vs serveurs Node dédiés. Sécurité : WAF Cloudflare (rules OWASP), bot management, rate limiting sur /api/login, page rules pour bloquer /admin aux IP non-corporate. Black Friday 2025 : pic à 12k req/s absorbé sans aucune action manuelle, factures Cloudflare lissées sur l'année.
🛠️ Exemple end-to-end
Use case : pipeline complet de déploiement SaaS RH sur Vercel (preview PR + prod) avec runtime config, source maps Sentry, et health checks.
// vercel.json
{
"buildCommand": "pnpm nx build web --configuration=production",
"outputDirectory": "dist/apps/web/browser",
"framework": null,
"rewrites": [
{ "source": "/((?!api|assets|.*\\..*).*)", "destination": "/index.html" }
],
"headers": [
{
"source": "/(.*)\\.(js|css|woff2|jpg|png|webp|avif)",
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
},
{
"source": "/index.html",
"headers": [{ "key": "Cache-Control", "value": "no-store, must-revalidate" }]
},
{
"source": "/(.*)",
"headers": [
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
]
}
]
}// runtime-config.ts — config par environnement chargée au bootstrap
import { InjectionToken, provideAppInitializer, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
export interface RuntimeConfig {
readonly apiBase: string;
readonly sentryDsn: string;
readonly featureFlags: Record<string, boolean>;
readonly tenantBranding: 'default' | 'enterprise';
}
export const RUNTIME_CONFIG = new InjectionToken<RuntimeConfig>('RUNTIME_CONFIG');
let configCache: RuntimeConfig | null = null;
export function provideRuntimeConfig() {
return [
provideAppInitializer(() => {
const http = inject(HttpClient);
return firstValueFrom(http.get<RuntimeConfig>('/runtime-config.json'))
.then((cfg) => { configCache = cfg; });
}),
{ provide: RUNTIME_CONFIG, useFactory: () => configCache! },
];
}// app.config.ts
import { ApplicationConfig, ErrorHandler, isDevMode } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter, withInMemoryScrolling } from '@angular/router';
import * as Sentry from '@sentry/angular';
import { routes } from './app.routes';
import { provideRuntimeConfig, RUNTIME_CONFIG } from './runtime-config';
import { authInterceptor } from './auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'top' })),
provideRuntimeConfig(),
{
provide: ErrorHandler,
useFactory: () => Sentry.createErrorHandler({ showDialog: false }),
},
Sentry.TraceService,
],
};# .github/workflows/deploy.yml
name: deploy
on:
push:
branches: [main]
pull_request:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm nx affected -t lint test build --base=origin/main
- name: Upload sourcemaps to Sentry
if: github.ref == 'refs/heads/main'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: |
npx @sentry/cli releases new "${{ github.sha }}"
npx @sentry/cli releases files "${{ github.sha }}" upload-sourcemaps ./dist/apps/web/browser --rewrite --strip-prefix ./
npx @sentry/cli releases finalize "${{ github.sha }}"
find ./dist -name "*.map" -delete
- name: Deploy Vercel
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: npx vercel deploy --prebuilt --token=$VERCEL_TOKEN ${{ github.ref == 'refs/heads/main' && '--prod' || '' }}// healthcheck.ts (route simple pour load balancer / Vercel cron)
export const HEALTH_ROUTE = {
path: 'health',
loadComponent: () =>
import('./health.component').then((m) => m.HealthComponent),
};Pipeline complet : tests + build affected via Nx, source maps uploadées à Sentry puis supprimées du dist (jamais servies en public), deploy preview ou prod selon la branche, runtime config rechargée par environnement sans rebuild, headers de cache et sécurité au niveau CDN.
🔁 Quand utiliser / éviter
Statique (CDN + nginx ou Vercel/Netlify) : SPA pure, audience B2C, SEO optionnel ou prerendering suffisant, équipe préfère la simplicité.
SSR Node host (Cloud Run, Render) : SEO + dynamic content, sessions, contrôle total backend, équipe ops familière avec containers.
SSR Edge (Cloudflare Workers) : audience globale, latence < 100 ms partout, peu de logique backend, prêt à composer avec les limites edge (pas de Node natif).
Docker maison + nginx : on-premise, ITOps internes, compliance, ou simplement contrôle 100 % du stack.
🏋️ Exercices
Progression : on construit un pipeline, on le rend production-grade, puis on le casse et on le répare. Chaque exercice suppose le précédent fait.
Exercice 1 — Build prod minimal + audit des fuites (implement)
Objectif : produire un build prod et prouver qu'il ne fuite ni secret ni source map exploitable.
Buildez votre app en --configuration=production, puis écrivez un script de garde qui échoue le CI si : (a) un fichier .map est servi avec une référence sourceMappingURL non-hidden, (b) une chaîne ressemblant à un secret (sk_, AKIA, un JWT) apparaît dans un .js du dist/, (c) le budget initial dépasse le seuil.
Indice / Solution
sourceMap.hidden: true dans angular.json ; en CI : grep -rE 'sk_live|AKIA[0-9A-Z]{16}|eyJ[A-Za-z0-9_-]+\.eyJ' dist/ && exit 1. Pour les .map non-hidden : grep -rl 'sourceMappingURL=.*\.map' dist/*.js. Budgets : déjà gérés par budgets dans angular.json (l'erreur fait échouer ng build). Bonus : source-map-explorer dist/**/*.js pour visualiser ce qui gonfle le bundle.
Exercice 2 — Runtime config + Docker sans rebuild par environnement (implement → prod-grade)
Objectif : un seul artefact Docker promu dev → staging → prod, l'API URL changeant uniquement par variable d'environnement au démarrage du conteneur.
Servez un config.json généré au lancement du conteneur (entrypoint shell) à partir de variables d'env (API_URL, TENANT_ID), chargé par provideAppInitializer. Vérifiez : docker run -e API_URL=https://staging... img et -e API_URL=https://prod... img produisent deux comportements sans rebuild.
Indice / Solution
docker-entrypoint.sh : envsubst < /usr/share/nginx/html/config.template.json > /usr/share/nginx/html/config.json && exec nginx -g 'daemon off;'. Template : { "apiUrl": "${API_URL}", "tenantId": "${TENANT_ID}" }. Piège classique : oublier must-revalidate/no-store sur config.json → un user reste sur l'ancienne API URL. Servez-le en no-store.
Exercice 3 — Headers de cache corrects + preuve par mesure (prod-grade)
Objectif : configurer puis prouver par requête HTTP réelle que chaque type de fichier a le bon Cache-Control.
Déployez (nginx ou Vercel) et écrivez un test qui curl -I chaque catégorie et asserte : index.html → no-store ; main.*.js → immutable, max-age=31536000 ; ngsw-worker.js → no-cache. Le test doit échouer si quelqu'un casse la config.
Indice / Solution
assert_cc() { curl -sI "$1" | grep -i 'cache-control' | grep -q "$2" || { echo "FAIL $1"; exit 1; }; }
assert_cc https://app.test/index.html 'no-store'
assert_cc "https://app.test/$(curl -s https://app.test/ | grep -oE 'main\.[a-z0-9]+\.js' | head -1)" 'immutable'Piège : un proxy intermédiaire (Cloudflare) peut réécrire les headers d'origine. Testez l'URL publique finale, pas l'origin.
Exercice 4 — Casser puis réparer le version skew (break → fix)
Objectif : reproduire un ChunkLoadError post-déploiement, puis le rendre impossible.
Déployez la version A (avec une lazy route @defer ou loadComponent). Chargez la page mais ne cliquez pas sur le lien lazy. Déployez la version B en purgeant les assets de A. Cliquez maintenant le lien lazy dans l'onglet encore ouvert → observez le ChunkLoadError. Puis corrigez sans purge agressive + garde-fou de reload.
Indice / Solution
Le fix à deux niveaux : (1) infra — ne purgez pas les anciens chunks immédiatement (gardez N-2 releases) ; (2) app — le listener ChunkLoadError → location.reload() une seule fois (voir section « staff engineer »), gardé par sessionStorage pour éviter la boucle de reload infinie. Vérifiez que le reload récupère bien le nouvel index.html (donc index.html doit être no-store, sinon le reload re-sert l'ancien index → boucle).
Exercice 5 — Canary sur l'edge avec rollback < 30s (break → fix, hard)
Objectif : router 5 % du trafic vers une nouvelle release via cookie sticky à l'edge, mesurer le taux d'erreur, et rollback automatiquement si le canary dégrade.
Sur Cloudflare Workers (ou Vercel Edge), assignez un bucket stable par utilisateur (hash d'un cookie), servez le canary à 5 %, et propagez un header x-app-version. Branchez une alerte : si le taux d'erreur Sentry du canary > 2× la baseline sur 5 min, coupez le routing canary (flag edge).
Indice / Solution
Worker : const bucket = hash(cookie) % 100; const isCanary = bucket < canaryPct; où canaryPct vient d'un KV namespace (modifiable sans redeploy = rollback instantané). Stickiness : dérivez le bucket d'un cookie persistant, pas d'un random par requête (sinon le user flicke entre versions). Rollback = wrangler kv:key put canaryPct 0 ou re-pointer l'alias. Mesure : segmentez Sentry par release tag ; comparez errors/session canary vs stable, pas le compte brut (volumes différents).
Exercice 6 — Build reproductible + provenance (hard, sécurité chaîne d'appro)
Objectif : garantir que le bundle déployé correspond bit-à-bit au code d'un SHA git, et le prouver à un auditeur.
Rendez le build déterministe (mêmes inputs → même hash de sortie), générez un manifeste release.json listant le SHA, les hashes SHA-256 de chaque asset, et l'horodatage. Signez-le. En CI, attachez une attestation de provenance (SLSA / actions/attest-build-provenance).
Indice / Solution
Déterminisme : npm ci (lockfile figé), SOURCE_DATE_EPOCH pour les timestamps, outputHashing: "all" (les hashes de contenu sont déjà déterministes pour un même input). Manifeste : find dist -type f -exec sha256sum {} \; > release.json. Provenance : actions/attest-build-provenance@v1 produit une attestation vérifiable liant l'artefact au workflow + SHA. Auditeur : cosign verify-attestation ou gh attestation verify. C'est la défense contre une compromission du pipeline (un asset injecté ne matchera pas le manifeste signé).
Exercice 7 — Déployer une UI agent en streaming et casser le buffering (break → fix, hard)
Objectif : reproduire le bug « les tokens IA arrivent en bloc en prod » causé par le buffering proxy, puis garantir un flux token-par-token de bout en bout, avec un bouton Stop qui annule client et serveur.
Servez votre app Angular derrière nginx avec proxy_buffering on (défaut) devant un backend qui stream du SSE. Observez : en ng serve le flux est fluide, derrière nginx il arrive d'un coup après plusieurs secondes. Corrigez l'infra (proxy_buffering off, X-Accel-Buffering: no, exclure text/event-stream du gzip). Puis prouvez que cliquer Stop coupe réellement la facturation côté serveur (le log backend doit montrer l'abort, pas une génération qui finit toute seule).
Indice / Solution
Buffering : proxy_buffering off + header X-Accel-Buffering: no côté backend (nginx l'honore même sans config explicite). Gzip : retirer text/event-stream de gzip_types, sinon le compresseur attend un buffer minimal. Cancel de bout en bout : AbortController côté Angular (voir pattern #7) → fetch ferme la connexion → côté NestJS, écouter req.on('close') ou passer le AbortSignal de la requête à anthropic.messages.stream(params, { signal }). Preuve : instrumentez un log generation aborted côté serveur et vérifiez qu'il apparaît au clic Stop. Piège : un edge runtime (Vercel/Cloudflare) avec timeout court (maxDuration / limite CPU Worker) coupe les longs agents — pour du tool-use multi-tours, préférez un Node host (Cloud Run, Render) avec proxy_read_timeout long.
🎤 En entretien
Q : Pourquoi l'index.html doit-il être no-store alors que main.[hash].js est immutable pour 1 an ? N'est-ce pas contradictoire ? Non, c'est le pattern « immutable assets, mutable pointer ». Le hash dans le nom de fichier est l'invalidation de cache : un contenu différent = un nom différent = jamais de cache périmé, donc 1 an immutable est sûr. L'index.html est le seul pointeur mutable vers ces noms hashés ; le cacher casserait la livraison des nouvelles versions. On cache agressivement tout ce qui est adressé par contenu, jamais le pointeur.
Q : Une SPA sur CDN, comment fais-tu un rollback en production ? Sur une plateforme statique (Vercel/Cloudflare/Netlify), chaque déploiement est un artefact immuable adressable : le rollback = re-pointer l'alias de prod vers le déploiement précédent, < 30 s, zéro rebuild. Le piège senior à mentionner : garder les assets de l'ancienne version accessibles (ne pas purger), sinon les onglets ouverts sur l'ancien index.html cassent en ChunkLoadError. Le MTTR prime sur le MTBF : j'optimise d'abord la capacité à revenir en arrière instantanément.
Q : Quelle est la différence entre build-time et runtime config, et comment promeut-on un build de staging vers prod sans risque ? Build-time (environment.ts) fige les valeurs dans le bundle au build → un artefact par environnement, impossible à promouvoir tel quel. Runtime config (un config.json chargé via provideAppInitializer) sépare le « quoi » (le code, identique partout) du « où » (l'environnement). Promotion sûre = un seul artefact testé en staging, promu bit-à-bit en prod, l'environnement n'étant qu'une variable injectée au démarrage. Ça élimine la classe de bugs « ça marchait en staging » due à un build différent.
Q : Pourquoi ne sert-on pas les source maps en prod, et comment garde-t-on quand même des stack traces lisibles ? Une source map sert publiquement reconstitue le code source original (logique métier, noms, parfois commentaires) → surface d'attaque et fuite d'IP. Solution : sourceMap.hidden: true génère les .map sans commentaire sourceMappingURL dans le bundle, on les upload à Sentry (lié au release = SHA git) pour symboliser les stack traces côté serveur, puis on supprime les .map du dist avant déploiement. Les users n'y ont jamais accès, mais l'équipe voit des traces dé-minifiées.
Q : Ton UI Angular stream des tokens d'un LLM ; ça marche en local mais en prod les tokens arrivent en bloc. Pourquoi, et que vérifies-tu ? Quasi toujours du buffering sur le chemin réseau : nginx (proxy_buffering on par défaut), un CDN, ou la compression gzip qui attend un buffer minimal avant d'émettre. Le ng serve n'a aucun proxy, d'où l'illusion en local. Je vérifie : proxy_buffering off + X-Accel-Buffering: no sur la route de stream, text/event-stream exclu du gzip, et un proxy_read_timeout assez long (un agent tool-use dure des minutes). Sur un edge runtime, je vérifie le timeout max (maxDuration Vercel, limite CPU Worker) — pour les longs streams je route vers un Node host. Et je teste l'URL publique réelle, pas l'origin.
Q : Quand l'utilisateur clique « Stop » sur une réponse IA en cours, qu'est-ce qui doit réellement se passer côté déploiement ? Le Stop doit annuler de bout en bout, pas juste masquer le texte côté UI. Côté Angular : AbortController.abort() ferme le fetch, ce qui coupe la connexion. Côté serveur : la requête HTTP émet un close/aborted, que je propage en AbortSignal à l'appel SDK (anthropic.messages.stream(params, { signal })) — sinon la génération orpheline continue à consommer des tokens facturés. Le même câblage couvre la fermeture d'onglet. Sans ça, chaque abandon = du cash brûlé et une charge backend invisible. Je le prouve par un log aborted côté serveur, pas par l'absence de texte côté client.
🔗 Liens
angular.dev/tools/cli/deploymentangular.dev/tools/cli/build- Cloud Run + Angular SSR — Google Cloud guide
- Vercel deployment docs
web.dev/articles/http-cache— guide cache HTTPcsp-evaluator.withgoogle.com— analyseur CSP- Sentry Angular SDK —
docs.sentry.io/platforms/javascript/guides/angular