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.

Architecture
Decisions
DECISION 01/04 · Container lifecycle
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
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
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
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 by
the `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 by
a background garbage-collection thread in the portal that tracks last activity per container and stops anything past the idle thresholdA restarted container never reuses a previous session's access token
Guaranteed by
the portal removes and fully recreates the container on every start, rather than resuming a stopped one, so a fresh token is generated every timeA student's password is never stored or displayed in a form that could be read back
Guaranteed by
Argon2id hashing in PHC format; the portal's login path only ever calls verify, never decryptA failed audio daemon costs the class silence, never a crashed game
Guaranteed by
SDL_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
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