Léo DavidDocumentation
Connexion

Procédure de déploiement — Keycloak (Docker Compose) sur Debian 13

Auteur : Leo David·Modifié le 08 août 2026

Procédure de déploiement — Keycloak (Docker Compose) sur Debian 13

Référence : PROC-SSO-001 — version anonymisée Date : 08/08/2026 — révision 2 (thème, correctifs certbot et sauvegarde) Diffusion : libre — ne contient aucune donnée d'environnement Auteur :Version Keycloak : 26.7.0 (dernière version maintenue, publiée le 09/07/2026)

Docker Keycloak PostgreSQL Nginx Let's Encrypt Debian


DOCUMENT ANONYMISÉ. Toutes les valeurs propres à l'environnement (noms d'hôtes, adresses IP, domaines, mots de passe, identités) ont été remplacées par des marqueurs de la forme <NOM_DU_MARQUEUR>. Se reporter au tableau de substitution du §0 avant toute réutilisation : le document n'est pas exécutable en l'état.

0. Tableau de substitution

Renseigner ces valeurs avant d'appliquer la procédure. Chaque marqueur apparaît tel quel dans l'ensemble du document.

Infrastructure

MarqueurSignificationExemple
<SRV_SSO>Nom court du serveur KeycloakSRV-SSO-01
<SRV_PROXY>Nom court du reverse proxySRV-RP-01
<DOMAINE_AD>Domaine interneinterne.lan
<DOMAINE_PUBLIC>Domaine public (certificats)example.com
<IP_SSO>IP du serveur Keycloak192.0.2.30
<IP_PROXY>IP du reverse proxy192.0.2.10
<GW_SSO>Passerelle du VLAN Keycloak192.0.2.254
<GW_PROXY>Passerelle du VLAN proxy198.51.100.254
<VLAN_SSO>Sous-réseau Keycloak192.0.2.0/24
<VLAN_PROXY>Sous-réseau du proxy198.51.100.0/24
<RESEAU_ADMIN>Plage d'administration (SSH)10.0.0.0/8
<DNS1> / <DNS2>Résolveurs internes192.0.2.1
<IP_BACKEND_TIERS>Backend d'un vhost existant (exemple)192.0.2.40

Secrets

Ces marqueurs doivent être remplacés par des valeurs générées, jamais réutilisées d'un environnement à l'autre :

for i in 1 2 3 4; do openssl rand -base64 48 | tr -d '/+=\n' | cut -c1-32; echo; done
MarqueurUsageStockage
<MDP_POSTGRES>Utilisateur PostgreSQL keycloak.envPOSTGRES_PASSWORD
<MDP_ADMIN_BOOTSTRAP>Compte admin temporaire.envKC_BOOTSTRAP_ADMIN_PASSWORD
<MDP_ADMIN_NOMINATIF>Compte admin nominatifBase Keycloak (haché)
<MDP_RESERVE>Réserve / compte de serviceCoffre-fort

Identités et marque

MarqueurSignificationExemple
<NOM_ORGANISATION>Raison sociale affichéeContoso
<TRIGRAMME>Trigramme de l'administrateuradm.jdo
corporateNom du thème graphiqueà renommer librement

Éléments à adapter sans marqueur

  • Interface réseau : ens18 dans les exemples, à vérifier avec ip -br a
  • Fuseau horaire : Europe/Paris
  • Adresse e-mail technique pour Let's Encrypt
  • Palette graphique et polices du §9 : issues d'une charte spécifique, à remplacer intégralement

1. Contexte et périmètre

1.1 Objectif

