Lab exams on your deployment
Limiting an exam to the lab’s network
The app can only trust a visitor’s address if something it trusts vouches for it (the rule and its tests: packages/judge/src/client-ip.ts).
On Vercel: nothing to set up
Every request reaches a Vercel deployment through Vercel’s edge, which overwrites the forwarded-for headers with the address it saw and doesn’t pass on addresses a visitor sends. The app sees VERCEL=1 (Vercel sets it) and reads x-vercel-forwarded-for, which no proxy in front of Vercel can replace. PROXY_SECRET isn’t needed there and is ignored. VERCEL=1 comes with Vercel’s system environment variables (Settings › Environment Variables › “Automatically expose System Environment Variables”, on by default); with it off, the app can’t tell it’s on Vercel and refuses network-limited exams rather than guess.
Don’t put another proxy or CDN in front of a Vercel deployment for exams: every student would then appear to come from that proxy.
On any other host: name the header it sets
Netlify, Cloudflare, Fly.io, Render, Railway and most other hosts put the visitor’s address in a header of their own. Set CLIENT_IP_HEADER to that header’s name and the app believes it. It takes priority over the other two ways.
| Host | CLIENT_IP_HEADER |
|---|---|
| Cloudflare (proxied DNS, or Workers and Pages) | cf-connecting-ip |
| Netlify | x-nf-client-connection-ip |
| Fly.io | fly-client-ip |
A host that only appends to X-Forwarded-For |
x-forwarded-for (the app takes the last entry, the one the host’s edge added; earlier ones are whatever the visitor sent) |
Check your host’s documentation, because you are vouching for two things:
- The host sets this header on every request and replaces any value the visitor sent.
- Visitors can’t reach the app around the host. If the app is also reachable directly (a raw server address, another domain, a
*.vercel.appaddress behind Cloudflare), a visitor can send the header themselves and appear to be in the lab.
Then check it: open any assignment’s settings and turn on Lab exam. Under the networks box, “Your address right now” shows the address the server sees for you. It should match what a “what is my IP” search shows. If it shows an address of the host’s own network instead, the header is the wrong one.
Behind Caddy (docker-compose.prod.yml)
Caddy (deploy/Caddyfile) sets X-Client-IP to the address it saw and X-Proxy-Secret to a secret shared with the app, replacing anything the visitor sent. The app believes the address only when the secret matches (apps/web/lib/api/client-ip.ts), so reaching the app directly and sending your own headers doesn’t work.
- Make a secret:
openssl rand -hex 24. - Set
PROXY_SECRETto it in the environment of bothcaddyandweb(docker-compose.prod.ymlpasses it to both from.env). - If a load balancer or CDN (Cloudflare, an AWS ALB) sits in front of Caddy, list its addresses under
trusted_proxiesin the Caddyfile’s global block. Otherwise every student appears to come from the balancer.
With none of the three (CLIENT_IP_HEADER, Vercel, PROXY_SECRET), exams still work, but saving one with networks is refused with “This server can’t confirm where students connect from”.
Networks are IPv4 or IPv6 addresses or ranges (10.20.0.0/16, 2001:db8:42::/48). A lab behind one NAT address is a single entry. For a dual-stack lab, list both its IPv4 range and its IPv6 prefix.
When the runner fails
A submission the runner can’t judge (a crashed sandbox, a runner restarting) is retried; if it still fails it’s marked “System error”. Runners then re-judge system errors by themselves a few minutes later (up to three rounds, for a week), and the result counts from when the student pressed Submit, so exam work isn’t lost. After a longer outage, the gradebook’s Re-judge system errors queues every one for that assignment again. Give more time remains for a student who lost time to the outage itself.