TPTito Pinardo Gutiérrez
← Volver al rack
English
1U · XPIFYK3s · 1 nodo · 23 piezas Retirada · reconstruida

Posición 23 de 25 en el rack · Retirada

Unidad 1U · Proyecto final ASIR + DAW

XPIFY

10/10 · evaluada en producción

Una plataforma de aprendizaje que se juega como un videojuego: misiones, XP, niveles, alianzas, rankings y chat en tiempo real. Por debajo, un clúster de Kubernetes desplegado con GitOps.

Mi parte
El backend (NestJS, PostgreSQL, Keycloak, WebSockets), toda la infraestructura y el despliegue (K3s, Argo CD, CI/CD, observabilidad) y parte del frontend. Éramos dos.
Dónde vivía
En una VM con K3s dentro de este mismo servidor Proxmox, detrás del Traefik de casa y de Cloudflare, igual que esta web.
Hoy
Retirada tras la evaluación. Esta unidad es su reconstrucción, pieza a pieza.
  • 10/10nota del tribunal, con la plataforma funcionando
  • 22tipos de pregunta en el editor visual
  • 40+endpoints documentados con Swagger
  • 180 msP95 con 500 usuarios a la vez (k6)
  • 1,2 GB → 180 MBimagen Docker tras el multi-stage
  • 150 → 2 msTop 10 del ranking con caché en Redis

La VM por dentro

Pulsa una pieza para ver qué hacía o elige un recorrido y sigue el paquete.

Hermesde visita en la unidad