Déployer une instance Keycloak (IAM / SSO) en conteneurs Docker sur une VM Debian 13 « Trixie », avec base PostgreSQL dédiée, publiée sur Internet via un reverse proxy nginx existant en terminaison TLS (certificats Let's Encrypt gérés par certbot).

1.2 Inventaire

RôleNomFQDN interneIPOS
Serveur Keycloak<SRV_SSO><SRV_SSO>.<DOMAINE_AD><IP_SSO>Debian 13
Reverse proxy<SRV_PROXY><SRV_PROXY>.<DOMAINE_AD><IP_PROXY>(existant)

⚠️ À valider : tu m'as donné le FQDN <SRV_SSO>.<DOMAINE_AD> alors que le nom de VM est <SRV_SSO>. J'ai retenu <SRV_SSO>.<DOMAINE_AD> par cohérence. Adapte si ta convention DNS est différente.

1.3 Nommage applicatif

ÉlémentValeur
URL publiquehttps://sso.<DOMAINE_PUBLIC>
Port HTTP interne Keycloak8080 (en clair, uniquement depuis le reverse proxy)
Port management (health/metrics)9000 (jamais publié, interne au conteneur)
Répertoire d'exploitation/opt/keycloak
Base de donnéesPostgreSQL 17 (conteneur)

1.4 Architecture cible

Internet
   │  443/TCP (TLS Let's Encrypt)
   ▼
<SRV_PROXY> (nginx) — <IP_PROXY>
   │  8080/TCP (HTTP en clair, réseau interne)
   ▼
<SRV_SSO> — <IP_SSO>
   ├── conteneur keycloak      (8080 publié, 9000 interne)
   └── conteneur postgres      (5432, réseau Docker uniquement)

Principe de sécurité : le TLS est terminé sur nginx (edge termination). Keycloak reçoit du HTTP en clair mais reconstruit ses URL publiques à partir des en-têtes X-Forwarded-* et de l'option hostname. Le port 8080 n'est jamais exposé sur Internet et est filtré pour n'accepter que <IP_PROXY>.

1.5 Prérequis à valider avant de commencer

  • VM Debian 13 installée, minimum 2 vCPU / 4 Go RAM / 40 Go disque (Keycloak est une JVM : 2 Go de RAM strictement pour le conteneur KC)
  • IP fixe <IP_SSO>, passerelle et DNS internes fonctionnels
  • Accès SSH avec un compte disposant de sudo
  • Sortie Internet autorisée depuis la VM (ou proxy HTTP) pour download.docker.com, quay.io, deb.debian.org
  • Flux réseau <IP_PROXY> → <IP_SSO>:8080/TCP ouvert sur le pare-feu inter-VLAN
  • Enregistrement DNS public sso.<DOMAINE_PUBLIC> → IP publique NATée vers <SRV_PROXY> (obligatoire avant la demande de certificat)
  • Enregistrement DNS interne sso.<DOMAINE_PUBLIC><IP_PROXY> (split-horizon, pour que les clients internes passent aussi par le proxy)
  • Serveur NTP joignable (une dérive d'horloge casse la validation des jetons OIDC)

2. Préparation du serveur Debian 13

Toutes les commandes de cette section s'exécutent sur <SRV_SSO>.

2.1 Mise à jour du système

sudo apt update && sudo apt full-upgrade -y
sudo apt install -y ca-certificates curl gnupg vim git jq unzip \
                    chrony bash-completion
sudo reboot

2.2 Nom d'hôte et résolution locale

sudo hostnamectl set-hostname <SRV_SSO>

Éditer /etc/hosts :

sudo vim /etc/hosts
127.0.0.1       localhost
127.0.1.1       <SRV_SSO>.<DOMAINE_AD> <SRV_SSO>
<IP_SSO>     <SRV_SSO>.<DOMAINE_AD> <SRV_SSO>

# IPv6
::1     localhost ip6-localhost ip6-loopback
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters

Vérification :

hostname -f     # doit renvoyer <SRV_SSO>.<DOMAINE_AD>

2.3 Fuseau horaire et synchronisation NTP

sudo timedatectl set-timezone Europe/Paris
sudo systemctl enable --now chrony
timedatectl status
chronyc sources -v

La ligne System clock synchronized: yes doit apparaître.

2.4 Réseau (si non configuré à l'installation)

Debian 13 utilise toujours /etc/network/interfaces par défaut sur une installation standard. Adapter le nom d'interface (ens18, eth0…) :

ip -br a          # identifier le nom de l'interface
sudo vim /etc/network/interfaces
auto ens18
iface ens18 inet static
    address <IP_SSO>/24
    gateway <GW_SSO>
    dns-nameservers <DNS1> <DNS2>
    dns-search <DOMAINE_AD>
sudo systemctl restart networking
ip -br a && ip r

2.5 Durcissement SSH (recommandé)

sudo vim /etc/ssh/sshd_config.d/99-hardening.conf
PermitRootLogin no
PasswordAuthentication no      # uniquement si tes clés SSH sont déjà en place !
X11Forwarding no
MaxAuthTries 3
sudo sshd -t && sudo systemctl reload ssh

⚠️ Ne pas activer PasswordAuthentication no avant d'avoir testé une connexion par clé dans une seconde session SSH.

2.6 Pare-feu local (ufw)

sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from <RESEAU_ADMIN> to any port 22 proto tcp comment 'SSH admin'
sudo ufw enable
sudo ufw status verbose

⚠️ Point critique : ufw ne filtre pas les ports publiés par Docker. Docker insère ses propres règles DNAT en amont de la chaîne INPUT. Le filtrage du port 8080 se fait donc via la chaîne DOCKER-USER — voir §5.3.


3. Installation de Docker Engine et Docker Compose

3.1 Suppression des paquets conflictuels

for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do
  sudo apt remove -y $pkg 2>/dev/null
done

3.2 Ajout du dépôt officiel Docker

Le paquet docker.io des dépôts Debian est en retard ; on utilise le dépôt upstream, qui supporte officiellement Debian 13 « Trixie ».

# Clé GPG
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg \
     -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Dépôt
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update

$VERSION_CODENAME doit renvoyer trixie. Vérifie avec . /etc/os-release && echo $VERSION_CODENAME.

3.3 Installation

sudo apt install -y docker-ce docker-ce-cli containerd.io \
                    docker-buildx-plugin docker-compose-plugin

3.4 Vérification

sudo systemctl enable --now docker
sudo systemctl status docker --no-pager
docker version
docker compose version
sudo docker run --rm hello-world

3.5 Configuration du daemon Docker

Rotation des logs (sinon les logs Keycloak remplissent /var/lib/docker) et désactivation du proxy userland (préserve l'IP source réelle vue par les conteneurs) :

sudo vim /etc/docker/daemon.json
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "20m",
    "max-file": "5"
  },
  "userland-proxy": false,
  "live-restore": true
}
sudo systemctl restart docker
docker info | grep -Ei 'logging|storage driver'

3.6 Utilisation sans sudo (optionnel)

sudo usermod -aG docker $USER
newgrp docker      # ou se déconnecter/reconnecter
docker ps

⚠️ L'appartenance au groupe docker équivaut à un accès root sur la machine. À réserver au compte d'exploitation.


4. Déploiement de Keycloak

4.1 Arborescence

sudo mkdir -p /opt/keycloak/{providers,themes,backups,imports}
sudo chown -R $USER:$USER /opt/keycloak
cd /opt/keycloak
tree -L 1 /opt/keycloak 2>/dev/null || ls -la /opt/keycloak

Structure finale :

/opt/keycloak/
├── .env                    # secrets et paramètres (chmod 600)
├── docker-compose.yml
├── providers/              # extensions .jar éventuelles
├── themes/                 # thèmes personnalisés
├── imports/                # exports/imports de realms JSON
└── backups/                # dumps PostgreSQL

4.2 Secrets du déploiement

Les mots de passe ci-dessous ont été générés pour ce déploiement (32 caractères alphanumériques, ~190 bits d'entropie). Ils sont volontairement sans caractères spéciaux : cela évite tout problème d'échappement dans les fichiers .env, les URL JDBC et les scripts shell, sans perte de robustesse à cette longueur.

Réf.UsageValeur
S1Base PostgreSQL — utilisateur keycloak<MDP_POSTGRES>
S2Compte admin de bootstrap Keycloak (bootstrapadmin) — temporaire<MDP_ADMIN_BOOTSTRAP>
S3Compte admin nominatif Keycloak (adm.<TRIGRAMME>) — voir §7.2<MDP_ADMIN_NOMINATIF>
S4Réserve (compte de service / futur usage)<MDP_RESERVE>

🔐 À faire immédiatement : enregistrer S1 à S4 dans le gestionnaire de secrets (Bitwarden, Vaultwarden, KeePass…) avant de poursuivre. Voir le récapitulatif complet en Annexe E.

Si tu préfères régénérer tes propres valeurs :

for i in 1 2 3 4; do openssl rand -base64 48 | tr -d '/+=\n' | cut -c1-32; echo; done

4.3 Fichier .env

vim /opt/keycloak/.env
# ============================================================
#  Keycloak — <SRV_SSO> — /opt/keycloak/.env
#  ATTENTION : contient des secrets — chmod 600
# ============================================================

# --- Versions (épinglées volontairement) ---
KEYCLOAK_VERSION=26.7.0
POSTGRES_VERSION=17-alpine

# --- Base de données ---
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=<MDP_POSTGRES>

# --- Identité publique ---
KC_HOSTNAME_URL=https://sso.<DOMAINE_PUBLIC>

# --- Compte admin temporaire (bootstrap, à supprimer après création
#     d'un compte nominatif — voir §7.2) ---
KC_BOOTSTRAP_ADMIN_USERNAME=bootstrapadmin
KC_BOOTSTRAP_ADMIN_PASSWORD=<MDP_ADMIN_BOOTSTRAP>

# --- Réseau ---
# IP d'écoute sur l'hôte pour le port 8080
KC_BIND_ADDRESS=<IP_SSO>
# IP du reverse proxy autorisé à positionner les en-têtes X-Forwarded-*
KC_TRUSTED_PROXY=<IP_PROXY>

# --- JVM ---
# Les guillemets sont indispensables : la valeur contient un espace, et le
# fichier doit rester lisible par `source` dans les scripts d'exploitation.
JAVA_OPTS_APPEND="-XX:MaxRAMPercentage=70 -Djava.net.preferIPv4Stack=true"
chmod 600 /opt/keycloak/.env

# Contrôle : le fichier doit être exploitable à la fois par Compose et par bash
docker compose config | grep -i java_opts
bash -n <(grep -v '^#' /opt/keycloak/.env) && echo "sourçable OK"

4.4 Fichier docker-compose.yml

vim /opt/keycloak/docker-compose.yml
# ============================================================
#  Keycloak + PostgreSQL — <SRV_SSO>
#  Publication publique : https://sso.<DOMAINE_PUBLIC> (via nginx <SRV_PROXY>)
# ============================================================

services:

  postgres:
    image: postgres:${POSTGRES_VERSION}
    container_name: keycloak-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      # Force l'initialisation en scram-sha-256
      POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256"
      TZ: Europe/Paris
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - keycloak-net
    # Aucun port publié : accessible uniquement depuis le réseau Docker
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 30s
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"

  keycloak:
    image: quay.io/keycloak/keycloak:${KEYCLOAK_VERSION}
    container_name: keycloak
    restart: unless-stopped
    command:
      - start
    environment:
      # ---------- Base de données ----------
      KC_DB: postgres
      KC_DB_URL_HOST: postgres
      KC_DB_URL_PORT: 5432
      KC_DB_URL_DATABASE: ${POSTGRES_DB}
      KC_DB_USERNAME: ${POSTGRES_USER}
      KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
      KC_DB_POOL_INITIAL_SIZE: 5
      KC_DB_POOL_MIN_SIZE: 5
      KC_DB_POOL_MAX_SIZE: 20

      # ---------- Nom d'hôte public ----------
      # URL complète : Keycloak construit tous ses liens (issuer, redirect,
      # découverte OIDC) à partir de cette valeur.
      KC_HOSTNAME: ${KC_HOSTNAME_URL}
      KC_HOSTNAME_STRICT: "true"
      KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "false"

      # ---------- Reverse proxy (terminaison TLS sur nginx) ----------
      KC_HTTP_ENABLED: "true"
      KC_HTTP_PORT: 8080
      KC_PROXY_HEADERS: xforwarded
      # Seul le reverse proxy est autorisé à fournir les X-Forwarded-*
      KC_PROXY_TRUSTED_ADDRESSES: ${KC_TRUSTED_PROXY}

      # ---------- Supervision ----------
      KC_HEALTH_ENABLED: "true"
      KC_METRICS_ENABLED: "true"
      # Interface de management (health/metrics) sur 9000, jamais publiée
      KC_HTTP_MANAGEMENT_PORT: 9000

      # ---------- Cache ----------
      # Instance unique : pas de cluster Infinispan/JGroups
      KC_CACHE: local

      # ---------- Thèmes ----------
      # Valeurs par défaut = production (cache actif).
      # Pour itérer sur un thème sans redémarrer, définir dans .env :
      #   KC_SPI_THEME_CACHE_THEMES=false
      #   KC_SPI_THEME_CACHE_TEMPLATES=false
      #   KC_SPI_THEME_STATIC_MAX_AGE=-1
      # Retirer ces lignes du .env remet automatiquement le cache en service.
      KC_SPI_THEME_CACHE_THEMES: ${KC_SPI_THEME_CACHE_THEMES:-true}
      KC_SPI_THEME_CACHE_TEMPLATES: ${KC_SPI_THEME_CACHE_TEMPLATES:-true}
      KC_SPI_THEME_STATIC_MAX_AGE: ${KC_SPI_THEME_STATIC_MAX_AGE:-2592000}

      # ---------- Divers ----------
      KC_LOG_LEVEL: info
      KC_LOG_CONSOLE_OUTPUT: default
      TZ: Europe/Paris
      JAVA_OPTS_APPEND: ${JAVA_OPTS_APPEND}

      # ---------- Compte admin de bootstrap ----------
      # Le `:-` évite un avertissement Compose une fois ces variables
      # commentées dans le .env (voir §7.2).
      KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME:-}
      KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD:-}

    ports:
      # Écoute uniquement sur l'IP de service, pas sur 0.0.0.0
      - "${KC_BIND_ADDRESS}:8080:8080"

    volumes:
      - ./providers:/opt/keycloak/providers:ro
      - ./themes:/opt/keycloak/themes:ro
      - ./imports:/opt/keycloak/data/import:ro

    networks:
      - keycloak-net

    depends_on:
      postgres:
        condition: service_healthy

    healthcheck:
      test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3; grep -q '\"status\": \"UP\"' <&3"]
      interval: 30s
      timeout: 10s
      retries: 10
      start_period: 90s

    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"

volumes:
  postgres_data:
    name: keycloak_postgres_data

networks:
  keycloak-net:
    name: keycloak-net
    driver: bridge

Explication des options clés

VariableRôle
KC_HOSTNAMEURL publique complète. Keycloak émet ses issuer, ses redirections et son .well-known avec cette valeur. Sans elle, les URL contiendraient http://<IP_SSO>:8080 et les clients OIDC échoueraient.
KC_HOSTNAME_STRICT=trueRefuse de déduire le nom d'hôte des en-têtes de la requête. Empêche les attaques par Host header injection.
KC_HTTP_ENABLED=trueIndispensable en terminaison TLS déportée : Keycloak accepte du HTTP en clair depuis le proxy.
KC_PROXY_HEADERS=xforwardedActive la lecture des X-Forwarded-For / -Proto / -Host / -Port. Sans ça, Keycloak croit être en HTTP et génère des boucles de redirection.
KC_PROXY_TRUSTED_ADDRESSESN'accepte ces en-têtes que s'ils viennent de <IP_PROXY>. Sans ce garde-fou, un client capable de joindre le 8080 directement pourrait falsifier son IP source ou le protocole.
KC_CACHE=localInstance unique. Évite les tentatives de découverte JGroups qui ralentissent le démarrage et polluent les logs.
KC_HEALTH_ENABLEDExpose /health/ready, /health/live sur le port 9000 (management), pas sur 8080.

4.5 Premier démarrage

cd /opt/keycloak
docker compose config          # valide la syntaxe + substitution des variables
docker compose pull
docker compose up -d

Suivi du démarrage (comptez 60 à 120 s pour le premier lancement : Keycloak effectue l'augmentation de schéma Liquibase) :

docker compose logs -f keycloak

Attendre la ligne :

Keycloak 26.7.0 on JVM (powered by Quarkus x.y.z) started in xx.xxxs.
Listening on: http://0.0.0.0:8080. Management interface listening on http://0.0.0.0:9000.

4.6 Vérifications locales

# État des conteneurs (les deux doivent être "healthy")
docker compose ps

# Health check applicatif
docker compose exec keycloak bash -c \
  'exec 3<>/dev/tcp/127.0.0.1/9000; echo -e "GET /health/ready HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" >&3; cat <&3'

# Écoute sur la bonne IP uniquement
sudo ss -tlnp | grep 8080
# → attendu : <IP_SSO>:8080 (et PAS 0.0.0.0:8080)

# Réponse HTTP applicative
curl -I http://<IP_SSO>:8080/realms/master
# → 200 OK attendu

Le message curl http://<IP_SSO>:8080/ renvoyant un code 30x vers https://sso.<DOMAINE_PUBLIC> est normal : hostname-strict force la réécriture vers l'URL publique.


5. Sécurisation du serveur Keycloak

5.1 Rappel : Docker contourne ufw

Quand Docker publie un port, il crée une règle DNAT dans la table nat, traitée avant la chaîne INPUT que ufw utilise. Une règle ufw deny 8080 n'a donc aucun effet sur un port publié par Docker.

La solution supportée est la chaîne DOCKER-USER, que Docker évalue en premier dans la table filter/FORWARD et qu'il ne réécrit jamais.

5.2 Vérification préalable

sudo iptables -L DOCKER-USER -n -v --line-numbers

5.3 Script de filtrage du port 8080

sudo vim /usr/local/sbin/docker-firewall.sh
#!/bin/bash
# Restreint l'accès au port 8080 (Keycloak) au seul reverse proxy.
# Appliqué après le démarrage de Docker (voir unité systemd associée).

set -euo pipefail

PROXY_IP="<IP_PROXY>"
KC_PORT="8080"

# Purge des règles précédemment posées par ce script (marquées par un commentaire)
while iptables -S DOCKER-USER | grep -q "keycloak-acl"; do
    RULE=$(iptables -S DOCKER-USER | grep -m1 "keycloak-acl" | sed 's/^-A //')
    iptables -D DOCKER-USER $RULE
done

# 1) On pose d'abord le DROP…
iptables -A DOCKER-USER -p tcp --dport "${KC_PORT}" \
    -m comment --comment "keycloak-acl-deny" -j DROP

# 2) …puis on insère les ACCEPT AU-DESSUS (l'ordre final compte)
iptables -I DOCKER-USER 1 -s "${PROXY_IP}/32" -p tcp --dport "${KC_PORT}" \
    -m comment --comment "keycloak-acl-proxy" -j ACCEPT

iptables -I DOCKER-USER 1 -s 127.0.0.1/32 -p tcp --dport "${KC_PORT}" \
    -m comment --comment "keycloak-acl-local" -j ACCEPT

# 3) Autorise le trafic déjà établi
iptables -I DOCKER-USER 1 -m conntrack --ctstate ESTABLISHED,RELATED \
    -m comment --comment "keycloak-acl-established" -j ACCEPT

echo "Règles DOCKER-USER appliquées."
sudo chmod 750 /usr/local/sbin/docker-firewall.sh

5.4 Unité systemd d'application persistante

La chaîne DOCKER-USER est recréée à chaque démarrage de Docker : on rejoue le script après docker.service.

sudo vim /etc/systemd/system/docker-firewall.service
[Unit]
Description=Regles de filtrage DOCKER-USER pour Keycloak
After=docker.service
Requires=docker.service
PartOf=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/docker-firewall.sh

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now docker-firewall.service
sudo systemctl status docker-firewall.service --no-pager
sudo iptables -L DOCKER-USER -n -v --line-numbers

5.5 Test de filtrage

# Depuis <SRV_PROXY> (<IP_PROXY>) : doit répondre
curl -I http://<IP_SSO>:8080/realms/master

# Depuis n'importe quel autre poste : doit timeout
curl -m 5 -I http://<IP_SSO>:8080/realms/master

5.6 Mises à jour automatiques de sécurité (recommandé)

sudo apt install -y unattended-upgrades apt-listchanges
sudo dpkg-reconfigure -plow unattended-upgrades

Ne jamais mettre docker-ce en mise à jour automatique non supervisée sur un serveur de production SSO : un redémarrage du daemon coupe le service. Restreindre aux paquets ${distro_id}:${distro_codename}-security.


6. Configuration du reverse proxy nginx (<SRV_PROXY>)

Toutes les commandes de cette section s'exécutent sur <SRV_PROXY>.

6.1 Sauvegarde préalable — obligatoire

sudo tar czf /root/nginx-backup-$(date +%F-%H%M).tar.gz /etc/nginx /etc/letsencrypt
sudo nginx -t
sudo nginx -T > /root/nginx-dump-avant-$(date +%F).conf
ls -l /etc/nginx/sites-enabled/
sudo certbot certificates

Le dump nginx -T te donne la configuration effective complète. C'est ton point de retour arrière.

6.2 Reconnaître la méthode certbot en place

sudo certbot certificates
cat /etc/letsencrypt/renewal/*.conf | grep -E 'authenticator|installer|webroot_path'
  • authenticator = nginx → certbot pilote nginx pour la validation
  • authenticator = webroot → certbot dépose un fichier dans un répertoire servi par nginx

La méthode retenue ci-dessous est webroot, car elle ne modifie jamais tes fichiers de configuration existants — contrairement au plugin --nginx qui réécrit les blocs server. Elle cohabite sans problème avec des certificats déjà émis via --nginx.

Contrôler les hooks déjà en place

grep -rn 'hook' /etc/letsencrypt/renewal/*.conf
ls -l /etc/letsencrypt/renewal-hooks/deploy/ 2>/dev/null

Deux pièges classiques :

Les scripts de /etc/letsencrypt/renewal-hooks/deploy/ s'exécutent pour chaque certificat renouvelé, pas seulement celui pour lequel ils ont été écrits. Un script qui référence un domaine en dur se déclenchera aussi sur le nouveau. Et un hook qui sort en erreur fait échouer le renouvellement. Ajouter systématiquement un garde en tête :

[[ "${RENEWED_LINEAGE:-}" == */mon-domaine.fr ]] || exit 0

RENEWED_LINEAGE est exportée par certbot et contient le chemin /etc/letsencrypt/live/<cert-name> du certificat concerné.

Un renew_hook doit se trouver dans la section [renewalparams]. Certains fichiers se terminent par une section [[webroot_map]] : un simple >> placerait la ligne dans le mauvais bloc, où certbot l'ignore silencieusement. Vérifier avant, et insérer au bon endroit :

tail -12 /etc/letsencrypt/renewal/<domaine>.conf
sed -i '/^\[\[webroot_map\]\]/i renew_hook = systemctl reload nginx' \
    /etc/letsencrypt/renewal/<domaine>.conf
certbot renew --cert-name <domaine> --dry-run

6.3 Préparer le répertoire ACME partagé

sudo mkdir -p /var/www/certbot/.well-known/acme-challenge
sudo chown -R www-data:www-data /var/www/certbot
sudo chmod -R 755 /var/www/certbot

6.4 Étape 1 — bloc HTTP temporaire pour la validation ACME

On crée un nouveau fichier, sans toucher aux autres :

sudo vim /etc/nginx/sites-available/sso.<DOMAINE_PUBLIC>.conf
# ------------------------------------------------------------
#  sso.<DOMAINE_PUBLIC> — Keycloak (<SRV_SSO> / <IP_SSO>:8080)
#  Étape 1 : validation ACME uniquement
# ------------------------------------------------------------
server {
    listen 80;
    listen [::]:80;
    server_name sso.<DOMAINE_PUBLIC>;

    # Challenge Let's Encrypt
    location ^~ /.well-known/acme-challenge/ {
        root /var/www/certbot;
        default_type "text/plain";
        allow all;
    }

    location / {
        return 404;
    }
}

Activation et test :

sudo ln -s /etc/nginx/sites-available/sso.<DOMAINE_PUBLIC>.conf \
           /etc/nginx/sites-enabled/sso.<DOMAINE_PUBLIC>.conf
sudo nginx -t
sudo systemctl reload nginx

Vérification que le challenge est bien servi :

echo "test-acme-ok" | sudo tee /var/www/certbot/.well-known/acme-challenge/test
curl http://sso.<DOMAINE_PUBLIC>/.well-known/acme-challenge/test    # → test-acme-ok
sudo rm /var/www/certbot/.well-known/acme-challenge/test

Si ça ne répond pas : vérifie la résolution DNS publique de sso.<DOMAINE_PUBLIC> et l'ouverture du 80/TCP entrant sur le pare-feu périmétrique. Le port 80 doit rester ouvert pour les renouvellements HTTP-01.

6.5 Étape 2 — émission du certificat

Test à blanc d'abord (ne consomme pas le quota Let's Encrypt) :

