Módulo 8 · Lección 16

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.

≈ 60 min Carpeta: tienda/pasos/paso-16 Caddy 2.11 · Keycloak 26.8 optimizado

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/) e internal/config/config.go; cambian internal/auth/auth.go, internal/apiauth/apiauth.go y los main.go que leen secretos · ver todos los cambios del paso.
En marcha
Solo la infraestructura de producción (para el entorno de desarrollo, docker compose down en infra/: ambos usan el AD).
Comprueba
tools/ci/verificar-produccion.sh (desde course/): levanta todo, lo comprueba por HTTPS y lo apaga.

Al terminar sabrás

  • Qué cambia entre start-dev y 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, cookies Secure y 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)
ArranqueRecompila según las opciones en cada inicioImagen construida una vez con kc.sh build
URL de KeycloakLa de cada petición (http://localhost:8080)Fija: KC_HOSTNAME=https://localhost:8443. Es la que va en iss.
TLSNoEn Caddy, delante. Keycloak escucha HTTP solo en la red interna.
Puertos publicados8080 y 9000Solo 8443 (Caddy). El 9000 (health, métricas) queda dentro.
SecretosEn el docker-compose.ymlEn un archivo aparte (secretos/produccion.env)
Programas GoValores por defecto para todoTIENDA_ENTORNO=produccion: sin secretos por defecto
Navegador y programas Go hablan con Caddy por HTTPS en el 8443; Caddy pasa a Keycloak por HTTP en la red interna; el puerto 9000 y PostgreSQL no se publican red interna de Docker Navegador y programas Go Caddy TLS · :8443 Keycloak HTTP :8080 · gestión :9000 PostgreSQL AD https http
Solo Caddy se ve desde fuera. Keycloak sabe que está detrás de él por 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ónPor qué
KC_HOSTNAMELa 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=trueEn 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=xforwardedQue 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 9000Caddy 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
SistemaCómo confía Go en la CA de Caddy
Linux, WSL, macOSexport SSL_CERT_FILE=/ruta/absoluta/caddy-root.crt (probado en WSL). Sustituye la lista de CA del sistema: vale para este ejercicio.
WindowsGo 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 navegadorLo 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.env lleva 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 *_FILE como la imagen de PostgreSQL.
  • Importar el realm, solo la primera vez. --import-realm salta 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 URI https://localhost:9443/callback, la URI tras el logout y el web origin.
  • tienda-web: OIDC_REDIRECT_URL=https://localhost:9443/callback y OIDC_POST_LOGOUT_URL=https://localhost:9443/. Sus cookies pasan a Secure automá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.

Resumen del curso

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.