In production2026-05 – nowSolo full-stack developer

Code Sphere

Sixty Chromebook students needed a real VS Code each, on hardware with memory for about half that many at once.

  • docker
  • traefik
  • flask
  • python
  • pygame

The problem and constraints

A classroom of up to 60 students needed the same thing every one of them would eventually want at a real job: an actual VS Code, running real Python, with a real terminal, rather than a stripped-down teaching sandbox that would need to be unlearned later. The hardware available to run all of that was a single server sized for roughly half that many people at once, because in practice a school runs two classes of about 30 at a time, well short of sixty students hammering the same box at once.

That single fact (thirty real, thirty theoretical) became the constraint that shaped almost everything else. A code-server container isn’t free even when nobody’s typing: background extension processes, file watchers, and language servers cost 400 to 700 MB of memory just existing. Multiply that by sixty and a 16 GB server is over budget by three or four times before a single student writes a line of code. The platform couldn’t assume all sixty were active; it had to actively arrange for most of them not to be, most of the time, without a teacher having to manage that by hand for every class.

The second constraint was the network the students actually sit behind. School Wi-Fi and school-issued Chromebooks are notorious for blocking anything that isn’t standard web traffic, non-standard ports especially. Whatever came out of a student’s browser had to look like ordinary HTTPS traffic to port 443, indistinguishable from any other website, with no port number to remember or type.

The third constraint was who operates this thing day to day: a single teacher, with no IT department behind them. The entire setup (generating configuration, building images, starting the stack) had to collapse into a small number of scripts a non-specialist could run once, plus a “pre-warm a class” button for the one operational quirk (a 30-to-45-second cold start) that on-demand containers introduce. There’s no on-call rotation behind this platform; whatever breaks, breaks in front of a classroom, in real time.

The fourth constraint arrived after the platform already existed and was already working for plain text-based coding: the ask to also teach Pygame Zero, actual visual, interactive games. That’s a different resource shape entirely: a running game needs a screen to render to and, eventually, sound to play, inside the exact same per-student memory and CPU ceiling that was already tight before anything was drawing a frame or mixing an audio buffer. Every byte spent making a game visible or audible was a byte no longer available to the thing the platform exists to teach in the first place.

The last constraint was one specific, unglamorous reality of where this actually runs: some of the environments running or developing against this platform are Windows machines using WSL2, which has real, documented quirks around how it shares files and directories with Linux containers, quirks that don’t show up in a clean Linux CI environment but absolutely show up in front of an actual class.

Architecture

A student’s browser talks to exactly one thing on the network: Traefik, listening on a single port. Every route Traefik knows about (the login portal, and one slot per student, /code/aluno01/ through /code/aluno60/) is written into a single configuration file before any container ever starts, generated once at setup rather than discovered at runtime. That static approach exists because the more “modern” alternative, letting each container announce its own route to Traefik as it starts, quietly stopped working: Traefik’s Docker integration speaks to the Docker API at a version too old for current Docker Engine to accept, and no environment variable overrides that negotiation. Pre-declaring every route sidesteps the incompatibility completely and means Traefik never needs access to the Docker socket at all.

Behind that proxy sits a small Flask portal that is the only thing with the authority to start or stop a student’s container. When a student logs in, the portal checks their password against an Argon2id hash, starts their specific container if it isn’t already running, and waits (polling the container’s own HTTP port rather than just sleeping a fixed number of seconds) until code-server inside it is actually ready to answer. A background thread in the same portal walks the list of running containers on a timer and stops whichever ones have gone quiet, returning their memory to whatever student logs in next. Every container the portal creates starts completely fresh instead of resuming a stopped one: restarting from scratch each time is what guarantees a fresh access token can never collide with a token from a previous session.

Once a student’s environment is running, it offers more than a terminal. A monitor view, built on noVNC, shows whatever the student’s Pygame Zero game is actually rendering: necessary the moment “coding” means “coding something you can see move on screen.” Audio came later and had to fight for the same tiny resource budget the video path was already spending: PulseAudio inside the container publishes a virtual sink, a second websockify process (the same tool already bridging the video, so no new dependency) carries that raw audio to the browser over its own WebSocket, and a small script decodes it with the Web Audio API. The two channels, video and audio, run side by side through the same proxy, on the same per-student cgroup limit, sharing a budget that was already accounted for before either of them needed to make a sound.

Three plain screenshots side by side: the portal's menu, offering a choice between opening VS Code or the monitor; VS Code in the browser with a Pygame Zero script open; and the noVNC monitor, its settings panel open, rendering a small demo game live.
A student's three screens, left to right and top to bottom: the portal's menu, VS Code, and the noVNC monitor (settings panel open) rendering a demo game written for this screenshot. All three run as ordinary browser pages, with no native app involved.

Architecture

Code Sphere: architecture A student's browser talks to Traefik on a single public port, which routes by URL path to either the Flask portal or a specific student's container. The portal starts a student's container only on login and stops it after idle time. Each running container exposes VS Code, a screen monitor over noVNC, and audio over a separate websockify channel, all through the same Traefik routes. Student browser Chromebook, port 80 only Traefik static routes, one per slot Flask portal auth, start/stop, GC idle Student container created, stopped by default code-server VS Code in the browser noVNC: visual monitor websockify: audio (PCM) starts / stops 60 slots declared, ~30 running at once

Decisions

DECISION 01/04 · Container lifecycle

ChosenDeclare all 60 student containers up front but never auto-start them: they exist in a stopped, created state, and the portal starts one only when that student logs in, stopping it again after a period of inactivity