sudo certbot certonly --webroot -w /var/www/certbot \
     -d sso.<DOMAINE_PUBLIC> \
     --cert-name sso.<DOMAINE_PUBLIC> \
     --key-type ecdsa \
     --deploy-hook "systemctl reload nginx" \
     --dry-run

Puis émission réelle :

sudo certbot certonly --webroot -w /var/www/certbot \
     -d sso.<DOMAINE_PUBLIC> \
     --cert-name sso.<DOMAINE_PUBLIC> \
     --key-type ecdsa \
     --deploy-hook "systemctl reload nginx"

--key-type ecdsa pour rester homogène avec les certificats existants. --deploy-hook doit être repassé à l'émission réelle : en --dry-run, certbot n'écrit rien dans /etc/letsencrypt/renewal/. Cette option écrit renew_hook directement au bon endroit dans le fichier de renouvellement, ce qui évite la manipulation manuelle décrite au §6.2.

Vérification :

sudo certbot certificates | grep -A4 sso.<DOMAINE_PUBLIC>
ls -l /etc/letsencrypt/live/sso.<DOMAINE_PUBLIC>/
grep renew_hook /etc/letsencrypt/renewal/sso.<DOMAINE_PUBLIC>.conf

certonly garantit que certbot n'installe rien et ne touche à aucun fichier nginx existant. Le --cert-name isole ce certificat dans son propre fichier de renouvellement /etc/letsencrypt/renewal/sso.<DOMAINE_PUBLIC>.conf.

