Cloud & Déploiement angularforall.com

- Dockeriser Angular en production : multi-stage build

Docker Angular Multi-Stage-Build Nginx Alpine Dockerfile Devops Ci-Cd Github-Actions Trivy Ghcr Production Spa-Routing Containers
Dockeriser Angular en production : multi-stage build

Conteneurisez votre app Angular avec un Dockerfile multi-stage : build Node, Nginx Alpine, gzip, sécurité non-root et image finale sous 30 MB.

Pourquoi un Dockerfile multi-stage ?

Lorsqu'on dockerise une application Angular pour la première fois, la tentation est de copier node_modules, lancer ng serve et exposer le port 4200. C'est exactement ce qu'il ne faut pas faire en production. ng serve est un serveur de développement non optimisé, qui recompile les assets à la volée et expose des ressources de débogage. Le résultat ? Une image Docker dépassant le gigaoctet, vulnérable, lente, et inadaptée au trafic réel.

Le pattern multi-stage build résout ce problème en exécutant deux phases distinctes dans un seul Dockerfile : une étape builder qui compile l'application avec Node.js, puis une étape runtime qui copie uniquement les artefacts statiques produits dans une image Nginx légère. L'image finale ne contient ni Node, ni node_modules, ni le code source TypeScript : juste le HTML, le CSS, le JavaScript bundlé et le serveur web.

Comparaison des tailles d'image

Stratégie Image de base Taille finale Surface d'attaque Production-ready
Single-stage avec ng serve node:20 ~ 1.2 GB Très large (Node + npm + sources) Non
Single-stage avec http-server node:20-alpine ~ 220 MB Moyenne (Node runtime restant) Non recommandé
Multi-stage Node + Nginx Alpine nginx:1.27-alpine ~ 25 MB Minimale (Nginx + assets) Oui
Multi-stage + distroless gcr.io/distroless/nginx ~ 18 MB Quasi-nulle (pas de shell) Oui (avancé)
Pourquoi 25 MB compte ? Une image légère se télécharge plus vite (cold start Kubernetes, déploiements canary), consomme moins de stockage sur le registry, et expose moins de CVE. Sur AWS ECR, la facturation se fait au Go/mois — passer de 1.2 GB à 25 MB divise les coûts de stockage par 48.

Architecture du Dockerfile multi-stage

Le diagramme logique est simple : deux FROM séparés, le second copiant uniquement le dist/ du premier via la directive COPY --from=builder.

# Vue d'ensemble (à détailler dans les sections suivantes)
# ┌─────────────────────────────┐
# │ STAGE 1 : builder           │
# │ FROM node:20-alpine         │
# │ npm ci → ng build           │
# │ → produit /app/dist/        │
# └──────────────┬──────────────┘
#                │ COPY --from=builder
#                ▼
# ┌─────────────────────────────┐
# │ STAGE 2 : runtime           │
# │ FROM nginx:1.27-alpine      │
# │ COPY dist/ → /usr/share/... │
# │ EXPOSE 8080 + CMD nginx     │
# └─────────────────────────────┘

Stage 1 — Build Node.js de l'application Angular

Le premier stage utilise node:20-alpine comme image de base. Alpine Linux est une distribution minimaliste (~5 MB) qui réduit considérablement la taille de l'étape builder. La cible : produire le dossier dist/<nom-app>/browser/ contenant les bundles optimisés pour la production.

Le Dockerfile complet — premier stage

# syntax=docker/dockerfile:1.7
# ============================================
# STAGE 1 : Build de l'application Angular
# ============================================
FROM node:20-alpine AS builder

# Métadonnées utiles pour le registry
LABEL org.opencontainers.image.title="Angular App Builder"
LABEL org.opencontainers.image.source="https://github.com/votre-org/votre-app"

# Définit le répertoire de travail dans le container
WORKDIR /app

# Copier UNIQUEMENT package.json et package-lock.json en premier
# Cette astuce permet au cache Docker de ne pas réinstaller les deps
# tant que le lockfile ne change pas (gain : 30-60s par build)
COPY package.json package-lock.json ./

# npm ci est plus rapide et déterministe que npm install pour CI/CD
# --no-audit et --no-fund évitent des appels réseau inutiles
RUN npm ci --no-audit --no-fund

# Maintenant on copie le reste du code source
# (les modifications de code n'invalident pas le cache des deps)
COPY . .

