Procédure de déploiement — Keycloak (Docker Compose) sur Debian 13
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)
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
| Marqueur | Signification | Exemple |
|---|---|---|
<SRV_SSO> | Nom court du serveur Keycloak | SRV-SSO-01 |
<SRV_PROXY> | Nom court du reverse proxy | SRV-RP-01 |
<DOMAINE_AD> | Domaine interne | interne.lan |
<DOMAINE_PUBLIC> | Domaine public (certificats) | example.com |
<IP_SSO> | IP du serveur Keycloak | 192.0.2.30 |
<IP_PROXY> | IP du reverse proxy | 192.0.2.10 |
<GW_SSO> | Passerelle du VLAN Keycloak | 192.0.2.254 |
<GW_PROXY> | Passerelle du VLAN proxy | 198.51.100.254 |
<VLAN_SSO> | Sous-réseau Keycloak | 192.0.2.0/24 |
<VLAN_PROXY> | Sous-réseau du proxy | 198.51.100.0/24 |
<RESEAU_ADMIN> | Plage d'administration (SSH) | 10.0.0.0/8 |
<DNS1> / <DNS2> | Résolveurs internes | 192.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
| Marqueur | Usage | Stockage |
|---|---|---|
<MDP_POSTGRES> | Utilisateur PostgreSQL keycloak | .env → POSTGRES_PASSWORD |
<MDP_ADMIN_BOOTSTRAP> | Compte admin temporaire | .env → KC_BOOTSTRAP_ADMIN_PASSWORD |
<MDP_ADMIN_NOMINATIF> | Compte admin nominatif | Base Keycloak (haché) |
<MDP_RESERVE> | Réserve / compte de service | Coffre-fort |
Identités et marque
| Marqueur | Signification | Exemple |
|---|---|---|
<NOM_ORGANISATION> | Raison sociale affichée | Contoso |
<TRIGRAMME> | Trigramme de l'administrateur | adm.jdo |
corporate | Nom du thème graphique | à renommer librement |
Éléments à adapter sans marqueur
- Interface réseau :
ens18dans les exemples, à vérifier avecip -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ôle | Nom | FQDN interne | IP | OS |
|---|---|---|---|---|
| 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ément | Valeur |
|---|---|
| URL publique | https://sso.<DOMAINE_PUBLIC> |
| Port HTTP interne Keycloak | 8080 (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ées | PostgreSQL 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/TCPouvert 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: yesdoit 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 noavant 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îneDOCKER-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_CODENAMEdoit renvoyertrixie. 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. | Usage | Valeur |
|---|---|---|
| S1 | Base PostgreSQL — utilisateur keycloak | <MDP_POSTGRES> |
| S2 | Compte admin de bootstrap Keycloak (bootstrapadmin) — temporaire | <MDP_ADMIN_BOOTSTRAP> |
| S3 | Compte admin nominatif Keycloak (adm.<TRIGRAMME>) — voir §7.2 | <MDP_ADMIN_NOMINATIF> |
| S4 | Ré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
| Variable | Rôle |
|---|---|
KC_HOSTNAME | URL 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=true | Refuse 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=true | Indispensable en terminaison TLS déportée : Keycloak accepte du HTTP en clair depuis le proxy. |
KC_PROXY_HEADERS=xforwarded | Active 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_ADDRESSES | N'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=local | Instance unique. Évite les tentatives de découverte JGroups qui ralentissent le démarrage et polluent les logs. |
KC_HEALTH_ENABLED | Expose /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 vershttps://sso.<DOMAINE_PUBLIC>est normal :hostname-strictforce 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-ceen 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 -Tte 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 validationauthenticator = 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 ecdsapour rester homogène avec les certificats existants.--deploy-hookdoit être repassé à l'émission réelle : en--dry-run, certbot n'écrit rien dans/etc/letsencrypt/renewal/. Cette option écritrenew_hookdirectement 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
certonlygarantit que certbot n'installe rien et ne touche à aucun fichier nginx existant. Le--cert-nameisole 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 -tvalide 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/*.confsous forme derenew_hook), n'en ajoute pas un second : un simplereloaden 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 succeededpour 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 :
| Champ | Valeur |
|---|---|
| Utilisateur | bootstrapadmin |
| 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'
issuercontienthttp://ou une IP, la configuration proxy est incorrecte → voir §11.
7.2 Remplacer le compte de bootstrap
- Realm
master→ Users → Add user - Créer un compte nominatif (ex.
adm.<TRIGRAMME>), renseigner e-mail, prénom, nom - Onglet Credentials → Set password → saisir le secret S3
<MDP_ADMIN_NOMINATIF>,Temporary = Off - Onglet Role mapping → Assign role → filtrer sur realm roles →
admin - Se déconnecter, se reconnecter avec le nouveau compte
- Supprimer l'utilisateur de bootstrap
- 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
Authentication → Required 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 settings → Security defenses → Brute force detection
| Paramètre | Valeur conseillée |
|---|---|
| Mode | Lockout temporarily |
| Max login failures | 5 |
| Wait increment | 1 minute |
| Max wait | 15 minutes |
| Failure reset time | 12 heures |
| Quick login check | 1000 ms |
7.5 Configuration SMTP
Realm settings → Email : 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 settings → Sessions et Tokens. Valeurs de départ raisonnables :
| Paramètre | Valeur |
|---|---|
| SSO Session Idle | 30 min |
| SSO Session Max | 10 h |
| Access Token Lifespan | 5 min |
| Client login timeout | 1 min |
7.7 Créer le realm applicatif
Ne jamais héberger les utilisateurs finaux dans master (réservé à l'administration de Keycloak).
Manage realms → Create 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 dumpet une trentaine deCREATE 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/themesde 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 :
| Champ | Valeur |
|---|---|
| Login theme | corporate |
| Account theme | corporate |
| Email theme | corporate |
À 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 :
| Variable | oklch | Repli hex |
|---|---|---|
--bg | oklch(0.98 0.004 240) | #f6f9fb |
--text | oklch(0.2 0.014 240) | #1e242b |
--muted | oklch(0.46 0.02 240) | #5f6a75 |
--accent | oklch(0.5 0.14 195) | #0f7a7a |
--accent-hover | oklch(0.42 0.14 195) | #0a6363 |
--border | oklch(0.88 0.012 240) | #d8dee5 |
--white | oklch(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ôle | Commande / action | Attendu |
|---|---|---|---|
| 1 | Docker opérationnel | docker compose ps | 2 conteneurs Up (healthy) |
| 2 | Écoute restreinte | ss -tlnp | grep 8080 | <IP_SSO>:8080 uniquement |
| 3 | Filtrage réseau | curl -m5 http://<IP_SSO>:8080/ depuis un poste tiers | timeout |
| 4 | Accès proxy | même commande depuis <IP_PROXY> | 200 / 30x |
| 5 | Health | /health/ready via port 9000 en interne | "status": "UP" |
| 6 | Résolution DNS publique | dig +short sso.<DOMAINE_PUBLIC> | IP publique du proxy |
| 7 | Redirection HTTP | curl -sSI http://sso.<DOMAINE_PUBLIC> | 301 vers HTTPS |
| 8 | Certificat | curl -vI https://sso.<DOMAINE_PUBLIC> 2>&1 | grep -i 'issuer|subject' | Let's Encrypt, CN sso.<DOMAINE_PUBLIC> |
| 9 | Issuer OIDC | curl -s .../.well-known/openid-configuration | jq .issuer | https://sso.<DOMAINE_PUBLIC>/realms/master |
| 10 | Console admin | navigateur | Connexion OK, pas de boucle |
| 11 | Endpoints internes masqués | curl -sI https://sso.<DOMAINE_PUBLIC>/metrics | 404 |
| 12 | Non-régression nginx | curl -sSI https://<autres sites> | 200/30x inchangés |
| 13 | Renouvellement TLS | certbot renew --dry-run | succès sur tous les domaines |
| 14 | Persistance au reboot | reboot puis test 9 | service remonté seul |
| 15 | Sauvegarde | /usr/local/sbin/keycloak-backup.sh | archive présente et non vide |
| 16 | Restauration | test sur environnement de recette | realm intact |
| 17 | Thème chargé | Realm settings > Themes | corporate proposé et sélectionnable |
| 18 | Rendu login | navigation privée sur /realms/master/account | logo 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
| Source | Destination | Port | Protocole | Usage |
|---|---|---|---|---|
| Internet | IP publique → <IP_PROXY> | 80/TCP | HTTP | Redirection + challenge ACME |
| Internet | IP publique → <IP_PROXY> | 443/TCP | HTTPS | Accès SSO |
<IP_PROXY> | <IP_SSO> | 8080/TCP | HTTP | Proxy → Keycloak |
<IP_SSO> | Internet | 443/TCP | HTTPS | quay.io, download.docker.com, deb.debian.org |
<IP_SSO> | DNS interne | 53/UDP+TCP | DNS | Résolution |
<IP_SSO> | NTP | 123/UDP | NTP | Horloge |
Admins (<RESEAU_ADMIN>) | <IP_SSO> | 22/TCP | SSH | Administration |
| Interne (conteneurs) | postgres | 5432/TCP | PostgreSQL | Réseau Docker uniquement |
Annexe C — Fichiers créés ou modifiés
Sur <SRV_SSO>
| Chemin | Nature |
|---|---|
/etc/hosts | modifié |
/etc/apt/keyrings/docker.asc | créé |
/etc/apt/sources.list.d/docker.list | créé |
/etc/docker/daemon.json | créé |
/opt/keycloak/.env | créé (secrets, chmod 600) |
/opt/keycloak/docker-compose.yml | créé |
/usr/local/sbin/docker-firewall.sh | créé |
/etc/systemd/system/docker-firewall.service | créé |
/usr/local/sbin/keycloak-backup.sh | créé |
/etc/systemd/system/keycloak-backup.{service,timer} | créés |
Sur <SRV_PROXY>
| Chemin | Nature |
|---|---|
/etc/nginx/sites-available/sso.<DOMAINE_PUBLIC>.conf | créé |
/etc/nginx/sites-enabled/sso.<DOMAINE_PUBLIC>.conf | créé (lien symbolique) |
/var/www/certbot/ | créé |
/etc/letsencrypt/live/sso.<DOMAINE_PUBLIC>/ | créé par certbot |
/etc/letsencrypt/renewal/sso.<DOMAINE_PUBLIC>.conf | créé par certbot |
/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh | créé |
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ément | Utilisateur / Compte | Mot de passe | Où il est stocké | Rotation |
|---|---|---|---|---|---|
| S1 | PostgreSQL (conteneur keycloak-postgres) | keycloak | <MDP_POSTGRES> | /opt/keycloak/.env → POSTGRES_PASSWORD | Annuelle |
| S2 | Keycloak — admin de bootstrap | bootstrapadmin | <MDP_ADMIN_BOOTSTRAP> | /opt/keycloak/.env → KC_BOOTSTRAP_ADMIN_PASSWORD | À supprimer après §7.2 |
| S3 | Keycloak — admin nominatif realm master | adm.<TRIGRAMME> | <MDP_ADMIN_NOMINATIF> | Base Keycloak (haché) | Semestrielle + MFA |
| S4 | Ré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