TPTito Pinardo Gutiérrez
← Back to the rack
Español
1U · XPIFYK3s · 1 node · 23 parts Retired · rebuilt

Slot 23 of 25 in the rack · Retired

1U unit · ASIR + DAW final project

XPIFY

10/10 · assessed in production

A learning platform you play like a video game: missions, XP, levels, alliances, leaderboards and real-time chat. Underneath, a Kubernetes cluster deployed with GitOps.

My part
The backend (NestJS, PostgreSQL, Keycloak, WebSockets), all the infrastructure and deployment (K3s, Argo CD, CI/CD, observability) and part of the frontend. We were a team of two.
Where it lived
In a K3s VM inside this very Proxmox server, behind my home Traefik and Cloudflare, just like this website.
Today
Retired after the assessment. This unit rebuilds it piece by piece.
  • 10/10panel grade, with the platform running
  • 22question types in the visual editor
  • 40+endpoints documented with Swagger
  • 180 msP95 with 500 concurrent users (k6)
  • 1.2 GB → 180 MBDocker image after the multi-stage build
  • 150 → 2 msleaderboard Top 10 with a Redis cache

Inside the VM

Click a part to see what it did, or pick a flow and follow the packet.

Hermesvisiting the unit

Click a part or pick a flow. If you came from a skill, the parts where I used it are already lit.

      Signing in (OIDC + PKCE)
      1. Player — You click «Sign in».
      2. Cloudflare — The request comes in through Cloudflare, already over HTTPS.
      3. Homelab Traefik — The home Traefik sends it to the cluster VM.
      4. Traefik (K3s Ingress) — The Ingress decides: the site goes to the frontend.
      5. Frontend (Next.js 14) — Next.js redirects you to Keycloak with a PKCE challenge.
      6. Keycloak (identity) — Keycloak shows its sign-in form.
      7. Player — You type your credentials (or use Google or GitHub).
      8. Keycloak (identity) — They are right: Keycloak sends you back with a one-time code.
      9. Frontend (Next.js 14) — The Next.js server picks up the code. The browser never sees the token.
      10. Keycloak (identity) — Back-channel exchange: code + PKCE verifier for a JWT.
      11. Frontend (Next.js 14) — The session is stored in an HTTP-only cookie (BFF pattern): an XSS cannot read it.
      12. Player — You are in. Every API call carries the JWT and the backend checks its signature.
      You finish a mission
      1. Player — You answer the last question and your answers are sent with the time each one took.
      2. Cloudflare — In through Cloudflare…
      3. Homelab Traefik — …and the home Traefik.
      4. Traefik (K3s Ingress) — The Ingress sends /api to the backend.
      5. Backend (NestJS, 3 replicas) — The guard validates the JWT and role. A transaction starts: it marks the answers and works out the XP.
      6. PostgreSQL 16 — Saves the attempt. A unique constraint stops anyone cashing in the same mission twice.
      7. Backend (NestJS, 3 replicas) — Commits the transaction and updates your alliance's XP.
      8. Redis — Invalidates the cached leaderboard: the next request sees your new position.
      9. Real time (Socket.io) — Emits the mission_completed event over the WebSocket.
      10. Player — Confetti and «+120 XP» on screen, no reload.
      Alliance chat across replicas
      1. Player — You type in your alliance chat.
      2. Cloudflare — In through Cloudflare…
      3. Homelab Traefik — …and the home Traefik.
      4. Traefik (K3s Ingress) — The Ingress hands it to the WebSocket. You landed on backend replica 1.
      5. Real time (Socket.io) — Replica 1 saves the message before emitting it: reloading no longer loses it.
      6. PostgreSQL 16 — It publishes it to the alliance room (at most 5 messages per second).
      7. Real time (Socket.io) — Your teammate is on replica 2, which knows nothing about your socket…
      8. Redis — …but Redis bridges the replicas and passes the message along.
      9. Real time (Socket.io) — Replica 2 emits it to its room.
      10. Alliance teammate — Your teammate sees it instantly. Without Redis, each replica was an island.
      You open the leaderboard
      1. Player — You open the global leaderboard.
      2. Cloudflare — Cloudflare…
      3. Homelab Traefik — …and the home Traefik.
      4. Traefik (K3s Ingress) — The Ingress sends the page to the frontend.
      5. Frontend (Next.js 14) — A Next.js server component asks the backend for the Top: the HTML arrives already filled in.
      6. Backend (NestJS, 3 replicas) — The backend checks the cache first.
      7. Redis — If the cache is empty (someone just earned XP)…
      8. PostgreSQL 16 — …it queries PostgreSQL with a descending index on XP: it only reads the top.
      9. Redis — Stores the result in Redis for whoever comes next: 2 ms instead of 150.
      10. Frontend (Next.js 14) — Next.js renders the table on the server.
      11. Player — You see the leaderboard on load, and then it moves live over WebSocket.
      Deploying with GitOps
      1. Repository (monorepo) — I push to the main branch.
      2. Continuous integration — CI: lint, unit and end-to-end tests, and multi-stage image builds.
      3. Image registry (GHCR) — Publishes the images tagged with the commit.
      4. Repository (monorepo) — The version is bumped in the repository manifests.
      5. Argo CD (GitOps) — Argo CD sees Git and the cluster no longer match and starts syncing.
      6. Kubernetes Secrets — The Secrets are already in the cluster: they never go through Git.
      7. Migration Job — First of all, the migration Job…
      8. PostgreSQL 16 — …applies the schema changes to PostgreSQL.
      9. Backend (NestJS, 3 replicas) — Backend replicas roll over one by one; the liveness probe decides when each is ready.
      10. Frontend (Next.js 14) — Then the frontend.
      11. Traefik (K3s Ingress) — The Ingress serves the new version. Not a single kubectl by hand.
      An error in the small hours
      1. Backend (NestJS, 3 replicas) — The backend throws an error with a multi-line trace.
      2. Promtail — Promtail reads it from the node and merges the trace into one event.
      3. Loki — Loki stores it with its labels: namespace and service.
      4. Grafana — A Grafana rule sees errors in the backend logs and fires the alert.
      5. Prometheus — Prometheus adds context: the pod's CPU and memory at that moment.
      6. Grafana — The dashboard shows it all together: what failed, when and under what load.
      7. Player — Fixed before the player notices (or nearly).
      1. Player

        A student on a browser or phone. The interface adapted to desktop, tablet and mobile.

      2. Alliance teammate

        Another player in the same alliance, connected to a different backend replica. Everything still reached them instantly.

      3. Cloudflare

        DNS and proxy in front: my home IP stayed hidden and HTTPS started here.

      4. Homelab Traefik

        The public entrance to my whole server. It forwarded the XPIFY domains to the cluster VM, just as it forwards this website to its container today.

        • Traefik
        • Proxmox VE
      5. Repository (monorepo)

        Frontend, backend, shared types and Kubernetes manifests in a single repository. What is in Git is what runs.

        • Git
      6. Continuous integration

        On every push: lint, tests (Jest and Playwright, with JUnit reports) and image builds. With husky you could not even commit code with errors.

        • GitLab CI · GitHub Actions
        • Docker
      7. Image registry (GHCR)

        Images were tagged twice: latest and the commit hash, so we always knew exactly which version was deployed.

        • Docker
      8. Traefik (K3s Ingress)

        Routed traffic inside the cluster: the site to the frontend, /api to the backend and login to Keycloak. Automatic Let's Encrypt certificates.

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

        Watched the repository and kept the cluster exactly as described in Git. Nothing was applied by hand with kubectl.

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

        Database, Keycloak and MinIO passwords out of the repository: created separately in the cluster and read by the pods as variables.

        • Kubernetes / K3s
      11. Migration Job

        A Job that applied schema changes before the new backend version started. A deployment never met half-built tables.

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

        An SPA with server-side rendering: first load under 500 ms. Public leaderboards were read in server components and the session lived in an HTTP-only cookie.

        • Next.js
        • JavaScript / TypeScript
      13. Backend (NestJS, 3 replicas)

        My main part: the REST API, a modular monolith with one module per domain (missions, alliances, users), JWT and role guards, and transactions when awarding XP.

        • NestJS
        • JavaScript / TypeScript
        • Docker
      14. Real time (Socket.io)

        WebSocket gateway: alliance chat, XP notifications and leaderboards that moved without reloading. The JWT was checked right at the handshake.

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

        Login with OIDC and PKCE, its own realm, admin and student roles. The backend validated every token with Keycloak's public keys.

        • Keycloak
      16. PostgreSQL 16

        A StatefulSet with its own volume. UUID keys, questions in JSONB (new types without touching the schema) and indexes designed for the leaderboard.

        • PostgreSQL
        • Kubernetes / K3s
      17. Redis

        Two jobs: Socket.io bridge between backend replicas and leaderboard cache (Top 10 in 2 ms instead of 150).

        • Redis
      18. MinIO

        S3-compatible storage for avatars. A Job created the buckets on deployment.

        • Docker
      19. Persistent volumes

        PostgreSQL, Redis and MinIO kept their data on volumes. I tested it by killing the database pod: it came back with everything intact.

        • Kubernetes / K3s
      20. Promtail

        Read every pod's logs straight from the node and merged multi-line error traces into a single event.

        • Prometheus · Grafana · Loki
      21. Loki

        Stored logs with labels (namespace, service) so they could be searched from Grafana without getting into any pod.

        • Prometheus · Grafana · Loki
      22. Prometheus

        CPU, memory and disk metrics for every container through cAdvisor, with no extra agents.

        • Prometheus · Grafana · Loki
      23. Grafana

        Provisioned dashboards and seven alert rules: resource usage and errors in the logs.

        • Prometheus · Grafana · Loki

      What I used from my skill tree

      Pick a skill: the parts where I used it light up on the map and I tell you what for.

      NestJS
      The whole API: one module per domain, JWT and role guards, transactions when awarding XP and the WebSocket gateway.
      JavaScript / TypeScript
      Backend and frontend in TypeScript, sharing the same types in the monorepo.
      Next.js
      I contributed to the frontend: server components for the leaderboards and the NextAuth session in an HTTP-only cookie.
      PostgreSQL
      UUID schema, questions in JSONB, indexes for the leaderboard and the unique constraint that ended double XP.
      Keycloak
      OIDC login with PKCE, its own realm, admin and student roles, and JWT validation with its public keys.
      Redis
      Socket.io bridge between replicas and leaderboard cache: from 150 ms to 2 ms.
      Kubernetes / K3s
      Single-node K3s cluster: Deployments, PostgreSQL as a StatefulSet, volumes, migration Jobs and Traefik routes.
      Argo CD
      GitOps: Argo CD kept the cluster exactly as described in Git. Nothing was applied by hand.
      GitLab CI · GitHub Actions
      Lint, tests (Jest and Playwright with JUnit reports), multi-stage builds and images published with the commit tag.
      Prometheus · Grafana · Loki
      Prometheus with cAdvisor, Loki and Promtail for logs, and seven alert rules in Grafana.
      Docker
      Multi-stage images from 1.2 GB down to 180 MB; locally everything started with docker-compose.
      Traefik
      K3s Ingress with Let's Encrypt, behind my home Traefik. The Keycloak loop was fixed with X-Forwarded-Proto.
      Linux (Debian, Alpine, Arch)
      I set the node up from scratch: system, firewall and K3s.
      Proxmox VE
      The cluster VM lived on my Proxmox server, next to the rest of the homelab.
      Git
      Monorepo with feature branches and hooks (husky and lint-staged) that refused commits with errors.

      Work out your XP

      The same calculation the backend ran when you finished a mission. Move the controls and see how much you would earn.

      Difficulty

      XP earned

      0 XP

      XP = base × difficulty × accuracy × speed

      Level after 10 missions like this

      1

      Level grows with the square root of total XP: the first ones come fast and the high ones take real effort.

      Incident log

      Real failures that cost us hours. Read the symptom, pick the cause you think it was and see how it was fixed.

      1. #1

        Double XP

        15 Jan 2026 · Critical: data integrity at stake

        On flaky connections, some users were paid the XP for the same mission twice.

        What do you think was going on?

        Show the fix
        Cause
        A client retry sent the same POST twice and both transactions ran in parallel. We assumed the transaction would lock the row, but with PostgreSQL's default isolation (READ COMMITTED) it does not.
        Fix
        A unique constraint on (user, mission). The second attempt fails in the database itself and the backend answers exactly as it did the first time: the operation becomes idempotent.
        Lesson
        Guarantees belong in the database, not in hoping the client never retries.
      2. #2

        The endless loop

        20 Jan 2026 · Blocking: nobody could sign in

        After clicking «Sign in», the browser bounced between the app and Keycloak until it gave up with ERR_TOO_MANY_REDIRECTS.

        What do you think was going on?

        Show the fix
        Cause
        Traefik terminated HTTPS and talked to Keycloak over internal HTTP. Keycloak saw HTTP, redirected to HTTPS… and Traefik sent it straight back.
        Fix
        Make the proxy tell the truth: the X-Forwarded-Proto: https header in the Traefik middleware. Keycloak then knew the client was already on HTTPS.
        Lesson
        Behind a proxy, applications only know what the proxy tells them.
      3. #3

        The build that ran out of memory

        2 Feb 2026 · Medium: delayed every deployment by about 20 minutes

        The pipeline failed at npm run build with «JavaScript heap out of memory».

        What do you think was going on?

        Show the fix
        Cause
        The Next.js build, plus other jobs on the runner, ate the CI machine's 7 GB.
        Fix
        More memory for Node (NODE_OPTIONS=--max-old-space-size=4096) and no source maps in production: 40 % less memory.
        Lesson
        CI is production too: measure it and give it resources like everything else.
      4. #4

        The time that did not match

        Medium: the console filled up with warnings

        React kept warning: «Text content did not match. Server: 10:00 AM, Client: 11:00 AM».

        What do you think was going on?

        Show the fix
        Cause
        Times were rendered with toLocaleTimeString() during rendering. The server HTML (UTC) and the client's did not match, which broke hydration.
        Fix
        Relative times («5 minutes ago») where possible and, elsewhere, render the time on the client only with a useMounted hook.
        Lesson
        If the result depends on where it runs, it cannot be rendered in both places.
      5. #5

        Five minutes down

        High: the app stopped responding during a deployment

        During a deployment the app hung for about five minutes.

        What do you think was going on?

        Show the fix
        Cause
        A migration ran ALTER TABLE users ADD COLUMN bio TEXT DEFAULT '…'. On the version we used that can rewrite the whole table, and a table under load stays locked meanwhile.
        Fix
        Split it into three steps: add the column without a default, backfill in the background and set the default at the end.
        Lesson
        A migration is production code too: review it with the load in mind, not just the schema.

      How it was built

      1. Iteration 1

        Foundations

        • Monorepo and docker-compose with PostgreSQL 16 and Redis 7 before writing any business logic.
        • NestJS API with input validation and a log line for every request.
        • Keycloak, «the first wall»: realm, clients, roles and JWT validation.
        • Missions with questions in JSONB and the first XP tests (including the cheater who answers in 0 seconds).
      2. Iteration 2

        Game experience

        • Custom-themed interface, a dashboard with level and avatar, and a mission map.
        • Game loop: state machine, drag and drop, confetti, sounds and a screen shake on wrong answers.
        • Alliances with invite codes and automatic succession if the leader leaves.
        • Testing with classmates: «it's slow moving to the next question» → prefetch the next one.
      3. Iteration 3

        Real time and scale

        • WebSocket gateway with the JWT checked at the handshake.
        • Persistent chat and per-alliance rooms.
        • Redis as a bridge between replicas and as the leaderboard cache.
        • Messages-per-second limit and avatars on MinIO.
      4. Iteration 4

        Production

        • Multi-stage Dockerfiles: from 1.2 GB to 180 MB.
        • K3s on a VM in my Proxmox, Traefik, Let's Encrypt and GitOps with Argo CD.
        • Load tests with k6 → missing indexes → database CPU down to 15 %.
        • Volumes tested by killing pods, security headers, Prometheus, Loki, Grafana and version 1.0.0.

      Decisions (and why)

      • Next.js vs plain React or Remix

        Next.js 14 (App Router)

        Server-side rendering to load in under 500 ms and read the public leaderboards without exposing more endpoints.

      • NestJS vs Django or Go

        NestJS

        TypeScript on both sides with the same types, a modular structure that keeps the code tidy, and good WebSocket support.

      • PostgreSQL vs MongoDB

        PostgreSQL with JSONB

        Referential integrity where it matters and JSONB for the questions: new exercise types without migrating the schema. The best of both worlds.

      • Microservices?

        A modular monolith, and called that

        One module per domain with clear boundaries, without paying the latency and distributed transactions of separate services the project did not need.

      • Integer IDs or UUIDs

        UUID

        Nobody can guess how many users there are or walk through them one by one, and merging data later does not clash.

      What I took away

      • The hardest part was not the code but getting Keycloak, NestJS and Next.js to understand each other: tokens, roles and redirects between containers.
      • JSONB for questions was the right call: we kept adding types until we reached 22 without migrating the schema.
      • Measure before optimising: k6 pointed at exactly the index that was missing.
      • In a team of two, talking early matters as much as any design pattern.

      If I kept going

      • A multi-node, highly available cluster.
      • Integration with Moodle or Canvas through LTI.
      • AI-generated questions from course notes.
      • Learning analytics for teachers.

      Shall we talk?

      I can show the code and walk through any of these decisions in detail in an interview.

      Download CV Get in touch ← Back to the rack

      Rebuilt from the project report and technical docs: no addresses, domains or internal names.