Deployment
Self-host the ALE Robotics website and verification service with Docker Compose, Caddy, GitHub OAuth, an age decryption key, and a SQLite or Postgres database.
Overview
This page covers running the ALE Robotics website and verification service yourself. The site is not a benchmark runner: participants run the benchmark locally, produce an encrypted submission bundle, and upload it here for validation and review. Your instance holds the age private key, verifies bundles, and serves the leaderboard.
The stack is three containers behind a reverse proxy:
| Service | Role | Notes |
|---|---|---|
caddy | Reverse proxy, automatic HTTPS | Terminates TLS, routes /api/* to api, everything else to web |
web | Next.js App Router | Public site + authenticated submit flow |
api | FastAPI | Auth, challenges, decryption, validation, scoring, admin review |
Two volumes: db-data (persistent, holds the SQLite file) and submission-tmp (ephemeral scratch for bundle processing; safe to wipe).
Quick start (self-hosting)
git clone <your-fork> ale-robotics-website && cd ale-robotics-website
cp .env.example .env # then edit — see "Required environment"
docker compose up --build # starts caddy + web + apiOn first boot the api container runs Alembic migrations and seeds the task registry:
docker compose exec api alembic upgrade head
docker compose exec api python -m app.seed # add --demo for sample leaderboard rowsVisit https://<your-domain> (or http://localhost for local development). Health check: GET /api/v1/health.
Local development
For iterating without containers, run the two apps directly. All API settings use the ALE_ env prefix (see apps/api/app/config.py):
# API
cd apps/api && pip install -e ".[dev]"
ALE_ENVIRONMENT=development uvicorn app.main:create_app --factory --reload --port 8000
# Web (separate shell)
pnpm --filter @ale/web dev # http://localhost:3000In development, if no age key exists one is auto-generated (ALE_SUBMISSION_GENERATE_DEV_KEY=true), and a POST /api/v1/auth/dev-login mock login is available. Both are refused in production.
Required environment
Set these before exposing the site. Defaults marked dev-only are insecure and must be overridden.
| Variable | Purpose |
|---|---|
ALE_ENVIRONMENT | production disables dev login and dev key generation |
ALE_PUBLIC_BASE_URL | External origin, e.g. https://ale.example.org |
ALE_WEB_ORIGIN | Comma-separated CORS origins allowed to call the API |
ALE_SESSION_SECRET | Signs session cookies (rotate = logs everyone out) |
ALE_CHALLENGE_SIGNING_SECRET | Signs one-time challenge tokens |
ALE_GITHUB_OAUTH_CLIENT_ID / _SECRET | GitHub OAuth app credentials |
ALE_ADMIN_GITHUB_LOGINS | Comma-separated GitHub logins granted admin |
ALE_SUBMISSION_AGE_KEY_PATH | Path to the mounted age private key |
ALE_SUBMISSION_KEY_ID | Public key id returned by /api/v1/submission-key |
ALE_DATABASE_URL | SQLite (default) or Postgres URL |
ALE_KEEP_SUBMISSION_BUNDLES | Default false — delete plaintext + ciphertext after processing |
Never place the private key, secrets, or .env in the image or in Git.
Domain and automatic HTTPS
Caddy obtains and renews certificates automatically. A minimal Caddyfile:
ale.example.org {
encode zstd gzip
handle /api/* {
reverse_proxy api:8000
}
handle {
reverse_proxy web:3000
}
log {
output file /var/log/caddy/access.log {
roll_size 50MiB
roll_keep 10
}
}
}Point the domain's A/AAAA records at the host and open ports 80/443 so the ACME challenge can complete.
GitHub OAuth setup
Create an OAuth App at GitHub → Settings → Developer settings → OAuth Apps:
- Homepage URL:
https://ale.example.org - Authorization callback URL:
https://ale.example.org/api/v1/auth/github/callback
The requested scope is read:user only. Copy the client id/secret into ALE_GITHUB_OAUTH_CLIENT_ID and ALE_GITHUB_OAUTH_CLIENT_SECRET. Admin status is derived from ALE_ADMIN_GITHUB_LOGINS at each login — it is never taken from an upload field, and successful decryption never grants a verification level.
age key generation and rotation
Bundles are encrypted to an age X25519 recipient using the audited pyrage library — never hand-rolled crypto. The runner fetches only the public key from GET /api/v1/submission-key; your instance holds the private key.
Generate a production key with the standard tool and lock down permissions:
age-keygen -o secrets/submission.age-key # prints "Public key: age1..."
chmod 600 secrets/submission.age-keyMount it read-only into the api container and set ALE_SUBMISSION_AGE_KEY_PATH plus a stable ALE_SUBMISSION_KEY_ID (e.g. prod-2026-07).
Rotation. The loader reads a single identity, so rotation is a scheduled hard cutover:
- Generate a new key file and set a new
ALE_SUBMISSION_KEY_ID. - Restart
api./api/v1/submission-keynow returns the newpublic_key+key_id. - Clients re-fetch the key before their next run; the
key_idlets them detect the change. Because challenge tokens are short-lived (ALE_CHALLENGE_TTL_SECONDS, default 24h), the in-flight window is bounded — bundles encrypted to the old key after cutover will fail to decrypt and must be regenerated.
Verify the served key id after any change:
curl -s https://ale.example.org/api/v1/submission-key | jq '{algorithm, key_id, public_key}'Database backup and restore
The default is SQLite in WAL mode inside db-data at data/ale.sqlite3. Take consistent online backups with the SQLite backup API (WAL-safe, no downtime):
docker compose exec api sqlite3 data/ale.sqlite3 ".backup 'data/backup-$(date +%F).sqlite3'"To restore, stop api, replace data/ale.sqlite3 (and remove -wal/-shm sidecars), then start again. Back up your age key file and secrets separately.
SQLite → Postgres
All database access goes through ALE_DATABASE_URL, and the SQLite PRAGMA hook is a no-op on other backends, so moving to Postgres is a config change:
export ALE_DATABASE_URL="postgresql+psycopg://ale:***@db:5432/ale"
docker compose exec api alembic upgrade head # create schema on Postgres
docker compose exec api python -m app.seed # re-seed registryMigrate existing rows with your preferred SQLite→Postgres tool before cutting over. Postgres is recommended for multi-node or high-concurrency deployments; SQLite is sufficient for a single node.
Log rotation
The API emits structured JSON to stdout with secret redaction (age keys, tokens, and OAuth secrets are stripped before logging). Rotate container logs via the Docker driver, and let Caddy roll its own access logs (shown above):
# docker-compose.yml (per service)
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"Upgrading benchmark task manifests
The site's canonical task list lives in content/tasks/registry.json. Sync it from a benchmark release manifest rather than editing by hand — paper targets, versions, and budgets are synced from the release manifest and are not published in v0.5.
pnpm sync:benchmark --manifest ../ale-robotics/release/manifest.json --commit <sha>The script merges manifest values over the current registry, stamps provenance, validates against the schema, and refuses to write on validation failure, so a bad manifest cannot corrupt the live site. After syncing, rebuild web and re-run python -m app.seed to update the database release row. See Contributing and the Architecture overview for the surrounding data flow.