# Argument de build pour la configuration Angular (production, staging, etc.)
ARG BUILD_CONFIG=production
ENV NODE_ENV=production

# Build de production avec optimisations Angular activées
# - AOT compilation, tree-shaking, minification, hashing des assets
RUN npm run build -- --configuration=${BUILD_CONFIG}

# À ce stade, /app/dist/<nom-app>/browser/ contient les fichiers à servir

Le fichier .dockerignore — indispensable

Sans .dockerignore, la commande COPY . . envoie l'intégralité de votre dossier de travail au démon Docker — y compris node_modules local, .git, les fichiers IDE, les .env contenant des secrets. Le contexte de build peut atteindre plusieurs gigaoctets et fuiter des informations sensibles dans l'image.

# .dockerignore — placer à la racine du projet
# Ignore tout ce qui n'est pas indispensable au build

# Dépendances installées localement (réinstallées dans le container)
node_modules
npm-debug.log*
yarn-error.log*

# Build local (recompilé dans le container)
dist
.angular

# Outils de développement
.git
.github
.gitignore
.vscode
.idea
*.iml

# Configuration locale et secrets
.env
.env.local
.env.*.local
*.pem
*.key

# Tests et fichiers temporaires
coverage
.nyc_output
*.log
.DS_Store
Thumbs.db

# Documentation et CI (pas nécessaires au build)
README.md
CHANGELOG.md
docs/
*.md
Vérifiez la taille de votre contexte de build avec docker build --no-cache -t test . 2>&1 | head -1. Si vous voyez "Sending build context" supérieur à 10 MB, votre .dockerignore est probablement incomplet.

Optimisation BuildKit — cache des node_modules

Avec Docker BuildKit (activé par défaut depuis Docker 23+), on peut utiliser un cache mount pour conserver le cache npm entre builds successifs, accélérant npm ci de 60-80% sur les builds répétés.

# Variante optimisée du stage builder avec cache mount BuildKit
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./

# --mount=type=cache : monte un cache persistant pour npm
# Le cache survit entre builds mais n'est pas inclus dans l'image finale
RUN --mount=type=cache,target=/root/.npm \
    npm ci --no-audit --no-fund --prefer-offline

COPY . .
ARG BUILD_CONFIG=production
RUN npm run build -- --configuration=${BUILD_CONFIG}

Stage 2 — Servir avec Nginx Alpine

Le second stage repart de zéro avec une image nginx:1.27-alpine (~ 20 MB). On ne copie que les fichiers compilés, pas les sources, pas Node, pas node_modules. C'est ce qui rend l'image finale si légère.

Stage runtime complet

# ============================================
# STAGE 2 : Runtime Nginx
# ============================================
FROM nginx:1.27-alpine AS runtime

# Métadonnées
LABEL org.opencontainers.image.title="Angular App Runtime"
LABEL org.opencontainers.image.description="Production-ready Angular SPA"

