Skip to content
Agents' Last Exam for Robotics
Contributing & ops

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:

ServiceRoleNotes
caddyReverse proxy, automatic HTTPSTerminates TLS, routes /api/* to api, everything else to web
webNext.js App RouterPublic site + authenticated submit flow
apiFastAPIAuth, 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 + api

On 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 rows

Visit 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:3000

In 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.

VariablePurpose
ALE_ENVIRONMENTproduction disables dev login and dev key generation
ALE_PUBLIC_BASE_URLExternal origin, e.g. https://ale.example.org
ALE_WEB_ORIGINComma-separated CORS origins allowed to call the API
ALE_SESSION_SECRETSigns session cookies (rotate = logs everyone out)
ALE_CHALLENGE_SIGNING_SECRETSigns one-time challenge tokens
ALE_GITHUB_OAUTH_CLIENT_ID / _SECRETGitHub OAuth app credentials
ALE_ADMIN_GITHUB_LOGINSComma-separated GitHub logins granted admin
ALE_SUBMISSION_AGE_KEY_PATHPath to the mounted age private key
ALE_SUBMISSION_KEY_IDPublic key id returned by /api/v1/submission-key
ALE_DATABASE_URLSQLite (default) or Postgres URL
ALE_KEEP_SUBMISSION_BUNDLESDefault 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-key

Mount 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:

  1. Generate a new key file and set a new ALE_SUBMISSION_KEY_ID.
  2. Restart api. /api/v1/submission-key now returns the new public_key + key_id.
  3. Clients re-fetch the key before their next run; the key_id lets 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 registry

Migrate 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.