6.6 Étape 3 — configuration finale du vhost

sudo vim /etc/nginx/sites-available/sso.<DOMAINE_PUBLIC>.conf

Remplacer intégralement le contenu par :

# ------------------------------------------------------------
#  sso.<DOMAINE_PUBLIC> — Keycloak
#  Backend : <SRV_SSO> — <IP_SSO>:8080
# ------------------------------------------------------------

upstream keycloak_backend {
    server <IP_SSO>:8080 max_fails=3 fail_timeout=15s;
    keepalive 32;
}

# --- Redirection HTTP → HTTPS + challenge ACME ---
server {
    listen 80;
    listen [::]:80;
    server_name sso.<DOMAINE_PUBLIC>;

    location ^~ /.well-known/acme-challenge/ {
        root /var/www/certbot;
        default_type "text/plain";
        allow all;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

# --- Vhost HTTPS ---
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name sso.<DOMAINE_PUBLIC>;

    # ---------- TLS ----------
    ssl_certificate     /etc/letsencrypt/live/sso.<DOMAINE_PUBLIC>/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/sso.<DOMAINE_PUBLIC>/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/sso.<DOMAINE_PUBLIC>/chain.pem;

    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_session_timeout 1d;
    ssl_session_cache   shared:SSLsso:10m;
    ssl_session_tickets off;
    ssl_stapling on;
    ssl_stapling_verify on;

    # ---------- Logs dédiés ----------
    access_log /var/log/nginx/sso.<DOMAINE_PUBLIC>-access.log;
    error_log  /var/log/nginx/sso.<DOMAINE_PUBLIC>-error.log warn;

    # ---------- En-têtes de sécurité ----------
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    # Ne PAS ajouter X-Frame-Options ici : Keycloak gère lui-même
    # ses en-têtes frame-ancestors via les paramètres de sécurité du realm.

    # ---------- Tailles et buffers ----------
    # Les jetons/cookies Keycloak peuvent être volumineux
    client_max_body_size        20m;
    client_body_buffer_size     128k;
    large_client_header_buffers 8 32k;

    proxy_buffer_size       128k;
    proxy_buffers           8 256k;
    proxy_busy_buffers_size 256k;

    proxy_connect_timeout 10s;
    proxy_send_timeout    120s;
    proxy_read_timeout    120s;

    # ---------- Proxy vers Keycloak ----------
    location / {
        proxy_pass http://keycloak_backend;

        proxy_http_version 1.1;
        proxy_set_header Connection "";

        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        # Le proxy est en bordure : on ÉCRASE la valeur cliente
        # au lieu de l'ajouter, pour éviter le spoofing d'IP.
        proxy_set_header X-Forwarded-For   $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_set_header X-Forwarded-Port  443;

        proxy_redirect off;
    }

    # ---------- Console d'administration : accès restreint ----------
    # Décommenter pour n'autoriser l'admin que depuis le LAN.
    # location /admin/ {
    #     allow <RESEAU_ADMIN>;
    #     deny  all;
    #
    #     proxy_pass http://keycloak_backend;
    #     proxy_http_version 1.1;
    #     proxy_set_header Connection "";
    #     proxy_set_header Host              $host;
    #     proxy_set_header X-Real-IP         $remote_addr;
    #     proxy_set_header X-Forwarded-For   $remote_addr;
    #     proxy_set_header X-Forwarded-Proto $scheme;
    #     proxy_set_header X-Forwarded-Host  $host;
    #     proxy_set_header X-Forwarded-Port  443;
    # }

    # ---------- Endpoints à ne jamais exposer ----------
    location ~ ^/(metrics|health) {
        deny all;
        return 404;
    }
}

Test et application :

sudo nginx -t
sudo systemctl reload nginx

⚠️ nginx -t valide toute la configuration. S'il échoue, ne recharge pas : le service reste sur l'ancienne configuration, donc aucun impact sur les sites existants. C'est le filet de sécurité principal.

6.7 Renouvellement automatique

Vérifier que le timer systemd de certbot est actif :

systemctl list-timers | grep certbot
sudo systemctl status certbot.timer --no-pager

Ajouter un hook global de rechargement nginx (idempotent, sans effet de bord sur les certificats existants) :

sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'EOF'
#!/bin/bash
/usr/sbin/nginx -t && /bin/systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

Si un hook équivalent existe déjà (dans /etc/letsencrypt/renewal/*.conf sous forme de renew_hook), n'en ajoute pas un second : un simple reload en double est inoffensif mais autant rester propre.

Test à blanc du renouvellement de tous les certificats :

sudo certbot renew --dry-run

Cette commande doit se terminer par Congratulations, all simulated renewals succeeded pour tous tes domaines, pas seulement le nouveau. C'est le contrôle de non-régression sur l'existant.

6.8 Contrôle de non-régression sur les sites existants

# Les autres vhosts répondent toujours
for site in <site1.<DOMAINE_PUBLIC>> <site2.<DOMAINE_PUBLIC>>; do
  echo "--- $site"
  curl -sSI https://$site | head -1
done

# Aucun fichier existant modifié
sudo find /etc/nginx -newer /root/nginx-backup-*.tar.gz -type f

7. Configuration initiale de Keycloak

7.1 Premier accès

Ouvrir https://sso.<DOMAINE_PUBLIC> puis Administration Console. Se connecter avec les identifiants de bootstrap :

ChampValeur
Utilisateurbootstrapadmin
Mot de passe<MDP_ADMIN_BOOTSTRAP> (secret S2)

Vérifier immédiatement que la découverte OIDC renvoie bien les URL publiques :

curl -s https://sso.<DOMAINE_PUBLIC>/realms/master/.well-known/openid-configuration | jq .issuer
# → attendu : "https://sso.<DOMAINE_PUBLIC>/realms/master"

Si l'issuer contient http:// ou une IP, la configuration proxy est incorrecte → voir §11.

7.2 Remplacer le compte de bootstrap

  1. Realm masterUsersAdd user
  2. Créer un compte nominatif (ex. adm.<TRIGRAMME>), renseigner e-mail, prénom, nom
  3. Onglet CredentialsSet password → saisir le secret S3 <MDP_ADMIN_NOMINATIF>, Temporary = Off
  4. Onglet Role mappingAssign role → filtrer sur realm rolesadmin
  5. Se déconnecter, se reconnecter avec le nouveau compte
  6. Supprimer l'utilisateur de bootstrap
  7. Retirer les variables du .env :
vim /opt/keycloak/.env
# commenter KC_BOOTSTRAP_ADMIN_USERNAME / KC_BOOTSTRAP_ADMIN_PASSWORD
docker compose up -d

Ces variables ne servent qu'à la création initiale ; leur présence ultérieure est un secret exposé inutilement dans l'environnement du conteneur.

7.3 Activer le MFA sur le realm master

AuthenticationRequired actions → activer Configure OTP (Set as default action). Ou, plus strict : Authentication → flow browser → dupliquer → passer l'exécution OTP Form en Required → lier le flow au realm.

7.4 Protection contre le bruteforce

Realm settingsSecurity defensesBrute force detection

ParamètreValeur conseillée
ModeLockout temporarily
Max login failures5
Wait increment1 minute
Max wait15 minutes
Failure reset time12 heures
Quick login check1000 ms

7.5 Configuration SMTP

Realm settingsEmail : renseigner hôte, port, expéditeur, authentification, TLS. Utiliser le bouton Test connection (nécessite une adresse e-mail sur le compte admin connecté).

7.6 Durées de session et de jetons

Realm settingsSessions et Tokens. Valeurs de départ raisonnables :

ParamètreValeur
SSO Session Idle30 min
SSO Session Max10 h
Access Token Lifespan5 min
Client login timeout1 min

7.7 Créer le realm applicatif

Ne jamais héberger les utilisateurs finaux dans master (réservé à l'administration de Keycloak).

Manage realmsCreate realm → nom : corporate (par exemple) → Create.

Les clients OIDC/SAML de tes applications seront créés dans ce realm.

7.8 Export d'un realm (sauvegarde de configuration)

cd /opt/keycloak
docker compose exec keycloak /opt/keycloak/bin/kc.sh export \
  --dir /opt/keycloak/data/export --realm corporate --users realm_file
docker compose cp keycloak:/opt/keycloak/data/export ./imports/export-$(date +%F)

8. Exploitation

8.1 Commandes courantes

cd /opt/keycloak

docker compose ps                    # état
docker compose logs -f keycloak      # logs temps réel
docker compose logs --tail=200 postgres
docker compose restart keycloak      # redémarrage applicatif
docker compose down                  # arrêt (volumes conservés)
docker compose up -d                 # démarrage
docker stats --no-stream             # consommation CPU/RAM

8.2 Démarrage automatique au boot

restart: unless-stopped + docker.service activé suffisent. Vérification :

sudo systemctl is-enabled docker      # → enabled
sudo reboot
# après redémarrage :
docker compose -f /opt/keycloak/docker-compose.yml ps
curl -sSI https://sso.<DOMAINE_PUBLIC>/realms/master | head -1

8.3 Script de sauvegarde PostgreSQL

sudo vim /usr/local/sbin/keycloak-backup.sh
#!/bin/bash
# Sauvegarde de la base Keycloak + configuration
set -euo pipefail

KC_DIR="/opt/keycloak"
BACKUP_DIR="${KC_DIR}/backups"
RETENTION_DAYS=30
STAMP=$(date +%Y%m%d-%H%M%S)

# Lecture ciblée des variables : on n'exécute PAS le .env.
# (un `source` casserait sur toute valeur contenant un espace, ex. JAVA_OPTS_APPEND)
get_env() { grep -E "^${1}=" "${KC_DIR}/.env" | head -1 | cut -d= -f2- | tr -d '"'"'"''; }

PG_USER="$(get_env POSTGRES_USER)"
PG_DB="$(get_env POSTGRES_DB)"

[[ -n "$PG_USER" && -n "$PG_DB" ]] || { echo "ERREUR : POSTGRES_USER/DB introuvables" >&2; exit 1; }

mkdir -p "${BACKUP_DIR}"

# Dump base
docker compose -f "${KC_DIR}/docker-compose.yml" exec -T postgres \
  pg_dump -U "${PG_USER}" -d "${PG_DB}" --clean --if-exists \
  | gzip > "${BACKUP_DIR}/keycloak-db-${STAMP}.sql.gz"

# Contrôle d'intégrité : un pg_dump en échec produit un .gz valide mais vide
if [[ $(zcat "${BACKUP_DIR}/keycloak-db-${STAMP}.sql.gz" | wc -l) -lt 50 ]]; then
    echo "ERREUR : dump anormalement court" >&2
    exit 1
fi

# Sauvegarde config (contient le .env, donc des secrets : protéger le dossier !)
tar czf "${BACKUP_DIR}/keycloak-conf-${STAMP}.tar.gz" \
    -C "${KC_DIR}" docker-compose.yml .env providers themes

# Rotation
find "${BACKUP_DIR}" -name 'keycloak-*' -type f -mtime +${RETENTION_DAYS} -delete

echo "[$(date)] Sauvegarde OK : keycloak-db-${STAMP}.sql.gz ($(du -h "${BACKUP_DIR}/keycloak-db-${STAMP}.sql.gz" | cut -f1))"
sudo chmod 750 /usr/local/sbin/keycloak-backup.sh
sudo chmod 700 /opt/keycloak/backups
sudo /usr/local/sbin/keycloak-backup.sh
ls -lh /opt/keycloak/backups/

# Contrôle du contenu réel du dump
zcat /opt/keycloak/backups/keycloak-db-*.sql.gz | head -20
zcat /opt/keycloak/backups/keycloak-db-*.sql.gz | grep -c "CREATE TABLE"

Ne jamais se fier à la seule présence du fichier : vérifier qu'il contient bien l'en-tête -- PostgreSQL database dump et une trentaine de CREATE TABLE.

8.4 Planification (systemd timer)

sudo vim /etc/systemd/system/keycloak-backup.service
[Unit]
Description=Sauvegarde Keycloak
After=docker.service

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/keycloak-backup.sh
sudo vim /etc/systemd/system/keycloak-backup.timer
[Unit]
Description=Sauvegarde Keycloak quotidienne

[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now keycloak-backup.timer
systemctl list-timers keycloak-backup.timer

Les sauvegardes doivent être externalisées (rsync/Borg/Restic vers un dépôt distant). Un dump resté sur la VM ne protège pas d'une perte de la VM.

8.5 Restauration

cd /opt/keycloak
docker compose stop keycloak

zcat backups/keycloak-db-AAAAMMJJ-HHMMSS.sql.gz | \
  docker compose exec -T postgres psql -U keycloak -d keycloak

docker compose start keycloak
docker compose logs -f keycloak

8.6 Procédure de mise à jour

Keycloak ne propose pas de version LTS : seule la dernière version mineure reçoit les correctifs de sécurité. Prévoir une montée de version trimestrielle.

cd /opt/keycloak

# 1. Sauvegarde impérative
sudo /usr/local/sbin/keycloak-backup.sh

# 2. Lire le guide de migration de la version cible
#    https://www.keycloak.org/docs/latest/upgrading/

# 3. Mettre à jour le tag
vim .env        # KEYCLOAK_VERSION=26.8.0

# 4. Télécharger puis appliquer
docker compose pull
docker compose up -d

# 5. Surveiller la migration de schéma
docker compose logs -f keycloak

# 6. Contrôler
curl -s https://sso.<DOMAINE_PUBLIC>/realms/master/.well-known/openid-configuration | jq .issuer

Retour arrière : remettre l'ancien tag dans .env, docker compose up -d, puis restaurer le dump si le schéma a été migré (une migration de schéma n'est pas réversible).

8.7 Nettoyage périodique

docker image prune -a -f --filter "until=720h"
docker system df

9. Personnalisation graphique (thème corporate)

9.1 Principe

Le thème hérite des thèmes intégrés (parent=keycloak pour le login, parent=keycloak.v3 pour la console utilisateur) et ne surcharge que les feuilles de style. Aucun template FreeMarker n'est copié : les montées de version restent sans risque, alors qu'un thème dupliquant les .ftl casse à chaque version majeure.

Depuis Keycloak 24, les thèmes intégrés ne sont plus des répertoires sur disque mais sont empaquetés dans un JAR. Le répertoire /opt/keycloak/themes de l'image est vide par conception : c'est l'emplacement prévu pour les thèmes personnalisés. Le bind mount ne masque donc rien.

9.2 Arborescence

/opt/keycloak/themes/corporate/
├── login/
│   ├── theme.properties              # parent=keycloak
│   └── resources/{css,img,fonts}/
├── account/
│   ├── theme.properties              # parent=keycloak.v3
│   └── resources/{css,img}/
├── email/theme.properties            # parent=keycloak
└── welcome/theme.properties          # parent=keycloak

Le volume ./themes:/opt/keycloak/themes:ro étant déjà déclaré, aucune modification de Compose n'est nécessaire.

9.3 Installation

Le script install-theme-corporate.sh crée l'arborescence, télécharge les polices auto-hébergées depuis <DOMAINE_PUBLIC> et génère les feuilles de style.

cd /opt/keycloak
chmod +x install-theme-corporate.sh
./install-theme-corporate.sh

find /opt/keycloak/themes -type f | sort
ls -lh /opt/keycloak/themes/corporate/login/resources/fonts/

Les polices sont volontairement auto-hébergées : une page d'authentification ne doit pas dépendre d'un CDN externe, ni pour la disponibilité ni pour la confidentialité.

9.4 Mode développement de thème

Par défaut Keycloak met les thèmes en cache : chaque modification de CSS demanderait un redémarrage du conteneur.

cd /opt/keycloak
cat >> .env <<'EOF'

# --- Développement de thème (À RETIRER une fois le thème figé) ---
KC_SPI_THEME_CACHE_THEMES=false
KC_SPI_THEME_CACHE_TEMPLATES=false
KC_SPI_THEME_STATIC_MAX_AGE=-1
EOF

docker compose up -d
docker compose exec keycloak env | grep -i theme

Retour en production une fois le rendu validé :

sed -i '/KC_SPI_THEME/d;/Développement de thème/d' /opt/keycloak/.env
docker compose up -d
docker compose exec keycloak env | grep -i theme    # → true / true / 2592000

Le cache désactivé fait relire les fichiers du thème à chaque affichage de page. Parfait pour itérer, à ne pas laisser en production.

9.5 Activation

Console admin → Realm settings → onglet Themes :

ChampValeur
Login themecorporate
Account themecorporate
Email themecorporate

À refaire sur chaque realm : le réglage n'est pas hérité du realm master.

Test en navigation privée : https://sso.<DOMAINE_PUBLIC>/realms/<realm>/account

9.6 Palette appliquée

Reprise de https://<DOMAINE_PUBLIC>/assets/charte.css :

VariableoklchRepli hex
--bgoklch(0.98 0.004 240)#f6f9fb
--textoklch(0.2 0.014 240)#1e242b
--mutedoklch(0.46 0.02 240)#5f6a75
--accentoklch(0.5 0.14 195)#0f7a7a
--accent-hoveroklch(0.42 0.14 195)#0a6363
--borderoklch(0.88 0.012 240)#d8dee5
--whiteoklch(0.99 0.004 240)#f9fcfe

Polices : Inter (variable 400–800) et IBM Plex Mono (codes OTP).

Le bloc @supports not (color: oklch(...)) fournit les équivalents hexadécimaux pour les navigateurs antérieurs à 2023.

9.7 Limites connues

  • Console d'administration : non thématisable depuis Keycloak 24, et c'est souhaitable — distinguer visuellement l'admin du public réduit le risque d'erreur.
  • E-mails : seules les couleurs héritées sont reprises. Un rebranding complet impose de copier email/html/template.ftl, ce qui réintroduit la dépendance aux templates.
  • Préfixes PatternFly : ils changent selon les versions (pf-c-pf-v5-c-pf-v6-c-). Les feuilles de style ciblent les trois formes plus un sélecteur générique [class*="pf-"].

10. Recette / checklist de validation

#ContrôleCommande / actionAttendu
1Docker opérationneldocker compose ps2 conteneurs Up (healthy)
2Écoute restreintess -tlnp | grep 8080<IP_SSO>:8080 uniquement
3Filtrage réseaucurl -m5 http://<IP_SSO>:8080/ depuis un poste tierstimeout
4Accès proxymême commande depuis <IP_PROXY>200 / 30x
5Health/health/ready via port 9000 en interne"status": "UP"
6Résolution DNS publiquedig +short sso.<DOMAINE_PUBLIC>IP publique du proxy
7Redirection HTTPcurl -sSI http://sso.<DOMAINE_PUBLIC>301 vers HTTPS
8Certificatcurl -vI https://sso.<DOMAINE_PUBLIC> 2>&1 | grep -i 'issuer|subject'Let's Encrypt, CN sso.<DOMAINE_PUBLIC>
9Issuer OIDCcurl -s .../.well-known/openid-configuration | jq .issuerhttps://sso.<DOMAINE_PUBLIC>/realms/master
10Console adminnavigateurConnexion OK, pas de boucle
11Endpoints internes masquéscurl -sI https://sso.<DOMAINE_PUBLIC>/metrics404
12Non-régression nginxcurl -sSI https://<autres sites>200/30x inchangés
13Renouvellement TLScertbot renew --dry-runsuccès sur tous les domaines
14Persistance au rebootreboot puis test 9service remonté seul
15Sauvegarde/usr/local/sbin/keycloak-backup.sharchive présente et non vide
16Restaurationtest sur environnement de recetterealm intact
17Thème chargéRealm settings > Themescorporate proposé et sélectionnable
18Rendu loginnavigation privée sur /realms/master/accountlogo et couleurs de la charte

11. Dépannage

11.1 Boucle de redirection infinie / « HTTPS required »

Cause : Keycloak ne voit pas X-Forwarded-Proto: https.

docker compose exec keycloak env | grep -i proxy
# KC_PROXY_HEADERS doit valoir "xforwarded"

Vérifier côté nginx que proxy_set_header X-Forwarded-Proto $scheme; est bien présent dans chaque bloc location qui fait du proxy_pass (les proxy_set_header ne sont pas hérités si un location en redéclare un).

11.2 Erreur 502 Bad Gateway

# Depuis le proxy
curl -v http://<IP_SSO>:8080/realms/master
tail -50 /var/log/nginx/sso.<DOMAINE_PUBLIC>-error.log
# Depuis le serveur Keycloak
docker compose ps && docker compose logs --tail=100 keycloak
sudo iptables -L DOCKER-USER -n -v

Causes fréquentes : conteneur en cours de démarrage, règle DOCKER-USER mal ordonnée, flux inter-VLAN fermé.

11.3 Erreur 431 / 400 « Request Header Fields Too Large »

Cookies Keycloak volumineux. Augmenter côté nginx :

large_client_header_buffers 8 64k;
proxy_buffer_size 256k;
proxy_buffers 8 512k;
proxy_busy_buffers_size 512k;

11.4 URL générées avec l'IP ou en HTTP

docker compose exec keycloak env | grep -i hostname

KC_HOSTNAME doit valoir l'URL complète https://sso.<DOMAINE_PUBLIC> et KC_HOSTNAME_STRICT=true.

11.5 Keycloak refuse les en-têtes du proxy

Si KC_PROXY_TRUSTED_ADDRESSES est actif et que Keycloak ignore les X-Forwarded-*, l'IP source vue par le conteneur n'est pas <IP_PROXY> :

docker compose logs keycloak | grep -i 'proxy\|forwarded'
# Vérifier l'IP source réellement vue
docker compose exec keycloak bash -c 'cat /proc/net/tcp' | head

Vérifier que "userland-proxy": false est bien appliqué dans /etc/docker/daemon.json (§3.5). En dépannage, commenter temporairement KC_PROXY_TRUSTED_ADDRESSES — mais le filtrage réseau du §5.3 devient alors la seule protection.

11.6 Script d'exploitation : « command not found » sur une ligne du .env

/opt/keycloak/.env: line 30: -Djava.net.preferIPv4Stack=true: command not found

Cause : une valeur contenant un espace n'est pas quotée. Docker Compose tolère cette syntaxe, mais source en bash interprète le second mot comme une commande à exécuter. Avec set -e, le script s'interrompt immédiatement et ne produit aucun fichier.

Correctif : quoter la valeur.

cd /opt/keycloak && cp .env .env.bak
sed -i 's|^JAVA_OPTS_APPEND=.*|JAVA_OPTS_APPEND="-XX:MaxRAMPercentage=70 -Djava.net.preferIPv4Stack=true"|' .env
docker compose config | grep -i java_opts     # Compose retire les guillemets

Compose transmet la même valeur au conteneur : aucun redémarrage requis.

11.7 Échec de connexion à la base

docker compose logs postgres | tail -50
docker compose exec postgres pg_isready -U keycloak -d keycloak
docker compose exec keycloak bash -c 'echo > /dev/tcp/postgres/5432 && echo OK'

11.8 Le healthcheck échoue alors que le service répond

L'astuce /dev/tcp dépend de bash dans l'image. Si le conteneur reste unhealthy à tort, commenter le bloc healthcheck: du service keycloak — il n'est pas indispensable en instance unique.

11.9 Certbot échoue sur le challenge HTTP-01

sudo certbot certonly --webroot -w /var/www/certbot -d sso.<DOMAINE_PUBLIC> --dry-run -v
sudo tail -100 /var/log/letsencrypt/letsencrypt.log
dig +short sso.<DOMAINE_PUBLIC> @8.8.8.8

Points à vérifier : DNS public propagé, 80/TCP ouvert depuis Internet, aucun location / capturant /.well-known/ avant le bloc dédié (le ^~ du fichier assure la priorité), droits www-data sur /var/www/certbot.

11.10 Récupérer la configuration nginx en cas d'erreur

sudo systemctl stop nginx
sudo rm -f /etc/nginx/sites-enabled/sso.<DOMAINE_PUBLIC>.conf
sudo tar xzf /root/nginx-backup-<date>.tar.gz -C /
sudo nginx -t && sudo systemctl start nginx

Annexe A — Image optimisée (démarrage accéléré)

Avec command: start, Keycloak reconstruit sa configuration Quarkus à chaque démarrage (~20-40 s supplémentaires). Pour une instance de production, on préconstruit l'image.

mkdir -p /opt/keycloak/build
vim /opt/keycloak/build/Dockerfile
ARG KC_VERSION=26.7.0

FROM quay.io/keycloak/keycloak:${KC_VERSION} AS builder

ENV KC_DB=postgres
ENV KC_HEALTH_ENABLED=true
ENV KC_METRICS_ENABLED=true
ENV KC_CACHE=local

WORKDIR /opt/keycloak
RUN /opt/keycloak/bin/kc.sh build

FROM quay.io/keycloak/keycloak:${KC_VERSION}
COPY --from=builder /opt/keycloak/ /opt/keycloak/
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]

Modifications dans docker-compose.yml :

  keycloak:
    build:
      context: ./build
      args:
        KC_VERSION: ${KEYCLOAK_VERSION}
    image: local/keycloak:${KEYCLOAK_VERSION}
    command:
      - start
      - --optimized
cd /opt/keycloak
docker compose build --no-cache
docker compose up -d

⚠️ Avec --optimized, les options de build (db, health-enabled, metrics-enabled, features, cache) doivent être fixées dans le Dockerfile : les passer en variables d'environnement au runtime provoquera une erreur au démarrage.

Annexe B — Récapitulatif des flux réseau

SourceDestinationPortProtocoleUsage
InternetIP publique → <IP_PROXY>80/TCPHTTPRedirection + challenge ACME
InternetIP publique → <IP_PROXY>443/TCPHTTPSAccès SSO
<IP_PROXY><IP_SSO>8080/TCPHTTPProxy → Keycloak
<IP_SSO>Internet443/TCPHTTPSquay.io, download.docker.com, deb.debian.org
<IP_SSO>DNS interne53/UDP+TCPDNSRésolution
<IP_SSO>NTP123/UDPNTPHorloge
Admins (<RESEAU_ADMIN>)<IP_SSO>22/TCPSSHAdministration
Interne (conteneurs)postgres5432/TCPPostgreSQLRéseau Docker uniquement

Annexe C — Fichiers créés ou modifiés

Sur <SRV_SSO>

CheminNature
/etc/hostsmodifié
/etc/apt/keyrings/docker.asccréé
/etc/apt/sources.list.d/docker.listcréé
/etc/docker/daemon.jsoncréé
/opt/keycloak/.envcréé (secrets, chmod 600)
/opt/keycloak/docker-compose.ymlcréé
/usr/local/sbin/docker-firewall.shcréé
/etc/systemd/system/docker-firewall.servicecréé
/usr/local/sbin/keycloak-backup.shcréé
/etc/systemd/system/keycloak-backup.{service,timer}créés

Sur <SRV_PROXY>

CheminNature
/etc/nginx/sites-available/sso.<DOMAINE_PUBLIC>.confcréé
/etc/nginx/sites-enabled/sso.<DOMAINE_PUBLIC>.confcréé (lien symbolique)
/var/www/certbot/créé
/etc/letsencrypt/live/sso.<DOMAINE_PUBLIC>/créé par certbot
/etc/letsencrypt/renewal/sso.<DOMAINE_PUBLIC>.confcréé par certbot
/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.shcréé

Aucun fichier de configuration existant n'est modifié. La méthode certbot certonly --webroot et l'ajout d'un vhost isolé garantissent l'absence d'impact sur les sites déjà en production.

Annexe D — Récapitulatif des secrets à enregistrer

Dans la version renseignée de ce document, cette annexe contient des secrets en clair : la stocker dans un coffre-fort et ne jamais la diffuser sur un partage réseau, un wiki ou un dépôt Git.

D.1 Tableau de report

Réf.ÉlémentUtilisateur / CompteMot de passeOù il est stockéRotation
S1PostgreSQL (conteneur keycloak-postgres)keycloak<MDP_POSTGRES>/opt/keycloak/.envPOSTGRES_PASSWORDAnnuelle
S2Keycloak — admin de bootstrapbootstrapadmin<MDP_ADMIN_BOOTSTRAP>/opt/keycloak/.envKC_BOOTSTRAP_ADMIN_PASSWORDÀ supprimer après §7.2
S3Keycloak — admin nominatif realm masteradm.<TRIGRAMME><MDP_ADMIN_NOMINATIF>Base Keycloak (haché)Semestrielle + MFA
S4Réserve (compte de service / usage futur)<MDP_RESERVE>Coffre-fort uniquement

D.2 Points de vigilance

S1 — PostgreSQL. Ce mot de passe est présent en clair dans /opt/keycloak/.env (mode 600) et injecté dans l'environnement des deux conteneurs. Il est donc lisible via docker inspect ou docker compose exec keycloak env par tout compte membre du groupe docker. C'est acceptable ici (base non exposée hors du réseau Docker), mais cela justifie de limiter strictement l'appartenance au groupe docker.

S2 — Compte de bootstrap. Il n'existe que pour la première connexion. Une fois le compte nominatif créé (§7.2), supprimer l'utilisateur dans Keycloak et retirer les deux variables du .env. Tant qu'elles restent présentes, un compte admin non nominatif reste disponible et non traçable.

S3 — Admin nominatif. À protéger par MFA (§7.3). C'est le seul compte qui doit subsister avec le rôle admin sur le realm master.

Ce qui n'est pas dans ce tableau et qu'il faudra ajouter au coffre-fort au fil de l'exploitation :

  • les client secrets OIDC générés par Keycloak à la création de chaque client confidentiel ;
  • le mot de passe du compte SMTP (§7.5) ;
  • les clés SSH d'accès à <SRV_SSO> et <SRV_PROXY> ;
  • la passphrase du dépôt de sauvegarde externalisé (§8.4).

D.3 Contrôle des permissions

# Sur <SRV_SSO>
ls -l /opt/keycloak/.env             # → -rw------- 1 <user> <user>
ls -ld /opt/keycloak/backups         # → drwx------
getent group docker                  # vérifier la liste des membres

D.4 Procédure de rotation du mot de passe PostgreSQL (S1)

cd /opt/keycloak
sudo /usr/local/sbin/keycloak-backup.sh          # sauvegarde préalable

NEW_PWD=$(openssl rand -base64 48 | tr -d '/+=\n' | cut -c1-32)
echo "Nouveau mot de passe : $NEW_PWD"           # → à reporter dans le coffre-fort

# 1. Changer le mot de passe dans PostgreSQL
docker compose exec -T postgres psql -U keycloak -d keycloak \
  -c "ALTER USER keycloak WITH PASSWORD '${NEW_PWD}';"

# 2. Mettre à jour le .env
sudo sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${NEW_PWD}|" .env

# 3. Recréer les conteneurs
docker compose up -d --force-recreate

# 4. Contrôler
docker compose ps
curl -s https://sso.<DOMAINE_PUBLIC>/realms/master/.well-known/openid-configuration | jq .issuer

Faire la rotation en dehors des heures de service : l'étape 3 provoque une coupure d'une trentaine de secondes.

Annexe E — Sources

  • Notes de version Keycloak 26.7.0 — keycloak.org (09/07/2026)
  • Guide de configuration Keycloak : chapitres Configuring the hostname (v2) et Using a reverse proxy
  • Documentation Docker : installation sur Debian (Trixie / Bookworm / Bullseye)
  • Documentation Certbot : plugin webroot, certonly, hooks de déploiement

Fin de procédure