Building Code Training Lab: a self-hosted multi-language coding platform

Code Training Lab is a self-hosted coding challenge platform where developers solve problems in eleven different languages, get automated test feedback, and receive AI coaching — all inside a browser-based IDE with real IntelliSense. Think LeetCode or Exercism, but running entirely on your own hardware, with no extern...

Code Training Lab ( https://github.com/josalero/code-challenge-ide ) is a POC self-hosted coding challenge platform where developers solve problems in different languages, get automated test feedback, and receive AI coaching — all inside a browser-based IDE with real IntelliSense. Think LeetCode or Exercism, but running entirely on your own hardware, with no external SaaS dependency for code execution. The project has three moving parts: A Spring Boot 4 API that accepts code submissions, dispatches them to isolated Docker containers, streams results in real time, and serves challenge content. A React 19 SPA with a Monaco editor that looks and feels like VS Code — resizable panels, syntax highlighting, diagnostics, autocompletion — connected to real language servers over WebSocket. Eleven execution sandboxes + seven LSP containers , each a separate Docker image, isolated from the network and host filesystem. Why build it? Three practical motivations drove the design: Full sandbox isolation. Commercial platforms often use Node.js VMs, Python exec , or shared JVM processes for execution. That works for homogeneous stacks but gets messy across languages. Here, every language gets its own container with a real toolchain: mvn test , pytest , go test , cargo test ,etc. The grading output is exactly what a developer would see in a terminal. Real IntelliSense, not a toy. Most browser code editors ship with basic tokenizer-based highlighting. This project connects Monaco to actual language protocol servers — JDT LS for Java, Pyright for Python, gopls for Go — so users get hover docs, completion, and inline error markers driven by the same tools their IDE uses. Control over data. Running this on-premises means challenge source, user progress, submission history, and any PII stay inside your own infrastructure. High-level architecture Browser (React + Monaco) │ ├── /api/v1/* HTTP/REST (Spring Boot) ├── /api/v1/submissions/{id}/events SSE stream └── /api/v1/lsp/{language} WebSocket (LSP) │ Spring Boot API (Java 26) │ ┌─────────┼──────────────────────────────┐ │ │ │ PostgreSQL RabbitMQ Docker socket (/var/run/docker.sock) (challenge, (submission │ user, queue) ├── ctl-runner-pool-{image} (one per language) progress, │ └── daemon.py on stdin submissions) │ └── ctl-lsp-pool-{user}-{language} (one per user × language) └── docker exec -i ← LSP stdio bridge The API is a modular monolith — one deployable JVM process split into bounded-context packages ( catalog , submission , identity , coach , operations , integration , platform ) rather than microservices. That choice keeps operational complexity low for a small team while preserving clean module boundaries. The execution model: Docker-in-Docker (without DinD) Code execution is the most security-sensitive part. The approach is Docker-out-of-Docker : the API container mounts the host's /var/run/docker.sock and uses the Docker CLI to spawn sibling containers on the host, rather than launching children inside itself. Host ├── docker.sock ├── ctl-api (Spring Boot, mounts docker.sock) │ └── $ docker exec -i ctl-runner-pool-java-26-local … └── ctl-runner-pool-java-26-local ← sibling, not child ├── --network none ← no outbound network ├── --cpus / --memory caps ← resource limits └── /challenge:ro ← challenge files read-only Runners get --network none and a read-only challenge mount. The user solution is the only writable thing inside the container ( /workspace ). The pool + daemon pattern A naive approach — docker run --rm per submission — takes 2–5 seconds just for container startup, plus tool initialization. For Java that's an additional 10–20 seconds for the JVM and Maven to cold-start. The fix is pooled runner containers : the API keeps one long-lived container per language image. Inside the Java container, a lightweight Python daemon ( runners/java/daemon.py ) listens on stdin for JSON job lines and returns one JSON result line per job: stdin → {"submission_id": "…", "solution_code": "…", "hidden_tests": "…", …} stdout ← {"status": "COMPLETED", "tests": […], "coverage": 0.91, …} The API sends jobs via docker exec -i into the running container, so there is no container startup overhead on repeated runs. Challenge files are synced via docker cp only when the slug changes; the Maven target/ directory persists between runs of the same challenge, giving incremental compilation. The submission pipeline Submissions follow an async pipeline rather than a synchronous HTTP request, for two reasons: execution can take 30–60 seconds, and clients may disconnect and reconnect. POST /api/v1/submissions → SubmissionService ↓ save to Postgres (status=PENDING) publish SubmissionJobMessage to RabbitMQ ↓ return 202 with submission ID ↓ (client subscribes) GET /api/v1/submissions/{id}/events (SSE) ↓ SubmissionJobListener (consumes queue) → DockerRunnerClient / RunnerContainerPool → coach (AI feedback) → persist result → push SSE events: status, test_result*, done SSE events are pushed through SubmissionEventHub , an in-memory pub/sub keyed by submission ID. When the frontend connects to the SSE endpoint, it receives test_result events as they stream in (the runner flushes each test case as it completes) and a final done event. Idempotency is built in: the same Idempotency-Key header from the same user within 24 hours returns the existing submission rather than running the code twice. IntelliSense: LSP over WebSocket Monaco Editor natively speaks the Language Server Protocol on a channel — it needs something to talk to. The backend bridges Monaco's WebSocket to a real language server's stdio. The evolution: from per-tab containers to per-user pools The first iteration started a docker run for every WebSocket connection — one container per open editor tab. This created immediate container sprawl: three browser tabs for Java = three ctl-lsp-java-* containers, each doing a cold JDT LS initialization (~3–4 s). The current model uses per-user language pools : User A opens Java challenge (tab 1) → pool key: {userId}:java → start ctl-lsp-pool-a1b2c3d4-java (sleep infinity, /workspace mounted) → docker exec -i … /entrypoint.sh ← JDT LS stdio bridge User A opens Java challenge (tab 2) → pool key: {userId}:java (same key) → container already running → docker exec -i … /entrypoint.sh ← new bridge, same container User B opens Java challenge → pool key: {userId-B}:java (different user) → start ctl-lsp-pool-b2c3d4e5-java The pool container runs sleep infinity as entrypoint. Each editor session attaches via docker exec -i which starts the language server process inside the already-warm container. When the tab closes, only the exec bridge is torn down; the pool container stays warm for the next open. Workspace sync Each pool maps to a stable directory on the host: {ops-data-dir}/lsp-workspaces/{userId}/{language}/ That directory is volume-mounted into the container at /workspace . When a user opens a different challenge, LspWorkspaceSupport.populate() rewrites the source files in place — no container restart needed. For Java that means rewriting src/main/java/com/challenge/Solution.java and keeping pom.xml stable so JDT LS's project state remains valid across challenge switches. Language server wiring Monaco (browser) ↓ WebSocket /api/v1/lsp/java JwtWebSocketHandshakeInterceptor (validates JWT, extracts userId) ↓ LspWebSocketHandler ↓ LspUserLanguagePool.attach(userId, language, image, solution) ↓ ensures pool container running ↓ populates workspace LspDockerSession.attachFromPool(...) ↓ docker exec -i -e CTL_LSP_LA...