Skip to main content
Cristhian Villegas
DevOps12 min read1 views

Keycloak con Docker Compose: multi-tenant por realm (ejemplo mx:ver:lerdo)

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.

Contenedores apilados representando arquitectura multi-tenant con Docker y Keycloak

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.

📌 Versiones confirmadas en este tutorial: Keycloak 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-lerdo no puede obtener un token válido para mx-jal-guadalajara ni 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-lerdo precargado 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) y lerdo-cli (público, para pruebas con curl).
  • Un endpoint funcional para pedir tokens con curl y decodificarlos con jq.

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:

bash
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:

bash
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.

bash
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
⚠️ Recordatorio obvio pero necesario: las contraseñas de este artículo son de ejemplo. Están pensadas para que copies, pegues y veas el flujo funcionando en tu laptop. Rota todo antes de tocar un entorno real. Más sobre eso en la sección 9.

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:

yaml
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-dev usa HTTP sin TLS y modo desarrollo. Para producción se usa start o start --optimized después de un build. Lo explico en la sección 9.
  • --import-realm le dice a Keycloak que lea todos los *.json del directorio /opt/keycloak/data/import al 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 antiguas KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD en Keycloak 26. Si copias un tutorial viejo con las variables antiguas, el contenedor arranca pero no puedes entrar a la consola.
  • El puerto 9000 es el puerto interno de management desde Keycloak 25 en adelante. Ahí viven /health y /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:

json
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-web es 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-cli es público con Direct Access Grants habilitado. Eso significa que puedo pedir un token con username + password vía curl, sin navegador, sin redirects. Es un antipattern en producción, pero para smoke tests en dev es de oro.
  • bruteForceProtected: true y accessTokenLifespan: 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:

UsuarioContraseñaRoles
admin.lerdoAdmin.Lerdo#2026admin, editor, viewer
juan.perezJuan.Perez#2026editor, viewer
maria.lopezMaria.Lopez#2026viewer

7. Levantar el stack y verificar que todo arrancó

Con los tres archivos en su sitio, un solo comando:

bash
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:

bash
1docker compose logs -f keycloak

Cuando veas algo parecido a esto, el servicio ya está listo:

bash
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:

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:

bash
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:

bash
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):

json
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:

Candado físico sobre un circuito, representando hardening de Keycloak

Fuente: FLY:D — Unsplash

  • Cambia la contraseña bootstrap del master. KC_BOOTSTRAP_ADMIN_PASSWORD sólo se usa la primera vez. Después, entra a la consola, crea un usuario admin con tu correo, asígnale admin en el master y borra al bootstrap.
  • TLS real, no HTTP. KC_HTTP_ENABLED=false, KC_HTTPS_CERTIFICATE_FILE y KC_HTTPS_CERTIFICATE_KEY_FILE apuntando a un cert emitido por Let's Encrypt. O, mejor todavía, un reverse proxy (nginx, Traefik, Caddy) terminando TLS y KC_PROXY_HEADERS=xforwarded en Keycloak.
  • start --optimized con un build previo. start-dev recompila providers en cada arranque. En producción se hace kc.sh build una vez en la imagen y kc.sh start --optimized al 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_HOSTNAME real. En local es localhost, en producción es el FQDN público del servicio. Si Keycloak emite un token con un iss distinto 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 *.json de importación es una semilla, no un backup. Monta pg_dump en un cron y guarda los volcados fuera del servidor.
  • Cambia el client secret de lerdo-web y desactiva lerdo-cli. El cliente público con Direct Access Grants sirve para pruebas en dev. En prod se borra o se queda deshabilitado.
🚨 Antipatrón común: exponer el puerto 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, email y clientId (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:

💡 El checklist de tres preguntas:
  1. ¿Obtengo un token con cada usuario del realm? Un curl por cada uno. Si alguno falla, la importación tiene un error tipográfico.
  2. ¿El iss del token coincide exactamente con el que mi backend valida? Copio el iss del payload y lo pego en la config del resource server. Un espacio de más y todo se rompe.
  3. ¿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í.

Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

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.