# Supprimer la configuration Nginx par défaut
RUN rm -rf /etc/nginx/conf.d/default.conf /usr/share/nginx/html/*

# Copier notre configuration Nginx personnalisée
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf

# Copier UNIQUEMENT le résultat du build depuis le stage 'builder'
# Remplacer 'votre-app' par le nom du projet dans angular.json
COPY --from=builder /app/dist/votre-app/browser /usr/share/nginx/html

# Exposer le port 8080 (non-privilégié pour fonctionner non-root)
EXPOSE 8080

# Healthcheck : Docker pingue / toutes les 30s pour vérifier la santé du container
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD wget --quiet --tries=1 --spider http://localhost:8080/ || exit 1

# Démarrage de Nginx en foreground (obligatoire pour Docker)
CMD ["nginx", "-g", "daemon off;"]

Vérifier la taille finale

# Construire l'image
docker build -t mon-angular:1.0.0 .

# Vérifier la taille
docker images mon-angular:1.0.0
# REPOSITORY     TAG       IMAGE ID       CREATED         SIZE
# mon-angular    1.0.0     a1b2c3d4e5f6   2 minutes ago   25.4MB

# Inspecter les couches pour identifier les optimisations possibles
docker history mon-angular:1.0.0

# Lancer le container en local pour test
docker run -d -p 8080:8080 --name angular-test mon-angular:1.0.0

# Tester l'accès
curl -I http://localhost:8080/
# HTTP/1.1 200 OK
# Server: nginx/1.27.x
# Content-Type: text/html

# Nettoyer
docker stop angular-test && docker rm angular-test
Astuce performance : Pour les applications avec internationalisation (i18n), Angular génère plusieurs dossiers locales (fr/, en/, de/). Vous pouvez soit copier tous les locales dans la même image et router via Nginx, soit créer une image par locale (image plus petite, déploiement par région).

Configuration nginx.conf : gzip, cache, SPA routing

La configuration Nginx par défaut est insuffisante pour une SPA Angular en production : pas de fallback index.html pour le routing client-side, pas de compression gzip, pas de headers de cache long-terme sur les assets hashés. Voici une configuration complète et commentée.

Le fichier docker/nginx.conf

# docker/nginx.conf — configuration Nginx pour Angular SPA en production

server {
    # Port non-privilégié (autorise l'exécution non-root)
    listen       8080;
    listen  [::]:8080;
    server_name  _;

    # Racine des fichiers statiques (correspond à COPY dans le Dockerfile)
    root   /usr/share/nginx/html;
    index  index.html;

    # Sécurité : cache headers protecteurs
    add_header X-Content-Type-Options    "nosniff" always;
    add_header X-Frame-Options           "SAMEORIGIN" always;
    add_header Referrer-Policy           "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy        "geolocation=(), microphone=(), camera=()" always;

    # ================================
    # Compression gzip (optionnelle si front proxy le fait déjà)
    # ================================
    gzip                on;
    gzip_vary           on;
    gzip_min_length     1024;
    gzip_comp_level     6;
    gzip_proxied        any;
    gzip_types
        text/plain
        text/css
        text/javascript
        text/xml
        application/javascript
        application/json
        application/xml
        application/xml+rss
        application/wasm
        image/svg+xml;

    # ================================
    # Cache long-terme pour les assets hashés (Angular génère main.abc123.js)
    # ================================
    location ~* \.(?:js|css|woff2?|ttf|eot|otf|svg|png|jpg|jpeg|gif|webp|ico)$ {
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
        access_log off;
        try_files $uri =404;
    }

    # ================================
    # index.html : JAMAIS en cache (point d'entrée mis à jour à chaque déploiement)
    # ================================
    location = /index.html {
        expires -1;
        add_header Cache-Control "no-store, no-cache, must-revalidate";
    }

    # ================================
    # SPA routing : toute URL non trouvée renvoie index.html
    # Permet au routeur Angular de gérer /dashboard, /profile, /products/42, etc.
    # ================================
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Bloquer l'accès aux fichiers cachés (.git, .env, etc.)
    location ~ /\. {
        deny all;
        return 404;
    }

    # Page d'erreur custom (optionnel)
    error_page  500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

Configuration nginx.conf principale (optionnelle, pour non-root)

Pour faire fonctionner Nginx en utilisateur non-root, il faut aussi adapter le fichier /etc/nginx/nginx.conf principal afin que Nginx écrive ses fichiers temporaires et son pid dans des dossiers accessibles.

# docker/nginx-main.conf — fichier principal pour exécution non-root
worker_processes auto;
pid /tmp/nginx.pid;
error_log /var/log/nginx/error.log warn;

events {
    worker_connections 1024;
    use epoll;
    multi_accept on;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    # Logs accessibles en non-root
    access_log /var/log/nginx/access.log;

    # Performances
    sendfile        on;
    tcp_nopush      on;
    tcp_nodelay     on;
    keepalive_timeout  65;
    types_hash_max_size 2048;
    server_tokens off;  # Masque la version Nginx dans les headers

    # Chemins temporaires en /tmp (writable par non-root)
    client_body_temp_path /tmp/client_body;
    fastcgi_temp_path     /tmp/fastcgi_temp;
    proxy_temp_path       /tmp/proxy_temp;
    scgi_temp_path        /tmp/scgi_temp;
    uwsgi_temp_path       /tmp/uwsgi_temp;

    # Inclure les configurations de sites
    include /etc/nginx/conf.d/*.conf;
}
La directive try_files $uri $uri/ /index.html; est l'unique ligne qui active le SPA routing. Si vous l'oubliez, l'utilisateur qui rafraîchit /dashboard obtiendra un 404 — bug le plus fréquent en production Angular.

Sécurité : utilisateur non-root et healthcheck

Par défaut, les containers Docker s'exécutent en root. Si une faille permet de s'échapper du container, l'attaquant hérite des privilèges root sur l'hôte. La pratique recommandée : créer un utilisateur dédié et passer en mode non-root via USER.

Dockerfile complet, sécurisé et production-ready

# syntax=docker/dockerfile:1.7
# ====================================================
# Dockerfile production pour application Angular
# Multi-stage + non-root + healthcheck + image < 30MB
# ====================================================

# ---------- STAGE 1 : Build ----------
FROM node:20-alpine AS builder

WORKDIR /app

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --no-audit --no-fund --prefer-offline

COPY . .

ARG BUILD_CONFIG=production
ENV NODE_ENV=production
RUN npm run build -- --configuration=${BUILD_CONFIG}

# ---------- STAGE 2 : Runtime ----------
FROM nginx:1.27-alpine AS runtime

# Installer wget pour le healthcheck (déjà présent dans alpine, on le garde)
RUN apk add --no-cache wget

# Configurations Nginx custom
RUN rm -rf /etc/nginx/conf.d/default.conf /usr/share/nginx/html/*
COPY docker/nginx.conf      /etc/nginx/conf.d/default.conf
COPY docker/nginx-main.conf /etc/nginx/nginx.conf

# Copier les assets compilés depuis le stage builder
COPY --from=builder /app/dist/votre-app/browser /usr/share/nginx/html

# ---------- Sécurité : exécution non-root ----------
# Créer un user nginx-app non-privilégié
RUN addgroup -g 101 -S nginx-app 2>/dev/null || true \
 && adduser -S -D -H -u 101 -s /sbin/nologin -G nginx-app nginx-app 2>/dev/null || true

# Donner les bons droits sur les dossiers nécessaires à Nginx
RUN chown -R nginx-app:nginx-app /usr/share/nginx/html \
 && chown -R nginx-app:nginx-app /var/cache/nginx \
 && chown -R nginx-app:nginx-app /var/log/nginx \
 && mkdir -p /tmp/client_body /tmp/fastcgi_temp /tmp/proxy_temp /tmp/scgi_temp /tmp/uwsgi_temp \
 && chown -R nginx-app:nginx-app /tmp

# Basculer en utilisateur non-root
USER nginx-app

# Port non-privilégié (1024+, autorisé pour utilisateurs non-root)
EXPOSE 8080

# Healthcheck Docker — utilisé par Docker Swarm, Kubernetes (livenessProbe), etc.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD wget --quiet --tries=1 --spider http://localhost:8080/ || exit 1

# Démarrer Nginx en foreground
CMD ["nginx", "-g", "daemon off;"]

Vérifier que le container tourne en non-root

# Construire et lancer
docker build -t mon-angular:1.0.0 .
docker run -d -p 8080:8080 --name angular-secure mon-angular:1.0.0

# Vérifier l'utilisateur effectif dans le container
docker exec angular-secure id
# uid=101(nginx-app) gid=101(nginx-app) groups=101(nginx-app)

# Vérifier le statut healthcheck
docker inspect --format='{{.State.Health.Status}}' angular-secure
# healthy

# Vérifier les processus
docker exec angular-secure ps aux
# PID  USER       COMMAND
# 1    nginx-app  nginx: master process nginx -g daemon off;
# 7    nginx-app  nginx: worker process

docker-compose.yml pour le développement

# docker-compose.yml — orchestration locale pour dev/staging
version: '3.9'

services:
  angular-app:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        BUILD_CONFIG: production
    image: mon-angular:latest
    container_name: angular-app
    restart: unless-stopped
    ports:
      - "8080:8080"
    environment:
      - TZ=Europe/Paris
    # Limites de ressources (équivalents Kubernetes resources.limits)
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 128M
        reservations:
          cpus: '0.1'
          memory: 32M
    # Sécurité supplémentaire
    read_only: true
    tmpfs:
      - /tmp
      - /var/cache/nginx
      - /var/run
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    security_opt:
      - no-new-privileges:true
    healthcheck:
      test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 10s
Défense en profondeur : read_only: true rend le filesystem en lecture seule, cap_drop: ALL retire toutes les capabilities Linux, no-new-privileges empêche l'escalade de privilèges via setuid. Combinés à l'utilisateur non-root, vous obtenez un container quasi-incassable.
Checklist sécurité container Angular :
  • Image de base récente (nginx:1.27-alpine) et tag fixé (jamais :latest en prod)
  • Utilisateur non-root via USER nginx-app
  • Port non-privilégié (8080, 8443) plutôt que 80/443
  • Headers de sécurité : X-Frame-Options, X-Content-Type-Options, Referrer-Policy
  • server_tokens off pour masquer la version Nginx
  • Healthcheck Docker configuré (HEALTHCHECK)
  • .dockerignore exclut .env, .git, node_modules
  • Pas de secrets dans les ARG ou ENV (utiliser Docker secrets ou Kubernetes secrets)
  • Image scannée régulièrement (Trivy, Snyk)

Push registry Docker Hub / GHCR + scan Trivy

Une fois l'image construite, il faut la publier sur un registry pour que vos environnements (staging, prod, Kubernetes) puissent la déployer. Les options principales : Docker Hub (gratuit pour images publiques), GitHub Container Registry (GHCR) (gratuit, intégré aux repos GitHub), AWS ECR, Azure Container Registry, Google Artifact Registry.

Push sur Docker Hub

# 1. Se connecter à Docker Hub (utilise un token, pas le mot de passe)
docker login -u votreuser
# Password: dckr_pat_xxxxxxxxxxxxxxxxxxxxx

# 2. Tagger l'image avec le namespace correct
docker tag mon-angular:1.0.0 votreuser/mon-angular:1.0.0
docker tag mon-angular:1.0.0 votreuser/mon-angular:latest

# 3. Pousser les tags
docker push votreuser/mon-angular:1.0.0
docker push votreuser/mon-angular:latest

# 4. Vérifier en pull depuis une autre machine
docker pull votreuser/mon-angular:1.0.0

Push sur GitHub Container Registry (GHCR)

# GHCR est gratuit pour les repos publics et utilise vos credentials GitHub

# 1. Créer un Personal Access Token (PAT) avec scope write:packages
#    https://github.com/settings/tokens

# 2. Login (CR_PAT contient votre token)
echo $CR_PAT | docker login ghcr.io -u votre-handle --password-stdin

# 3. Tagger pour ghcr.io
docker tag mon-angular:1.0.0 ghcr.io/votre-handle/mon-angular:1.0.0

# 4. Push
docker push ghcr.io/votre-handle/mon-angular:1.0.0

# 5. Pour rendre l'image publique : Settings du package → Change visibility → Public

Scanner les vulnérabilités avec Trivy

Trivy est un scanner open-source d'Aqua Security qui détecte les CVE des packages OS, des dépendances applicatives, et des fichiers de configuration. Léger, rapide, intégrable en CI.

# Installation Trivy (macOS / Linux via brew)
brew install trivy

# Ou via Docker (sans installation locale)
alias trivy='docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy'

# Scan basique de l'image
trivy image mon-angular:1.0.0

# Scan avec filtre sur les vulnérabilités HIGH et CRITICAL uniquement
trivy image --severity HIGH,CRITICAL mon-angular:1.0.0

# Scan + exit code 1 si CVE critique détecté (utile en CI/CD pour bloquer)
trivy image --severity HIGH,CRITICAL --exit-code 1 mon-angular:1.0.0

# Génération d'un rapport JSON pour intégration outil
trivy image --format json --output trivy-report.json mon-angular:1.0.0

# Ignorer les CVE non corrigeables (qui n'ont pas de patch disponible)
trivy image --ignore-unfixed --severity HIGH,CRITICAL mon-angular:1.0.0

Comparer deux versions avant push

# Comparer les vulnérabilités entre l'ancienne et la nouvelle version
trivy image --severity HIGH,CRITICAL mon-angular:0.9.0 > old.txt
trivy image --severity HIGH,CRITICAL mon-angular:1.0.0 > new.txt
diff old.txt new.txt

# Si la nouvelle version introduit des CVE critiques → ne pas pousser
Bonne pratique : Scannez systématiquement avant docker push. Une image compromise sur un registry public peut être tirée par n'importe qui — y compris des CI tiers — et exposer votre infrastructure.

CI/CD avec GitHub Actions

L'objectif : à chaque push sur main, GitHub Actions construit l'image, la scanne avec Trivy, et la pousse sur GHCR avec le tag de la version Git. Aucune intervention manuelle.

Workflow GitHub Actions complet

# .github/workflows/docker-build-push.yml
name: Build & Push Docker Image

on:
  push:
    branches: [main]
    tags: ['v*.*.*']
  pull_request:
    branches: [main]

# Permissions nécessaires pour GHCR (write:packages)
permissions:
  contents: read
  packages: write
  security-events: write   # Pour uploader les résultats Trivy dans Security tab

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      # 1. Récupérer le code
      - name: Checkout
        uses: actions/checkout@v4

      # 2. Configurer Docker Buildx (nécessaire pour cache mounts et multi-arch)
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      # 3. S'authentifier sur GHCR
      - name: Log in to Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # 4. Extraire les métadonnées (tags + labels)
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha,prefix=sha-

      # 5. Build et push
      - name: Build and push
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            BUILD_CONFIG=production

      # 6. Scan Trivy de l'image construite
      - name: Run Trivy vulnerability scanner
        if: github.event_name != 'pull_request'
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'HIGH,CRITICAL'
          exit-code: '0'   # Ne pas échouer le build, juste reporter

      # 7. Uploader les résultats dans GitHub Security tab
      - name: Upload Trivy results to GitHub Security
        if: github.event_name != 'pull_request'
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: 'trivy-results.sarif'

      # 8. Affichage info image
      - name: Image digest
        if: github.event_name != 'pull_request'
        run: |
          echo "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
          echo "Tags: ${{ steps.meta.outputs.tags }}"
          echo "Digest: ${{ steps.build.outputs.digest }}"

Job de déploiement (exemple : SSH vers VPS)

# Suite de docker-build-push.yml — déploiement sur un VPS
  deploy-prod:
    needs: build-and-push
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy to VPS via SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            # Récupérer la nouvelle image
            docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}

            # Arrêter l'ancien container
            docker stop angular-prod 2>/dev/null || true
            docker rm angular-prod 2>/dev/null || true

            # Lancer la nouvelle version
            docker run -d \
              --name angular-prod \
              --restart unless-stopped \
              -p 80:8080 \
              --health-cmd="wget --quiet --tries=1 --spider http://localhost:8080/ || exit 1" \
              --health-interval=30s \
              ghcr.io/${{ github.repository }}:${{ github.ref_name }}

            # Nettoyer les vieilles images (garde les 3 dernières)
            docker image prune -af --filter "until=168h"
Pour un déploiement production sérieux, préférez Kubernetes avec un GitOps tool (ArgoCD, Flux) ou un orchestrateur managé (AWS ECS, Google Cloud Run, Azure Container Apps). Le déploiement SSH montré ici convient aux MVP et VPS simples.

Conclusion

Dockeriser une application Angular en production exige bien plus qu'un simple FROM node. Le pattern multi-stage build avec node:20-alpine en builder et nginx:1.27-alpine en runtime divise la taille de l'image par 50, élimine la surface d'attaque inutile et accélère drastiquement les déploiements. La clé : ne jamais exposer Node.js et node_modules dans l'image finale.

Couplé à une nginx.conf avec try_files pour le SPA routing, à un utilisateur non-root, à un healthcheck Docker, à un .dockerignore rigoureux et à un scan Trivy automatisé, vous obtenez une image de moins de 30 MB, conforme aux bonnes pratiques OWASP et prête pour Kubernetes, AWS ECS, Azure Container Apps ou Google Cloud Run.

Checklist mise en production :
  • Dockerfile multi-stage : builder Node + runtime Nginx Alpine
  • Image finale < 30 MB (vérifier avec docker images)
  • Fichier .dockerignore excluant node_modules, .env, .git
  • nginx.conf avec try_files $uri $uri/ /index.html;
  • Headers de sécurité : X-Frame-Options, X-Content-Type-Options, Referrer-Policy
  • Compression gzip activée pour js, css, svg, json
  • Cache long-terme sur les assets hashés (1 an, immutable)
  • index.html en no-store pour récupérer les nouvelles versions immédiatement
  • Utilisateur nginx-app non-root (USER)
  • Port non-privilégié 8080 (pas 80)
  • HEALTHCHECK Docker actif
  • Image taggée avec la version Git (jamais :latest en prod)
  • Scan Trivy intégré au pipeline CI/CD (bloquant sur HIGH/CRITICAL)
  • Push sur registry privé ou GHCR avec tag versionné
  • docker-compose.yml avec read_only, cap_drop: ALL, no-new-privileges

Partager