Keycloak en producción
Todo el curso ha usado start-dev: HTTP, URL deducida de cada petición, admin de prueba. Aquí montas la tienda como en un servidor real, con HTTPS y un proxy delante, y ves qué cambia en los programas Go. Spoiler: casi nada, pero hay cuatro detalles que conviene no descubrir en producción.
En este capítulo
- Trabajas en
- La infraestructura (una carpeta nueva,
infra/produccion) y la configuración de los programas Go: tienda-web, api-pedidos, facturacion y admin-tool. - Carpeta
tienda/pasos/paso-16.infra/sigue siendo el entorno de desarrollo del paso 15;infra/produccion/es el nuevo.- Archivos
- Nuevos:
infra/produccion/(Dockerfile,Caddyfile,docker-compose.yml,realm/,secretos/) einternal/config/config.go; cambianinternal/auth/auth.go,internal/apiauth/apiauth.goy losmain.goque leen secretos · ver todos los cambios del paso. - En marcha
- Solo la infraestructura de producción (para el entorno de desarrollo,
docker compose downeninfra/: ambos usan el AD). - Comprueba
tools/ci/verificar-produccion.sh(desdecourse/): levanta todo, lo comprueba por HTTPS y lo apaga.
Al terminar sabrás
- Qué cambia entre
start-devy un Keycloak de producción, y por qué. - Poner Keycloak detrás de un proxy con TLS: hostname, cabeceras y puertos.
- Qué deben hacer tus programas Go: issuer
https, confiar en la CA, timeouts, cookiesSecurey secretos. - Dónde están las trampas, porque las hemos pisado al preparar la lección.
1. Desarrollo frente a producción
Hasta ahora (start-dev) | Este paso (start --optimized) | |
|---|---|---|
| Arranque | Recompila según las opciones en cada inicio | Imagen construida una vez con kc.sh build |
| URL de Keycloak | La de cada petición (http://localhost:8080) | Fija: KC_HOSTNAME=https://localhost:8443. Es la que va en iss. |
| TLS | No | En Caddy, delante. Keycloak escucha HTTP solo en la red interna. |
| Puertos publicados | 8080 y 9000 | Solo 8443 (Caddy). El 9000 (health, métricas) queda dentro. |
| Secretos | En el docker-compose.yml | En un archivo aparte (secretos/produccion.env) |
| Programas Go | Valores por defecto para todo | TIENDA_ENTORNO=produccion: sin secretos por defecto |
KC_PROXY_HEADERS.2. La imagen optimizada
Algunas opciones de Keycloak son «de construcción»: la base de datos, si hay health y métricas… En desarrollo se aplican en cada arranque; en producción se fijan una vez al construir la imagen, y el servidor arranca con --optimized, más rápido y sin sorpresas.
# Keycloak «optimizado» para producción (lección 16): las opciones de build
# (base de datos, health, métricas) se fijan al construir la imagen, y el
# servidor arranca con «start --optimized», sin recompilarse en cada inicio.
FROM quay.io/keycloak/keycloak:26.8.0 AS builder
ENV KC_DB=postgres
ENV KC_HEALTH_ENABLED=true
ENV KC_METRICS_ENABLED=true
RUN /opt/keycloak/bin/kc.sh build
FROM quay.io/keycloak/keycloak:26.8.0
COPY --from=builder /opt/keycloak/ /opt/keycloak/
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
3. Hostname, proxy y TLS
# Caddy termina TLS y pasa las peticiones a Keycloak por la red interna.
# «tls internal»: Caddy crea su propia CA y un certificado para localhost.
# En un servidor real pondrías tu dominio y Caddy pediría el certificado a
# Let's Encrypt (o usarías el de tu empresa).
https://localhost:8443 {
tls internal
reverse_proxy keycloak:8080
}
# La tienda con un Keycloak «como en producción» (lección 16). Solo cambia la
# infraestructura: el realm, los usuarios y el AD de prueba son los del paso.
#
# cd infra/produccion
# docker compose up -d --build
# docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt
#
# Keycloak queda en https://localhost:8443 (a través de Caddy). Su puerto
# 8080 y el de gestión (9000) no se publican: solo los ve la red interna.
services:
ad:
image: instantlinux/samba-dc:4.23.10-r0
hostname: dc1
cap_add: [SYS_ADMIN]
environment:
REALM: tienda.local
WORKGROUP: TIENDA
NETBIOS_NAME: DC1
DOMAIN_ACTION: provision
BIND_INTERFACES_ONLY: "no"
secrets: [samba-admin-password]
volumes:
- ad-etc:/etc/samba
- ad-lib:/var/lib/samba
- ../ad/0globals.conf:/etc/samba/conf.d/0globals.conf:ro
ad-seed:
image: instantlinux/samba-dc:4.23.10-r0
entrypoint: ["sh", "/seed.sh"]
environment:
AD_ADMIN_PASSWORD_FILE: /run/secrets/samba-admin-password
secrets: [samba-admin-password]
volumes:
- ../ad/seed.sh:/seed.sh:ro
depends_on: [ad]
postgres:
image: postgres:17
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
env_file: secretos/produccion.env # POSTGRES_PASSWORD
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
interval: 5s
timeout: 3s
retries: 10
keycloak:
build: .
command: start --optimized --import-realm
environment:
# URL pública: la que ven navegadores y programas, y la que va en «iss».
KC_HOSTNAME: https://localhost:8443
# TLS lo termina Caddy: Keycloak escucha HTTP solo en la red interna y
# se fía de las cabeceras X-Forwarded-* que pone el proxy.
KC_HTTP_ENABLED: "true"
KC_PROXY_HEADERS: xforwarded
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
# Admin temporal para el primer arranque: crea uno permanente y bórralo.
KC_BOOTSTRAP_ADMIN_USERNAME: admin
# KC_DB_PASSWORD y KC_BOOTSTRAP_ADMIN_PASSWORD: fuera de este archivo.
env_file: secretos/produccion.env
volumes:
- ./realm:/opt/keycloak/data/import:ro
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
# La imagen no trae curl: preguntamos a /health/ready (puerto de gestión)
# con bash y miramos el código HTTP: 200 = listo, 503 = aún no. (Buscar
# «UP» en el cuerpo no sirve: los checks internos dicen UP antes.)
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && head -1 <&3 | grep -q ' 200 '"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
depends_on:
postgres:
condition: service_healthy
ad-seed:
condition: service_completed_successfully
caddy:
image: caddy:2.11.7-alpine
ports:
- "127.0.0.1:8443:8443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
depends_on:
keycloak:
condition: service_healthy
secrets:
samba-admin-password:
file: ../ad/admin-password
volumes:
pgdata:
ad-etc:
ad-lib:
caddy-data:
| Opción | Por qué |
|---|---|
KC_HOSTNAME | La URL pública, completa. Sin ella, Keycloak deduce la URL de cada petición: detrás de un proxy, o con varios nombres, los iss no cuadrarían. |
KC_HTTP_ENABLED=true | En producción el HTTP está apagado por defecto; aquí lo encendemos porque el TLS lo pone Caddy y el tráfico HTTP no sale de la red interna. |
KC_PROXY_HEADERS=xforwarded | Que Keycloak crea las cabeceras X-Forwarded-* de Caddy (esquema https, IP real del cliente). Solo si de verdad hay un proxy delante: si no, cualquiera podría falsearlas. |
| Healthcheck al 9000 | Caddy no arranca hasta que Keycloak está listo. Ver «Errores comunes»: lo hicimos mal la primera vez. |
Los realms, con la URL pública
Dos cosas de los realms dependen de la URL de Keycloak: el proveedor de la empresa (lección 12) y la redirect URI de tienda-broker. tools/realm/produccion.py deriva los realms de producción de los de desarrollo cambiando solo lo que ve el navegador y el issuer; las llamadas de servidor a servidor (token, JWKS, logout) siguen por http://localhost:8080 dentro del contenedor. Probado: la empresa funciona igual detrás del proxy.
4. Lo que cambia en los programas Go
El issuer y la CA
Solo cambia una variable: OIDC_ISSUER=https://localhost:8443/realms/tienda (y KEYCLOAK_URL=https://localhost:8443 en admin-tool). Pero Go tiene que confiar en el certificado. Caddy crea su propia CA (tls internal); en un servidor real sería Let's Encrypt o la CA de tu empresa. Sin confiar en ella, salida real:
tls: failed to verify certificate: x509: certificate signed by unknown authority
| Sistema | Cómo confía Go en la CA de Caddy |
|---|---|
| Linux, WSL, macOS | export SSL_CERT_FILE=/ruta/absoluta/caddy-root.crt (probado en WSL). Sustituye la lista de CA del sistema: vale para este ejercicio. |
| Windows | Go usa el almacén de certificados de Windows y no lee SSL_CERT_FILE. Importa la CA en el de tu usuario: Import-Certificate -FilePath caddy-root.crt -CertStoreLocation Cert:\CurrentUser\Root. No lo ejecutamos en el equipo del curso porque instala una CA en el sistema: hazlo solo en tu equipo de pruebas y bórrala al terminar. |
| El navegador | Lo mismo: confía en la CA del sistema. Sin ella verás un aviso de certificado al ir a Keycloak. |
Timeouts
go-oidc y x/oauth2 usan http.DefaultClient si no les das otro, y ese cliente no tiene timeout: si Keycloak deja de responder, cada login se queda colgado para siempre. Ambas librerías toman el cliente del contexto:
// Sin timeout, una petición a un Keycloak que no responde se queda
// colgada para siempre. go-oidc y x/oauth2 toman el cliente del contexto.
client := &http.Client{Timeout: 10 * time.Second}
ctx = oidc.ClientContext(ctx, client)
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
// withClient añade al contexto el cliente HTTP con timeout para que x/oauth2 y
// go-oidc lo usen al hablar con Keycloak.
func (a *Auth) withClient(ctx context.Context) context.Context {
return oidc.ClientContext(ctx, a.httpClient)
}
tienda-web lo usa en el canje del código, la renovación y userinfo. En api-pedidos basta con dárselo a NewProvider: lo vimos en el código de go-oidc, que guarda el contexto (sin su cancelación) para refrescar el JWKS cuando Keycloak rota las claves.
Cookies Secure
El paso 3 dejaba un comentario: «// Secure: true, // obligatorio en producción (HTTPS)». Ahora se decide solo: si la URL pública de tienda-web es https://, sus cookies llevan Secure. Probado con OIDC_REDIRECT_URL=https://tienda.example/callback:
Set-Cookie: tienda_state=I7LPYMNONBJT4DLFLUXIX7BHWA; Path=/callback; Max-Age=600; HttpOnly; Secure; SameSite=Lax
Secretos sin valor por defecto
Desde la lección 3, los secretos tienen un valor por defecto «solo para desarrollo». El riesgo es que lleguen a producción sin que nadie lo note. config.Secret lo impide:
// Package config lee la configuración de los programas de la tienda desde
// variables de entorno (lección 16).
package config
import (
"log"
"os"
)
// Env devuelve la variable de entorno key o, si no está, def.
func Env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// Production indica si el programa corre en producción (TIENDA_ENTORNO=produccion).
func Production() bool { return os.Getenv("TIENDA_ENTORNO") == "produccion" }
// Secret lee un secreto. En desarrollo usa devDefault si falta; en producción
// se niega a arrancar: un secreto «de ejemplo» en producción es peor que un error.
func Secret(key, devDefault string) string {
if v := os.Getenv(key); v != "" {
return v
}
if Production() {
log.Fatalf("falta %s: en producción los secretos no tienen valor por defecto", key)
}
return devDefault
}
$ TIENDA_ENTORNO=produccion go run ./cmd/web
falta OIDC_CLIENT_SECRET: en producción los secretos no tienen valor por defecto
5. Probarlo
cd tienda/pasos/paso-15/infra && docker compose down -v # libera el AD de desarrollo
cd ../../paso-16/infra/produccion
docker compose up -d --build
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt
cd ../.. # tienda/pasos/paso-16
export SSL_CERT_FILE=$PWD/infra/produccion/caddy-root.crt
export TIENDA_ENTORNO=produccion OIDC_ISSUER=https://localhost:8443/realms/tienda
go run ./cmd/api &
OIDC_CLIENT_SECRET=tienda-web-secret go run ./cmd/web &
OIDC_CLIENT_SECRET=facturacion-secret go run ./cmd/facturacion &
cd ../../.. # course/
go -C tools/comprobar run . -paso 16 -keycloak https://localhost:8443 -servicios
# … las 18 comprobaciones en verde, ahora por HTTPS
# Todo en orden para el paso 16.
O todo de una vez, como hace la verificación del curso: tools/ci/verificar-produccion.sh (salida real: «✔ producción verificada (paso 16)», en algo más de un minuto). Comprueba también que desde fuera no se llega ni al 8080 ni al 9000.
Las métricas siguen ahí, para tu Prometheus, en el puerto de gestión (dentro de la red de Docker): /metrics devuelve, por ejemplo, keycloak_credentials_password_hashing_validations_total.
6. Antes de abrirlo al mundo
Lo que este paso no hace (y conviene repasar en tu despliegue). Son puntos de la documentación de Keycloak, no probados en el curso salvo donde se indica:
- Admin permanente.
KC_BOOTSTRAP_ADMIN_*crea un admin temporal (el aviso amarillo de la consola). Crea uno de verdad, con segundo factor, y borra el temporal. - Secretos de verdad.
secretos/produccion.envlleva valores de ejemplo para que el curso funcione. En un despliegue real, los secretos del orquestador, un gestor como Vault o el config keystore de Keycloak. Keycloak no lee variables*_FILEcomo la imagen de PostgreSQL. - Importar el realm, solo la primera vez.
--import-realmsalta los realms que ya existen; los cambios posteriores, con el aprovisionador de la lección 10 o una herramienta declarativa. - Copias de seguridad de PostgreSQL: ahí está todo (usuarios, sesiones, claves de firma).
- Más de un nodo. Varias réplicas de Keycloak comparten caché; consulta la guía de alta disponibilidad de tu versión antes de escalar.
- Logs en JSON para tu sistema de logs:
KC_LOG_CONSOLE_OUTPUT=json(probado: cada línea pasa a ser un objeto JSON). - Protección contra fuerza bruta en el realm (Realm settings → Security defenses) y tiempos de vida de tokens y sesiones revisados.
Ejercicios
1. tienda-web también detrás de Caddy · media
Sirve tienda-web en https://localhost:9443 con el mismo Caddy. ¿Qué tienes que cambiar en Caddy, en el realm y en tienda-web?
Ver solución
- Caddy: otro bloque
https://localhost:9443 { tls internal; reverse_proxy host.docker.internal:3000 }y publicar el puerto 9443. Caddy reenvía al tienda-web que corre en tu máquina. - Realm: en
tienda-web, la redirect URIhttps://localhost:9443/callback, la URI tras el logout y el web origin. - tienda-web:
OIDC_REDIRECT_URL=https://localhost:9443/callbackyOIDC_POST_LOGOUT_URL=https://localhost:9443/. Sus cookies pasan aSecureautomáticamente (sección 4).
El código Go no cambia: es la ventaja de que todo salga de la configuración. Probado: el login completo por https://localhost:9443 funciona y la cookie de sesión sale con Secure. En WSL con red NAT, Caddy no llega a tu tienda-web por host.docker.internal (lección 4): usa la IP de WSL (ip addr show eth0).
2. ¿Qué pasa si Keycloak no responde? · fácil
Con todo en marcha, pausa Keycloak (docker compose pause keycloak en infra/produccion) e intenta entrar en la tienda. ¿Cuánto tarda en fallar? ¿Y las peticiones a api-pedidos con un token ya emitido?
Ver solución
Probado, y con matices:
- El navegador se queda esperando. Una petición a Keycloak a través de Caddy siguió sin respuesta a los 70 s: Caddy no corta por defecto. Si te importa, configura timeouts en el proxy.
- api-pedidos con las claves ya en memoria (había validado algún token antes): sigue respondiendo 200 al instante. Valida en local con el JWKS que guardó (lección 5).
- api-pedidos recién arrancada, sin claves aún: 401 a los 10 s exactos, por el timeout de esta lección. El log lo explica:
fetching keys oidc: get keys failed … context deadline exceeded (Client.Timeout exceeded while awaiting headers). Sin ese timeout, la petición se habría quedado colgada.
Despausa con docker compose unpause keycloak.
3. Un admin de verdad · fácil
Crea en el realm master un administrador permanente y borra el temporal. ¿Qué cambia en la consola?
Ver solución
Entra con el admin temporal (admin / Admin-Keycloak-2026!, de secretos/produccion.env), crea un usuario en master con el rol de realm admin, dale contraseña (y, mejor, segundo factor), entra con él y borra admin. El aviso amarillo «You are logged in as a temporary admin user», que has visto en todas las capturas del curso, depende del atributo is_temporary_admin: comprobamos con la Admin API que solo lo tiene el admin creado al arrancar, así que con tu admin permanente no aparece.
Errores comunes
503 Service Unavailable justo después de arrancar
Keycloak responde 503 mientras importa los realms («Request received during bootstrapping»). Lo vimos: /health/ready dice DOWN en ese rato (unos 15 s) y UP cuando el realm ya responde. Espera a que esté listo; aquí lo hace el healthcheck.
healthy pero no funciona: el healthcheck que miente
Nuestra primera versión buscaba «UP» en la respuesta de /health/ready. Daba éxito durante la importación porque los checks internos (la base de datos) ya decían UP aunque el estado general fuera DOWN. Mira el código HTTP: 200 listo, 503 no.
x509: certificate signed by unknown authority
Go no confía en la CA (sección 4). Si usas SSL_CERT_FILE con go -C, dale una ruta absoluta: go -C cambia de directorio y una ruta relativa deja de apuntar al archivo. Nos pasó.
la CA dejó de valer tras docker compose down -v
La CA de Caddy vive en su volumen caddy-data. Con -v se borra y Caddy crea otra al arrancar: vuelve a copiar root.crt (y a importarla donde la importaras).
KC_DB_PASSWORD_FILE no hace nada
La convención *_FILE es de algunas imágenes (como PostgreSQL), no de Keycloak. Usa la variable normal desde un archivo que no esté en Git, o el config keystore.
Has construido una tienda en Go protegida con Keycloak de principio a fin: login con PKCE, API con JWT, roles y scopes, servicios con Client Credentials y Token Exchange, administración desde Go, usuarios de Active Directory y de otra empresa, una arquitectura que se prueba sin Keycloak, segundo factor para lo delicado y un despliegue con TLS. El hilo común: Keycloak decide quién es cada uno; tu código Go valida tokens y aplica sus reglas, y casi todo lo demás es configuración.