Keycloak con Docker Compose: multi-tenant por realm (ejemplo mx:ver:lerdo)
El caso que llevo armando en varios proyectos multi-inquilino
Cada vez que aparece un sistema con más de un "cliente" dentro —una plataforma municipal, un SaaS con varias organizaciones, un portal interno donde cada dirección tiene sus propios usuarios— el patrón se repite con una precisión que ya casi parece folclor. El equipo quiere aislar a los usuarios por jurisdicción, poder nombrar cada tenant con una nomenclatura legible en voz alta (mx:ver:lerdo, mx:cdmx:cuauhtemoc, mx:jal:guadalajara) y no terminar con un árbol de ifs en el backend cada vez que alguien pide un login.
He probado varias rutas para resolverlo: una base de datos por cliente, schemas separados en PostgreSQL, un claim tenant en el JWT con ramificación manual, e incluso un proxy que reescribía rutas. Todas funcionan. Ninguna envejece bien. La que sí envejece bien, y la que recomiendo cuando me preguntan, es un realm de Keycloak por tenant. Es aburrida, está bien documentada y no tiene sorpresas.
![]()
Fuente: Venti Views — Unsplash
Este artículo es la guía concreta que me hubiera gustado tener la primera vez: cómo levantar Keycloak con Docker Compose, crear el realm mx-ver-lerdo, precargarlo con usuarios, roles y un cliente, y probar que el login funciona. Todo con copy-paste, todo reproducible, cero magia.
26.0.5, PostgreSQL 16-alpine, Docker Engine 27.x, Docker Compose v2.29+. Lo probé en Linux y en Docker Desktop para Windows. Los comandos son idénticos.
1. Por qué un realm por tenant (y no un claim en el JWT)
Antes del docker-compose.yml conviene decir por qué esta decisión. Si ya la tienes tomada, puedes saltar a la sección 2 sin remordimiento.
Un realm en Keycloak es un universo aislado: sus propios usuarios, sus propios roles, sus propios clientes, sus propios temas visuales, sus propios flujos de autenticación, sus propios tokens firmados con una llave independiente. Esto significa cuatro cosas muy prácticas:
- Aislamiento real. Un usuario del realm
mx-ver-lerdono puede obtener un token válido paramx-jal-guadalajarani por accidente ni por descuido, porque las llaves de firma son diferentes. - Rotación independiente. Si el municipio de Lerdo quiere forzar renovación de contraseñas, no tengo que tocar a ningún otro inquilino.
- Auditoría limpia. Los events de Keycloak ya vienen etiquetados por realm. No tengo que filtrar nada en consulta.
- Branding propio. Cada tenant puede tener su logo, sus colores y hasta su idioma por defecto en la pantalla de login sin tocar el backend.
El tradeoff honesto es que cada tenant añade un endpoint nuevo al gateway (/realms/{tenant}/...) y tu frontend tiene que saber a cuál apuntar. A cambio te ahorras el ejército de ifs que aparece cuando se intenta resolver todo con un claim.
2. Qué vamos a construir — la foto completa
Al final de este artículo vas a tener corriendo en tu máquina:
- Un contenedor Keycloak 26 escuchando en
http://localhost:8080. - Un contenedor PostgreSQL 16 como almacén persistente de Keycloak.
- Un realm llamado
mx-ver-lerdoprecargado al arrancar, con:- Tres roles:
admin,editor,viewer. - Tres usuarios con contraseñas conocidas (te las doy más abajo, son de ejemplo).
- Dos clientes:
lerdo-web(confidencial, para una SPA con backend) ylerdo-cli(público, para pruebas concurl).
- Tres roles:
- Un endpoint funcional para pedir tokens con
curly decodificarlos conjq.
La convención mx:ver:lerdo representa país:estado:ciudad. Keycloak usa el nombre del realm dentro de la URL, y los dos puntos ahí molestan a proxies y a clientes HTTP mal configurados, así que dentro de Keycloak el realm se llama mx-ver-lerdo con guiones. El displayName sí puede ser lo que tú quieras — ahí lo dejamos como "MX / Veracruz / Lerdo" para que cualquier humano lo entienda en voz alta.
3. Estructura del proyecto
Un árbol minimalista. Tres archivos reales, un directorio para el realm y nada más:
1keycloak-mx-ver-lerdo/
2├── .env
3├── docker-compose.yml
4└── realms/
5 └── mx-ver-lerdo-realm.json
Crea la carpeta y entra en ella:
1mkdir -p keycloak-mx-ver-lerdo/realms
2cd keycloak-mx-ver-lerdo
4. Variables de entorno — el archivo .env
Guarda esto como .env en la raíz del proyecto. Son los únicos secretos reales del stack. Nunca los subas al repositorio.
1# Base de datos que usa Keycloak
2POSTGRES_DB=keycloak
3POSTGRES_USER=keycloak
4POSTGRES_PASSWORD=Keycloak.DB#2026
5
6# Usuario bootstrap del master realm (consola /admin)
7KC_BOOTSTRAP_ADMIN_USERNAME=kcadmin
8KC_BOOTSTRAP_ADMIN_PASSWORD=ChangeMe.Master#2026
9
10# Hostname público de Keycloak (ajusta para producción)
11KC_HOSTNAME=localhost
5. docker-compose.yml explicado sin milagros
Este es el corazón del artículo. Guarda el archivo como docker-compose.yml en la raíz del proyecto:
1services:
2 postgres:
3 image: postgres:16-alpine
4 container_name: kc-postgres
5 restart: unless-stopped
6 environment:
7 POSTGRES_DB: ${POSTGRES_DB}
8 POSTGRES_USER: ${POSTGRES_USER}
9 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
10 volumes:
11 - kc-pgdata:/var/lib/postgresql/data
12 healthcheck:
13 test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
14 interval: 10s
15 timeout: 5s
16 retries: 10
17 networks:
18 - kc-net
19
20 keycloak:
21 image: quay.io/keycloak/keycloak:26.0.5
22 container_name: kc-server
23 restart: unless-stopped
24 command:
25 - start-dev
26 - --import-realm
27 environment:
28 # Base de datos
29 KC_DB: postgres
30 KC_DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
31 KC_DB_USERNAME: ${POSTGRES_USER}
32 KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
33
34 # Bootstrap del admin del master realm
35 KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME}
36 KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD}
37
38 # Hostname y HTTP (sólo aceptable en dev)
39 KC_HOSTNAME: ${KC_HOSTNAME}
40 KC_HTTP_ENABLED: "true"
41 KC_HOSTNAME_STRICT: "false"
42
43 # Observabilidad
44 KC_HEALTH_ENABLED: "true"
45 KC_METRICS_ENABLED: "true"
46 ports:
47 - "8080:8080"
48 volumes:
49 - ./realms:/opt/keycloak/data/import:ro
50 depends_on:
51 postgres:
52 condition: service_healthy
53 healthcheck:
54 test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/9000 && echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\n\r\n' >&3 && cat <&3 | grep -q '200 OK'"]
55 interval: 15s
56 timeout: 5s
57 start_period: 40s
58 retries: 10
59 networks:
60 - kc-net
61
62volumes:
63 kc-pgdata:
64
65networks:
66 kc-net:
67 driver: bridge
Los puntos que suelen causar tropiezos en este archivo:
start-devusa HTTP sin TLS y modo desarrollo. Para producción se usastartostart --optimizeddespués de unbuild. Lo explico en la sección 9.--import-realmle dice a Keycloak que lea todos los*.jsondel directorio/opt/keycloak/data/importal arrancar y cree los realms si no existen. El segundo restart no vuelve a importar, así que es seguro.KC_BOOTSTRAP_ADMIN_*reemplazó a las antiguasKEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORDen Keycloak 26. Si copias un tutorial viejo con las variables antiguas, el contenedor arranca pero no puedes entrar a la consola.- El puerto
9000es el puerto interno de management desde Keycloak 25 en adelante. Ahí viven/healthy/metrics. No se expone al host porque no hace falta.
6. El realm mx-ver-lerdo — usuarios, roles y clientes
Guarda este archivo como realms/mx-ver-lerdo-realm.json. Es el que Keycloak va a importar automáticamente al arrancar. Fíjate que los usuarios ya traen contraseña en texto plano (type: "password", temporary: false). Keycloak las hashea al importarlas, no se guardan así en la base:
1{
2 "realm": "mx-ver-lerdo",
3 "displayName": "MX / Veracruz / Lerdo",
4 "enabled": true,
5 "sslRequired": "external",
6 "registrationAllowed": false,
7 "loginWithEmailAllowed": true,
8 "duplicateEmailsAllowed": false,
9 "resetPasswordAllowed": true,
10 "editUsernameAllowed": false,
11 "bruteForceProtected": true,
12 "defaultSignatureAlgorithm": "RS256",
13 "accessTokenLifespan": 900,
14
15 "roles": {
16 "realm": [
17 { "name": "admin", "description": "Administra el tenant Lerdo" },
18 { "name": "editor", "description": "Crea y modifica contenido" },
19 { "name": "viewer", "description": "Sólo lectura" }
20 ]
21 },
22
23 "users": [
24 {
25 "username": "admin.lerdo",
26 "email": "[email protected]",
27 "firstName": "Admin",
28 "lastName": "Lerdo",
29 "enabled": true,
30 "emailVerified": true,
31 "credentials": [
32 { "type": "password", "value": "Admin.Lerdo#2026", "temporary": false }
33 ],
34 "realmRoles": ["admin", "editor", "viewer"]
35 },
36 {
37 "username": "juan.perez",
38 "email": "[email protected]",
39 "firstName": "Juan",
40 "lastName": "Pérez",
41 "enabled": true,
42 "emailVerified": true,
43 "credentials": [
44 { "type": "password", "value": "Juan.Perez#2026", "temporary": false }
45 ],
46 "realmRoles": ["editor", "viewer"]
47 },
48 {
49 "username": "maria.lopez",
50 "email": "[email protected]",
51 "firstName": "María",
52 "lastName": "López",
53 "enabled": true,
54 "emailVerified": true,
55 "credentials": [
56 { "type": "password", "value": "Maria.Lopez#2026", "temporary": false }
57 ],
58 "realmRoles": ["viewer"]
59 }
60 ],
61
62 "clients": [
63 {
64 "clientId": "lerdo-web",
65 "name": "Portal Lerdo (SPA + backend)",
66 "enabled": true,
67 "protocol": "openid-connect",
68 "publicClient": false,
69 "secret": "lerdo-web-secret-change-me-in-prod",
70 "standardFlowEnabled": true,
71 "directAccessGrantsEnabled": false,
72 "serviceAccountsEnabled": false,
73 "redirectUris": [
74 "http://localhost:3000/*",
75 "https://portal.lerdo.example.mx/*"
76 ],
77 "webOrigins": [
78 "http://localhost:3000",
79 "https://portal.lerdo.example.mx"
80 ],
81 "attributes": {
82 "pkce.code.challenge.method": "S256"
83 }
84 },
85 {
86 "clientId": "lerdo-cli",
87 "name": "CLI de pruebas (curl / httpie)",
88 "enabled": true,
89 "protocol": "openid-connect",
90 "publicClient": true,
91 "standardFlowEnabled": false,
92 "directAccessGrantsEnabled": true,
93 "redirectUris": [],
94 "webOrigins": []
95 }
96 ]
97}
Tres cosas que conviene notar:
lerdo-webes confidencial (publicClient: false), usa Authorization Code Flow con PKCE y tiene un client secret. Es el cliente "de verdad" que tu portal va a consumir.lerdo-clies público con Direct Access Grants habilitado. Eso significa que puedo pedir un token conusername+passwordvíacurl, sin navegador, sin redirects. Es un antipattern en producción, pero para smoke tests en dev es de oro.bruteForceProtected: trueyaccessTokenLifespan: 900(15 minutos) son defaults cuerdos. Los explícito para que los veas y los cambies con criterio, no por descuido.
Para que no quede duda, la tabla de credenciales de ejemplo:
| Usuario | Contraseña | Roles |
|---|---|---|
admin.lerdo | Admin.Lerdo#2026 | admin, editor, viewer |
juan.perez | Juan.Perez#2026 | editor, viewer |
maria.lopez | Maria.Lopez#2026 | viewer |
7. Levantar el stack y verificar que todo arrancó
Con los tres archivos en su sitio, un solo comando:
1docker compose up -d
La primera vez tarda uno o dos minutos porque descarga las imágenes y Keycloak crea el schema de su base. Ver qué está haciendo:
1docker compose logs -f keycloak
Cuando veas algo parecido a esto, el servicio ya está listo:
1kc-server | Imported realm mx-ver-lerdo from file /opt/keycloak/data/import/mx-ver-lerdo-realm.json
2kc-server | Keycloak 26.0.5 on JVM (powered by Quarkus 3.x.x) started in 18.412s.
3kc-server | Listening on: http://0.0.0.0:8080
4kc-server | Management interface listening on http://0.0.0.0:9000
Tres URLs para comprobar que todo responde:
http://localhost:8080/— portada de Keycloak.http://localhost:8080/admin— consola de administración. Entra conkcadmin/ChangeMe.Master#2026. Cambia en la esquina superior al realmmx-ver-lerdoy verifica que los tres usuarios y los dos clientes ya existen.http://localhost:8080/realms/mx-ver-lerdo/.well-known/openid-configuration— el documento de descubrimiento OIDC del tenant. Si carga JSON, todo funciona.
8. Probar el login con curl — el mejor smoke test
Antes de escribir una línea de frontend, demuestro que los tokens se emiten. Pido un access token para juan.perez usando el cliente público lerdo-cli:
1curl -s -X POST \
2 http://localhost:8080/realms/mx-ver-lerdo/protocol/openid-connect/token \
3 -H "Content-Type: application/x-www-form-urlencoded" \
4 -d "grant_type=password" \
5 -d "client_id=lerdo-cli" \
6 -d "username=juan.perez" \
7 -d "password=Juan.Perez#2026" \
8 -d "scope=openid"
La respuesta viene con access_token, refresh_token, expires_in, etc. Para ver el payload del JWT sin instalar nada más que jq y base64:
1TOKEN=$(curl -s -X POST \
2 http://localhost:8080/realms/mx-ver-lerdo/protocol/openid-connect/token \
3 -H "Content-Type: application/x-www-form-urlencoded" \
4 -d "grant_type=password" \
5 -d "client_id=lerdo-cli" \
6 -d "username=juan.perez" \
7 -d "password=Juan.Perez#2026" \
8 -d "scope=openid" | jq -r .access_token)
9
10echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .
Deberías ver algo así (recortado):
1{
2 "exp": 1744823412,
3 "iat": 1744822512,
4 "iss": "http://localhost:8080/realms/mx-ver-lerdo",
5 "aud": "account",
6 "sub": "c9a3...",
7 "typ": "Bearer",
8 "azp": "lerdo-cli",
9 "realm_access": {
10 "roles": ["editor", "viewer", "default-roles-mx-ver-lerdo"]
11 },
12 "preferred_username": "juan.perez",
13 "email": "[email protected]"
14}
Fíjate en dos cosas: iss apunta al realm específico (no al master) y realm_access.roles contiene exactamente los roles que definí en el JSON. Si tu backend valida el iss —y debería— este es el mecanismo que impide que un token de mx-jal-guadalajara se cuele en los endpoints de Lerdo.
9. Hardening mínimo antes de llevarlo a un servidor real
start-dev es cómodo, pero no es para producción. Aquí va la lista corta —no exhaustiva— de lo que yo nunca saco sin hacer antes:

