Deployment internals¶
Interlock ships as three deployables from one repository: the backend container on Fly.io, the static console on Vercel, and this documentation on GitHub Pages. The operator's walkthrough is in Deployment; this page explains the files that make it work and the constraints behind them.
Why a container and not a function¶
The backend is one stateful process. The simulator, the loaded DQN, the Jev cache and any open escalation live in memory, and the play loop runs between messages. Serverless hosts are stateless, scale to zero when idle and close WebSockets at the function's maximum duration, so the backend cannot live there. It runs as one always-on container, and the console points at it. See Server and session for the one-session-per-process design.
The Dockerfile¶
FROM python:3.12-slim-bookworm
COPY --from=ghcr.io/astral-sh/uv:0.9.21 /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never PYTHONUNBUFFERED=1
WORKDIR /app
COPY pyproject.toml uv.lock .python-version ./
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev --no-install-project
COPY backend ./backend
COPY models/dqn_highway.zip ./models/dqn_highway.zip
COPY internal/eval/latest.json ./internal/eval/latest.json
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
ENV SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy MPLBACKEND=Agg PORT=8000
EXPOSE 8000
CMD ["sh", "-c", "exec uv run --no-sync uvicorn backend.server:app --host 0.0.0.0 --port ${PORT}"]
Layer by layer:
| Layer | Purpose |
|---|---|
| Base and uv | python:3.12-slim-bookworm with the uv 0.9.21 binaries copied from the official image, so the container resolves the same lock as the laptop |
ENV flags |
Compile bytecode at install, copy instead of hard-link across layers, never download a Python, unbuffered logs |
| Dependency layer | Only the lock, pyproject.toml and .python-version are copied, then uv sync --frozen --no-dev --no-install-project with a cache mount. A code change does not re-download torch |
| Project layer | backend/, the promoted checkpoint, the eval report for the metrics tab, then a second uv sync --frozen --no-dev that installs the package itself. A missing models/dqn_highway.zip fails the build here, on purpose |
| Runtime env | highway-env imports pygame; SDL_VIDEODRIVER=dummy and SDL_AUDIODRIVER=dummy keep it headless. MPLBACKEND=Agg covers any matplotlib import. PORT defaults to 8000 |
CMD |
exec so uvicorn is PID 1 and receives signals; --no-sync so startup never touches the network; --host 0.0.0.0 so Fly can reach it |
The Jev cache ships when it exists
backend/logs/jev_cache.jsonl is inside the backend/ copy because .dockerignore lets it through. When present, the demo seeds answer from cache without a network call. The shield-off logs come along the same way, backend/logs/eval/*_off/seed*.jsonl and backend/logs/runs/*_off.jsonl, so the unshielded twin shows for every seed the evaluation covered. The image carries a snapshot; appends inside the container stay in the container.
.dockerignore is an allowlist¶
*
!pyproject.toml
!uv.lock
!.python-version
!backend/
**/__pycache__
backend/.env
backend/logs/*
!backend/logs/.gitkeep
!backend/logs/jev_cache.jsonl
!backend/logs/runs/*_off.jsonl
!backend/logs/eval/*_off/seed*.jsonl
!models/dqn_highway.zip
!internal/eval/latest.json
Everything is excluded, then the needed files are let back in. backend/.env is excluded explicitly even though backend/ is allowed, and backend/logs/* is dropped except the keep file and the cache. The rule that follows: a new runtime file the server reads must be added to .dockerignore and copied in the Dockerfile, or the container starts without it.
CPU-only torch on Linux¶
pyproject.toml pins torch to the CPU index on Linux only:
[tool.uv.sources]
torch = [{ index = "pytorch-cpu", marker = "sys_platform == 'linux'" }]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
macOS keeps the PyPI wheel. Without this the image would grow by the CUDA libraries. Keep the source when bumping torch.
Fly.io: fly.toml¶
| Key | Value | Why |
|---|---|---|
app |
interlock-backend |
The Fly app name; the URL is https://interlock-backend.fly.dev |
primary_region |
iad |
One region, one machine |
[build] dockerfile |
Dockerfile |
fly deploy builds remotely from this directory with .dockerignore applied, so the gitignored checkpoint and cache ship without a commit |
[env] ESCALATION_TIMEOUT_S |
"0" |
Wait for the operator, never default-deny in a live session |
[env] CONSOLE_ORIGINS |
every Vercel production alias of the console, including the one the others redirect to | CORS for the console's fetches |
[http_service] internal_port |
8000 | Matches PORT |
force_https |
true | The console connects over wss |
auto_stop_machines |
"off" |
The session lives in memory; never scale to zero |
auto_start_machines, min_machines_running |
true, 1 | One machine, always up |
[http_service.concurrency] |
connections, soft limit 200 |
Every browser on the console holds one WebSocket |
[[http_service.checks]] |
GET /health, 15 s interval, 5 s timeout, 30 s grace |
torch and highway-env take a few seconds to import |
[[vm]] |
shared-cpu-2x, 2 GB |
Enough for the DQN on CPU and the simulator |
The API key is a Fly secret and never appears in the file:
--ha=false matters: Fly otherwise starts two machines for high availability, and two machines would be two unrelated sessions behind one URL.
The machine is always on
With auto-stop off the machine bills continuously while it exists. Destroy the app when the deployment is no longer needed.
Vercel: the console¶
- The Vercel project is
interlock-console, rooted atfrontend/.frontend/.vercel/project.jsonholds the link. VITE_BACKEND_URLis a build-time variable read byfrontend/src/backend-url.ts. Set, every fetch goes to that origin and the socket to the same host aswss. Unset, the page stays same-origin, which is the local Vite proxy and the Playwright e2e mock backend.- The build is
tscthenvite build; the output is static.
CORS scope¶
CONSOLE_ORIGINS adds CORSMiddleware for GET only. Browsers do not apply CORS to the WebSocket upgrade, so the variable covers exactly /health and /eval/latest. Unset, the server is same-origin only, which is what the Vite proxy needs locally.
Order of operations¶
- Deploy the backend and note its
httpsURL. - Put that URL in
VITE_BACKEND_URLon Vercel and deploy the console. - Put the console's production aliases in
CONSOLE_ORIGINSunder[env]infly.tomland runfly deployagain.
GitHub Pages: the docs¶
.github/workflows/docs.yml builds this site on every push to main that touches docs/**, mkdocs.yml, pyproject.toml, uv.lock or the workflow itself, and on manual dispatch.
uv sync --only-group docsinstalls mkdocs-material from thedocsdependency group and nothing else. The project and its runtime dependencies are omitted, so torch is never downloaded in CI.uv run --no-sync mkdocs build --strictfails the job on a broken link or a missing page.actions/upload-pages-artifactuploadssite/andactions/deploy-pagespublishes it.- The repository's Pages source must be set to GitHub Actions, once, in the repository settings.
Locally the same commands are uv run mkdocs serve -a 127.0.0.1:8080 and uv run mkdocs build --strict. Port 8000 belongs to the backend.
What lives where in production¶
| State | Location | Survives a redeploy |
|---|---|---|
| The promoted checkpoint | Image layer | Yes, rebuilt from the build context |
| The eval report for the metrics tab | Image layer | Yes |
| The Jev cache as shipped | Image layer | Yes, as a snapshot |
| Cache appends from live questions | Container filesystem | No |
Run logs under backend/logs/runs/ |
Container filesystem | No |
| The API key | Fly secret | Yes |
| The open episode and escalation | Process memory | No; a restart starts a fresh session |
Run logs written in production are therefore available to the replay picker only until the next deploy or restart. That is acceptable for a demo; a persistent volume would be the next step if logs mattered.