Discarded insteadRun all 60 containers all the time

Each code-server container costs 400-700 MB of RAM even sitting idle; 60 of them running simultaneously overruns a 16 GB target server by three to four times, but in practice a class of 30 doesn't all peak their resource use at once. The real ceiling is class size rather than total student count

Cost acceptedThe first login of the day for any student takes 30-45 seconds while their container boots and code-server initializes. A pre-warm script lets a teacher start a class ahead of time, but a teacher who forgets to run it has students staring at a loading screen right when the lesson starts

DECISION 02/04 · Reverse proxy routing

ChosenRoute all traffic through Traefik using a statically generated routes file, with every student's URL path pre-declared before the proxy ever starts

Discarded insteadDocker label-based routing, where each container advertises its own route as it starts

Label-based routing looked simpler until it broke: Traefik's Docker provider talks to the Docker API at a fixed, outdated version, and modern Docker Engine refuses connections below its minimum supported version. No environment variable fixes it, because Traefik negotiates the version internally. Static routes sidestep the incompatibility entirely and don't need Traefik touching the Docker socket at all

Cost acceptedA new student slot needs a regeneration script and a proxy restart instead of just appearing automatically, an acceptable cost, since the number of students is fixed once at setup and doesn't change mid-semester

DECISION 03/04 · Game audio delivery

ChosenStream raw, uncompressed audio (PCM) from each student's container to the browser over a plain WebSocket, decoded client-side with the Web Audio API

Discarded insteadCompress the audio with Opus through ffmpeg and play it through a standard HTML audio element

Both were built and measured on the real image. Opus used a fraction of the bandwidth, but the browser's audio element buffers 1 to 3 seconds before playing anything: worthless for a game reacting to a key press. Raw PCM has no codec buffer at all, so the only latency is a deliberately small jitter buffer

Cost accepted3.8% of a CPU core and about 350 kbps of bandwidth per student, taken from the exact same resource budget a Pygame Zero game already nearly maxes out, a cost paid in the one place this platform can least afford it, accepted because the alternative simply didn't work for the use case

DECISION 04/04 · Password storage

ChosenHash every student password with Argon2id, using the library's default cost parameters, with automatic re-hashing on login if the defaults ever change

Discarded insteadbcrypt

Students are required only a 6-character minimum password, which makes GPU-accelerated cracking a real threat if the password file ever leaks; Argon2 is memory-hard in a way bcrypt isn't, which is exactly the property that resists GPU acceleration

Cost acceptedEach login takes roughly 50-100 ms just for the hash verification, deliberately slow, and fine for about 60 logins at the start of a class, though it wouldn't scale to thousands of logins per second

Invariants

  • A student container never starts on its own: it stays in a created, stopped state until that student logs in

    Guaranteed bythe `profiles: ["manual"]` setting on every student service in docker-compose.yml, which excludes them from a plain `docker compose up`

  • An idle student container is eventually stopped, returning its memory to the pool

    Guaranteed bya background garbage-collection thread in the portal that tracks last activity per container and stops anything past the idle threshold

  • A restarted container never reuses a previous session's access token

    Guaranteed bythe portal removes and fully recreates the container on every start, rather than resuming a stopped one, so a fresh token is generated every time

  • A student's password is never stored or displayed in a form that could be read back

    Guaranteed byArgon2id hashing in PHC format; the portal's login path only ever calls verify, never decrypt

  • A failed audio daemon costs the class silence, never a crashed game

    Guaranteed bySDL_AUDIODRIVER is set to the fallback list `pulse,dummy` instead of just `pulse`, a guarantee measured on the real image rather than assumed theoretically: with `pulse` alone, a PulseAudio failure raises an exception that kills the student's running game outright

What broke

Symptom
On Windows machines running the platform through WSL2, Traefik would start and route nothing: the routing file it needed appeared empty to the container, even though it existed on the host and had just been generated
Root cause
The routes file was mounted as part of a directory bind-mount that Traefik was also configured to watch for live changes. WSL2's directory bind-mounts are unreliable for files created immediately before the container starts, and on top of that, Traefik's file-watching relies on inotify, which doesn't propagate through the 9P filesystem layer WSL2 uses to share files with Linux containers, so even a successful mount would never have picked up a later change anyway
Fix
Mounted the specific file instead of its containing directory, and turned off file-watching entirely. The startup script already force-recreates the Traefik container on every run, so the file is read fresh regardless
Prevention
Documented as a standing known-issue in the reverse-proxy's own architecture decision record, specifically so a future cleanup pass that reintroduces directory-watching for convenience doesn't quietly break the platform again on the exact environment (a teacher's Windows laptop) most likely to be running it

Results

60student accounts on hardware sized for ~30 concurrentcount of alunos/alunoNN directories on the real deployment, 2026-08-29
27commitsgit log --oneline count on main, 2026-08-29
11architecture decision recordscount of files in docs/decisions/, 2026-08-29
3.8%of one CPU core spent on game audio, measuredADR-0011, measured end to end on the built student image
13portal and orchestration source filescount of .py/.sh/.js files outside student home directories, 2026-08-29
since May 2026in continuous productiondate of the first commit

Full stack

Backend

  • Flask (Python)
  • Argon2id (argon2-cffi)
  • Docker SDK for Python

Frontend

  • code-server (VS Code in the browser)
  • noVNC
  • Web Audio API

Data

  • JSON file store (portal-data/alunos.json)

Infra

  • Traefik v3.2
  • Docker Compose
  • PulseAudio + websockify
  • Cloudflare Tunnel

Interested in a project like this? Get in touch.

Get in touch