Fuente: FLY:D — Unsplash
- Cambia la contraseña bootstrap del master.
KC_BOOTSTRAP_ADMIN_PASSWORDsólo se usa la primera vez. Después, entra a la consola, crea un usuario admin con tu correo, asígnaleadminen el master y borra al bootstrap. - TLS real, no HTTP.
KC_HTTP_ENABLED=false,KC_HTTPS_CERTIFICATE_FILEyKC_HTTPS_CERTIFICATE_KEY_FILEapuntando a un cert emitido por Let's Encrypt. O, mejor todavía, un reverse proxy (nginx, Traefik, Caddy) terminando TLS yKC_PROXY_HEADERS=xforwardeden Keycloak. start --optimizedcon unbuildprevio.start-devrecompila providers en cada arranque. En producción se hacekc.sh builduna vez en la imagen ykc.sh start --optimizedal correrla. El arranque baja de 18 segundos a 3.- Secrets fuera del
.env. Docker Secrets, HashiCorp Vault, AWS Secrets Manager, Azure Key Vault. Cualquiera es mejor que un archivo plano en el host. KC_HOSTNAMEreal. En local eslocalhost, en producción es el FQDN público del servicio. Si Keycloak emite un token con unissdistinto al que tus clientes esperan, el backend lo rechaza y pasas tres horas buscando el error.- Backup de la base. El realm vive en Postgres. El
*.jsonde importación es una semilla, no un backup. Montapg_dumpen un cron y guarda los volcados fuera del servidor. - Cambia el
client secretdelerdo-weby desactivalerdo-cli. El cliente público con Direct Access Grants sirve para pruebas en dev. En prod se borra o se queda deshabilitado.
8080 de Keycloak directo a internet y dejar KC_HOSTNAME_STRICT=false. Eso funciona pero te mete en un open relay de tokens. Pon un reverse proxy delante siempre.
10. Añadir el siguiente tenant — el patrón se repite
La gracia de elegir "un realm por tenant" es que agregar el siguiente es aburrido en el mejor sentido. Copia mx-ver-lerdo-realm.json, cámbiale:
"realm": "mx-jal-guadalajara""displayName": "MX / Jalisco / Guadalajara"- Los
username,emailyclientId(no mezcles clientes entre realms, aunque tengan el mismo nombre).
Pon el archivo en ./realms/, reinicia el contenedor (docker compose restart keycloak) y listo: dos tenants aislados, dos URLs de token (/realms/mx-ver-lerdo/... y /realms/mx-jal-guadalajara/...), dos conjuntos de llaves de firma. Tu backend sólo necesita saber a cuál apuntar según el host, el path o un header — esa decisión vive en el gateway, no en Keycloak.
11. El ritual con el que cierro cada deploy nuevo
Cuando termino de levantar un Keycloak así, corro exactamente estas tres validaciones antes de entregárselo a nadie. Es mi checklist personal y me ha ahorrado más de una llamada a deshoras:
- ¿Obtengo un token con cada usuario del realm? Un
curlpor cada uno. Si alguno falla, la importación tiene un error tipográfico. - ¿El
issdel token coincide exactamente con el que mi backend valida? Copio elissdel payload y lo pego en la config del resource server. Un espacio de más y todo se rompe. - ¿Un token emitido en el realm A es rechazado en el realm B? Lo pruebo. Dos tenants, un endpoint común, cruzo los tokens. Si cualquiera de los dos pasa, hay algo muy mal configurado en el gateway.
Keycloak no es magia. Es software bien documentado que acepta ser tratado como tal. Si le das una base de datos decente, un realm.json ordenado y un reverse proxy serio, te devuelve una capa de identidad que dura años sin pedirte atención. Que es, al final, lo único que uno le pide a un componente de infraestructura: desaparecer del radar y no volver a aparecer hasta que haga falta.
Si este artículo te sirvió, guárdalo. La siguiente vez que alguien te pida "autenticación multi-inquilino", el 80% del trabajo ya está escrito aquí.
Comments
Sign in to leave a comment
No comments yet. Be the first!
Related Articles
Mantente actualizado
Recibe notificaciones cuando publique nuevos artículos en español. Sin spam, cancela cuando quieras.