Pulsa una pieza o elige un recorrido. Si vienes de una habilidad, ya están encendidas las piezas donde la usé.

      Inicio de sesión (OIDC + PKCE)
      1. Jugador — Pulsas «Entrar».
      2. Cloudflare — La petición entra por Cloudflare, ya con HTTPS.
      3. Traefik del homelab — El Traefik de casa la manda a la VM del clúster.
      4. Traefik (Ingress de K3s) — El Ingress decide: la web va al frontend.
      5. Frontend (Next.js 14) — Next.js te redirige a Keycloak con un reto PKCE.
      6. Keycloak (identidad) — Keycloak te enseña su formulario de acceso.
      7. Jugador — Escribes tus credenciales (o entras con Google o GitHub).
      8. Keycloak (identidad) — Son correctas: Keycloak te devuelve con un código de un solo uso.
      9. Frontend (Next.js 14) — El servidor de Next.js recoge el código. El navegador nunca llega a ver el token.
      10. Keycloak (identidad) — Intercambio por el canal trasero: código + verificador PKCE a cambio de un JWT.
      11. Frontend (Next.js 14) — La sesión se guarda en una cookie HTTP-Only (patrón BFF): un XSS no puede leerla.
      12. Jugador — Dentro. Cada petición a la API lleva el JWT y el backend comprueba su firma.
      Terminas una misión
      1. Jugador — Respondes la última pregunta y se envían tus respuestas con lo que tardaste en cada una.
      2. Cloudflare — Entra por Cloudflare…
      3. Traefik del homelab — …y por el Traefik de casa.
      4. Traefik (Ingress de K3s) — El Ingress manda /api al backend.
      5. Backend (NestJS, 3 réplicas) — El guard valida el JWT y el rol. Empieza una transacción: corrige y calcula el XP.
      6. PostgreSQL 16 — Guarda el intento. Una restricción única impide cobrar dos veces la misma misión.
      7. Backend (NestJS, 3 réplicas) — Confirma la transacción y actualiza el XP de tu alianza.
      8. Redis — Invalida el ranking en caché: el siguiente que lo pida verá tu nueva posición.
      9. Tiempo real (Socket.io) — Emite el evento mission_completed por el WebSocket.
      10. Jugador — Confeti y «+120 XP» en pantalla, sin recargar.
      Chat de alianza entre réplicas
      1. Jugador — Escribes en el chat de tu alianza.
      2. Cloudflare — Entra por Cloudflare…
      3. Traefik del homelab — …y por el Traefik de casa.
      4. Traefik (Ingress de K3s) — El Ingress lo pasa al WebSocket. Te ha tocado la réplica 1 del backend.
      5. Tiempo real (Socket.io) — La réplica 1 guarda el mensaje antes de emitirlo: recargar ya no lo borra.
      6. PostgreSQL 16 — Lo publica en la sala de la alianza (máximo 5 mensajes por segundo).
      7. Tiempo real (Socket.io) — Tu compañero está conectado a la réplica 2, que no sabe nada de tu socket…
      8. Redis — …pero Redis hace de puente entre réplicas y le pasa el mensaje.
      9. Tiempo real (Socket.io) — La réplica 2 lo emite a su sala.
      10. Compañero de alianza — Tu compañero lo ve al instante. Sin Redis, cada réplica era una isla.
      Abres el ranking
      1. Jugador — Abres la clasificación global.
      2. Cloudflare — Cloudflare…
      3. Traefik del homelab — …y el Traefik de casa.
      4. Traefik (Ingress de K3s) — El Ingress manda la página al frontend.
      5. Frontend (Next.js 14) — Un componente de servidor de Next.js pide el Top al backend: el HTML llega ya relleno.
      6. Backend (NestJS, 3 réplicas) — El backend mira primero en la caché.
      7. Redis — Si la caché está vacía (alguien acaba de sumar XP)…
      8. PostgreSQL 16 — …consulta PostgreSQL con un índice descendente sobre el XP: solo lee el principio.
      9. Redis — Guarda el resultado en Redis para los siguientes: 2 ms en vez de 150.
      10. Frontend (Next.js 14) — Next.js renderiza la tabla en el servidor.
      11. Jugador — Ves el ranking al cargar, y luego se mueve en vivo por WebSocket.
      Despliegue con GitOps
      1. Repositorio (monorepo) — Hago push a la rama principal.
      2. Integración continua — CI: lint, tests unitarios y de extremo a extremo, y build multi-stage de las imágenes.
      3. Registro de imágenes (GHCR) — Publica las imágenes con la etiqueta del commit.
      4. Repositorio (monorepo) — Se actualiza la versión en los manifiestos del repositorio.
      5. Argo CD (GitOps) — Argo CD ve que Git y el clúster ya no coinciden y empieza a sincronizar.
      6. Secrets de Kubernetes — Los Secrets ya están en el clúster: nunca pasan por Git.
      7. Job de migraciones — Antes que nada, el Job de migraciones…
      8. PostgreSQL 16 — …aplica los cambios de esquema en PostgreSQL.
      9. Backend (NestJS, 3 réplicas) — Las réplicas del backend se renuevan una a una; la sonda de vida decide cuándo una está lista.
      10. Frontend (Next.js 14) — Después el frontend.
      11. Traefik (Ingress de K3s) — El Ingress ya sirve la nueva versión. Ni un kubectl a mano.
      Un error a las tantas
      1. Backend (NestJS, 3 réplicas) — El backend lanza un error con su traza de varias líneas.
      2. Promtail — Promtail lo lee del nodo y junta la traza en un solo evento.
      3. Loki — Loki lo guarda con sus etiquetas: namespace y servicio.
      4. Grafana — Una regla de Grafana ve errores en los logs del backend y salta la alerta.
      5. Prometheus — Prometheus aporta el contexto: CPU y memoria del pod en ese momento.
      6. Grafana — En el panel se ve todo junto: qué falló, cuándo y con qué consumo.
      7. Jugador — Se arregla antes de que el jugador lo note (o casi).
      1. Jugador

        Un estudiante desde el navegador o el móvil. La interfaz se adaptaba a escritorio, tablet y teléfono.

      2. Compañero de alianza

        Otro jugador de la misma alianza, conectado a otra réplica del backend. Aun así le llegaba todo al momento.

      3. Cloudflare

        DNS y proxy por delante: la IP de casa no se veía y el HTTPS empezaba aquí.

      4. Traefik del homelab

        La entrada pública de todo mi servidor. Reenviaba los dominios de XPIFY a la VM del clúster, igual que hoy reenvía esta web a su contenedor.

        • Traefik
        • Proxmox VE
      5. Repositorio (monorepo)

        Frontend, backend, tipos compartidos y manifiestos de Kubernetes en un mismo repositorio. Lo que hay en Git es lo que corre.

        • Git
      6. Integración continua

        En cada push: lint, tests (Jest y Playwright, con informes JUnit) y build de las imágenes. Con husky ni siquiera se podía commitear código con errores.

        • GitLab CI · GitHub Actions
        • Docker
      7. Registro de imágenes (GHCR)

        Las imágenes se publicaban con dos etiquetas: latest y el hash del commit, para saber siempre qué versión exacta estaba desplegada.

        • Docker
      8. Traefik (Ingress de K3s)

        Repartía el tráfico dentro del clúster: la web al frontend, /api al backend y el login a Keycloak. Certificados de Let's Encrypt automáticos.

        • Traefik
        • Kubernetes / K3s
      9. Argo CD (GitOps)

        Vigilaba el repositorio y dejaba el clúster igual que lo descrito en Git. Nada se aplicaba a mano con kubectl.

        • Argo CD
        • Git
        • Kubernetes / K3s
      10. Secrets de Kubernetes

        Contraseñas de la base de datos, Keycloak y MinIO fuera del repositorio: se creaban aparte en el clúster y los pods las leían como variables.

        • Kubernetes / K3s
      11. Job de migraciones

        Un Job que aplicaba los cambios de esquema antes de arrancar la nueva versión del backend. Así un despliegue nunca encontraba tablas a medias.

        • PostgreSQL
        • Kubernetes / K3s
      12. Frontend (Next.js 14)

        SPA con renderizado en servidor: carga inicial por debajo de 500 ms. Los rankings públicos se leían en componentes de servidor y la sesión vivía en una cookie HTTP-Only.

        • Next.js
        • JavaScript / TypeScript
      13. Backend (NestJS, 3 réplicas)

        Mi parte principal: la API REST, un monolito modular con un módulo por dominio (misiones, alianzas, usuarios), guards de JWT y roles, y transacciones al dar XP.

        • NestJS
        • JavaScript / TypeScript
        • Docker
      14. Tiempo real (Socket.io)

        Gateway de WebSockets: chat de alianza, avisos de XP y rankings que se movían sin recargar. El JWT se comprobaba ya en el handshake.

        • NestJS
        • JavaScript / TypeScript
        • Redis
      15. Keycloak (identidad)

        Login con OIDC y PKCE, realm propio, roles de administrador y estudiante. El backend validaba cada token con las claves públicas de Keycloak.

        • Keycloak
      16. PostgreSQL 16

        StatefulSet con volumen propio. UUID como claves, preguntas en JSONB (nuevos tipos sin tocar el esquema) e índices pensados para el ranking.

        • PostgreSQL
        • Kubernetes / K3s
      17. Redis

        Dos trabajos: puente de Socket.io entre las réplicas del backend y caché del ranking (Top 10 en 2 ms en vez de 150).

        • Redis
      18. MinIO

        Almacenamiento compatible con S3 para los avatares. Un Job creaba los buckets al desplegar.

        • Docker
      19. Volúmenes persistentes

        PostgreSQL, Redis y MinIO guardaban sus datos en volúmenes. Lo probé matando el pod de la base de datos: volvía con todo intacto.

        • Kubernetes / K3s
      20. Promtail

        Leía los logs de cada pod directamente del nodo y agrupaba las trazas de error de varias líneas en un solo evento.

        • Prometheus · Grafana · Loki
      21. Loki

        Guardaba los logs con etiquetas (namespace, servicio) para buscarlos desde Grafana sin entrar en ningún pod.

        • Prometheus · Grafana · Loki
      22. Prometheus

        Métricas de CPU, memoria y disco de cada contenedor a través de cAdvisor, sin agentes extra.

        • Prometheus · Grafana · Loki
      23. Grafana

        Paneles ya provisionados y siete reglas de alerta: consumo de recursos y errores en los logs.

        • Prometheus · Grafana · Loki

      Lo que usé de mi árbol

      Pulsa una habilidad: se encienden en el mapa las piezas donde la usé y te cuento para qué.

      NestJS
      Toda la API: un módulo por dominio, guards de JWT y roles, transacciones al dar XP y el gateway de WebSockets.
      JavaScript / TypeScript
      Backend y frontend en TypeScript, con los mismos tipos compartidos en el monorepo.
      Next.js
      Contribuí al frontend: componentes de servidor para los rankings y la sesión con NextAuth en una cookie HTTP-Only.
      PostgreSQL
      Esquema con UUID, preguntas en JSONB, índices para el ranking y la restricción única que acabó con el doble XP.
      Keycloak
      Login OIDC con PKCE, realm propio, roles de administrador y estudiante, y validación del JWT con sus claves públicas.
      Redis
      Puente de Socket.io entre réplicas y caché del ranking: de 150 ms a 2 ms.
      Kubernetes / K3s
      Clúster K3s de un nodo: Deployments, PostgreSQL en StatefulSet, volúmenes, Jobs de migración y rutas de Traefik.
      Argo CD
      GitOps: Argo CD dejaba el clúster igual que lo descrito en Git. Nada se aplicaba a mano.
      GitLab CI · GitHub Actions
      Lint, tests (Jest y Playwright con informes JUnit), build multi-stage y publicación de imágenes con la etiqueta del commit.
      Prometheus · Grafana · Loki
      Prometheus con cAdvisor, Loki y Promtail para los logs, y siete reglas de alerta en Grafana.
      Docker
      Imágenes multi-stage de 1,2 GB a 180 MB; en local todo arrancaba con docker-compose.
      Traefik
      Ingress de K3s con Let's Encrypt, detrás del Traefik de casa. El bucle con Keycloak se arregló con X-Forwarded-Proto.
      Linux (Debian, Alpine, Arch)
      El nodo lo preparé desde cero: sistema, cortafuegos y K3s.
      Proxmox VE
      La VM del clúster vivía en mi servidor Proxmox, junto al resto del homelab.
      Git
      Monorepo con ramas por funcionalidad y hooks (husky y lint-staged) que no dejaban commitear código con errores.

      Calcula tu XP

      El mismo cálculo que hacía el backend al terminar una misión. Mueve los controles y mira cuánto ganarías.

      Dificultad

      XP ganada

      0 XP

      XP = base × dificultad × precisión × velocidad

      Nivel tras 10 misiones como esta

      1

      El nivel crece con la raíz cuadrada del XP total: los primeros llegan rápido y los altos cuestan de verdad.

      Bitácora de incidencias

      Fallos reales que nos costaron horas. Lee el síntoma, elige la causa que creas y mira cómo se arregló.

      1. #1

        El doble XP

        15-01-2026 · Crítica: afectaba a la integridad de los datos

        Con mala conexión, algunos usuarios cobraban dos veces el XP de la misma misión.

        ¿Qué crees que pasaba?

        Ver la solución
        Causa
        Un reintento del cliente mandaba el mismo POST dos veces y las dos transacciones corrían en paralelo. Supusimos que la transacción bloquearía la fila, pero con el aislamiento por defecto de PostgreSQL (READ COMMITTED) no es así.
        Solución
        Una restricción única sobre (usuario, misión). El segundo intento falla en la propia base de datos y el backend responde lo mismo que al primero: la operación se vuelve idempotente.
        Lección
        Las garantías, en la base de datos; no en la esperanza de que el cliente no repita.
      2. #2

        El bucle infinito

        20-01-2026 · Bloqueante: nadie podía iniciar sesión

        Al pulsar «Entrar», el navegador rebotaba entre la aplicación y Keycloak hasta rendirse con ERR_TOO_MANY_REDIRECTS.

        ¿Qué crees que pasaba?

        Ver la solución
        Causa
        Traefik terminaba el HTTPS y hablaba con Keycloak por HTTP interno. Keycloak veía HTTP, redirigía a HTTPS… y Traefik se lo devolvía otra vez.
        Solución
        Que el proxy diga la verdad: la cabecera X-Forwarded-Proto: https en el middleware de Traefik. Keycloak supo que el cliente ya venía por HTTPS.
        Lección
        Detrás de un proxy, las aplicaciones solo saben lo que el proxy les cuenta.
      3. #3

        La build que se quedaba sin memoria

        02-02-2026 · Media: retrasaba cada despliegue unos 20 minutos

        El pipeline fallaba en npm run build con «JavaScript heap out of memory».

        ¿Qué crees que pasaba?

        Ver la solución
        Causa
        La compilación de Next.js, junto con otras tareas del runner, se comía los 7 GB de la máquina de CI.
        Solución
        Más memoria para Node (NODE_OPTIONS=--max-old-space-size=4096) y sin source maps en producción: un 40 % menos de consumo.
        Lección
        El CI también es producción: hay que medirlo y darle recursos como a todo lo demás.
      4. #4

        La hora que no cuadraba

        Media: la consola se llenaba de avisos

        React avisaba sin parar: «Text content did not match. Server: 10:00 AM, Client: 11:00 AM».

        ¿Qué crees que pasaba?

        Ver la solución
        Causa
        Se pintaban horas con toLocaleTimeString() durante el renderizado. El HTML del servidor (UTC) y el del cliente no coincidían y se rompía la hidratación.
        Solución
        Tiempos relativos («hace 5 minutos») donde se podía y, donde no, pintar la hora solo en el cliente con un hook useMounted.
        Lección
        Si el resultado depende de dónde se ejecuta, no puede renderizarse en los dos sitios.
      5. #5

        Cinco minutos sin servicio

        Alta: la aplicación dejó de responder durante un despliegue

        Durante un despliegue la aplicación se quedó colgada unos cinco minutos.

        ¿Qué crees que pasaba?

        Ver la solución
        Causa
        Una migración hacía ALTER TABLE users ADD COLUMN bio TEXT DEFAULT '…'. En la versión que usábamos eso puede reescribir la tabla entera, y una tabla con carga se queda bloqueada mientras tanto.
        Solución
        Partirla en tres pasos: añadir la columna sin valor por defecto, rellenar en segundo plano y fijar el valor por defecto al final.
        Lección
        Una migración también es código de producción: se revisa pensando en la carga, no solo en el esquema.

      Cómo se construyó

      1. Iteración 1

        Fundamentos

        • Monorepo y docker-compose con PostgreSQL 16 y Redis 7 antes de escribir lógica de negocio.
        • API en NestJS con validación de datos y logs de cada petición.
        • Keycloak, «el primer muro»: realm, clientes, roles y validación del JWT.
        • Misiones con preguntas en JSONB y los primeros tests del cálculo de XP (incluido el tramposo que responde en 0 segundos).
      2. Iteración 2

        Experiencia de juego

        • Interfaz con tema propio, panel con nivel y avatar, y mapa de misiones.
        • Bucle de juego: máquina de estados, arrastrar y soltar, confeti, sonidos y pantalla que tiembla al fallar.
        • Alianzas con código de invitación y sucesión automática si se va el líder.
        • Pruebas con compañeros: «va lento al pasar de pregunta» → precarga de la siguiente.
      3. Iteración 3

        Tiempo real y escala

        • Gateway de WebSockets con el JWT comprobado en el handshake.
        • Chat persistente y salas por alianza.
        • Redis como puente entre réplicas y caché del ranking.
        • Límite de mensajes por segundo y avatares en MinIO.
      4. Iteración 4

        Producción

        • Dockerfiles multi-stage: de 1,2 GB a 180 MB.
        • K3s en una VM de mi Proxmox, Traefik, Let's Encrypt y GitOps con Argo CD.
        • Pruebas de carga con k6 → índices que faltaban → la CPU de la base de datos bajó al 15 %.
        • Volúmenes probados matando pods, cabeceras de seguridad, Prometheus, Loki, Grafana y la versión 1.0.0.

      Decisiones (y por qué)

      • Next.js frente a React a secas o Remix

        Next.js 14 (App Router)

        Renderizado en servidor para cargar en menos de 500 ms y leer los rankings públicos sin exponer más endpoints.

      • NestJS frente a Django o Go

        NestJS

        TypeScript en las dos partes con los mismos tipos, una estructura modular que obliga a ordenar el código y buen soporte de WebSockets.

      • PostgreSQL frente a MongoDB

        PostgreSQL con JSONB

        Integridad referencial donde importa y JSONB para las preguntas: nuevos tipos de ejercicio sin migrar el esquema. Lo mejor de los dos mundos.

      • ¿Microservicios?

        Monolito modular, y dicho tal cual

        Un módulo por dominio con límites claros, pero sin pagar la latencia y las transacciones distribuidas de servicios separados que el proyecto no necesitaba.

      • IDs enteros o UUID

        UUID

        Nadie puede adivinar cuántos usuarios hay ni recorrerlos de uno en uno, y fusionar datos en el futuro no choca.

      Lo que me llevé

      • Lo más difícil no fue el código, sino que Keycloak, NestJS y Next.js se entendieran: tokens, roles y redirecciones entre contenedores.
      • JSONB para las preguntas fue un acierto: fuimos añadiendo tipos hasta llegar a 22 sin migrar el esquema.
      • Medir antes de optimizar: k6 señaló exactamente qué índice faltaba.
      • En un equipo de dos, hablar a tiempo pesa tanto como cualquier patrón de diseño.

      Si siguiera

      • Clúster de varios nodos con alta disponibilidad.
      • Integración con Moodle o Canvas mediante LTI.
      • Generar preguntas con IA a partir de los apuntes.
      • Analítica del aprendizaje para el profesorado.

      ¿Hablamos?

      Puedo enseñar el código y contar cualquiera de estas decisiones en detalle en una entrevista.

      Descargar CV Contactar ← Volver al rack

      Reconstrucción a partir de la memoria y la documentación del proyecto: sin direcciones, dominios ni nombres internos.