Skip to content

Self-hosting

skael is a single Go binary that embeds the dashboard and serves the API. It needs one thing: a Postgres database.

Every variable the server reads. DATABASE_URL is the only required one; everything else has a working default. S3 settings are separate — see Object storage.

Migrations run automatically on startup. Auth is via user accounts and personal API keys — there is no static server key; sign up to create the first account.

VariableRequiredDefaultDescription
DATABASE_URLyesPostgres connection string
STORAGE_PATHno./data/skillsArchive storage: a local directory, or s3://bucket/prefix for S3
LISTEN_ADDRno:8080HTTP listen address
DISABLE_SIGNUPnofalsetrue closes signups once you’ve created the accounts you need. Only the literal true counts. The server logs a startup warning while it is unset
COOKIE_SECUREnofalsetrue marks the session cookie Secure. Requires TLS in front — a browser refuses a Secure cookie over plain HTTP, which breaks login with no error. The server logs a startup warning while it is unset
TRUSTED_PROXIESnoComma-separated addresses or CIDR blocks whose X-Forwarded-For / X-Real-IP are believed. Unset means neither header is trusted and the socket address wins — correct for a directly exposed server. Set it when running behind a reverse proxy, or every client shares one rate-limit bucket. See Production
CORS_ORIGINSnoComma-separated allowed origins, e.g. https://app.example.com,http://localhost:5173. Unset means no CORS headers are sent
METRICS_ENABLEDnotrueSet to false to drop the /metrics Prometheus endpoint and its instrumentation. Only the literal false disables it
GITHUB_TOKENnoGitHub token used by skill import; raises the GitHub API rate limit
VariableRequiredDefaultDescription
QUALITY_FLOORno0Minimum headline quality score (0–100) a verified evaluation must reach to release a version held for review. 0 accepts any verified report with a complete panel and no critical contract violations

QUALITY_FLOOR is the only setting whose value the server validates. A value that isn’t a number, or falls outside 0–100, stops the server from booting with an error naming the variable. Every other numeric and duration setting silently falls back to its default instead: DB_MAX_CONNS=twenty gives you 25 with no warning, RATE_LIMIT_WRITE= gives you 60, LOG_LEVEL=verbose gives you info. The floor is treated differently on purpose — a security control that reads as unset because of a typo looks configured and does nothing.

VariableRequiredDefaultDescription
DB_MAX_CONNSno25Maximum connections in the pool
DB_MIN_CONNSno5Idle connections the pool keeps open
DB_MAX_CONN_LIFETIMEno1hMaximum lifetime of a connection before it is closed (Go duration)
DB_MAX_CONN_IDLE_TIMEno30mMaximum idle time before a connection is closed (Go duration)
DB_HEALTH_CHECK_PERIODno1mInterval between pool health checks (Go duration)
VariableRequiredDefaultDescription
RATE_LIMIT_AUTHno20Per-minute budget for /api/auth/* — login, signup, password reset
RATE_LIMIT_EVENTSno600Per-minute budget for POST /api/events — activation tracking
RATE_LIMIT_READno300Per-minute budget for GET/HEAD routes — list, search, manifest, downloads
RATE_LIMIT_WRITEno60Per-minute budget for every other mutating route — publish, import, delete
RATE_LIMIT_SUITESno20Per-minute budget for POST /api/eval/suites, which accepts up to a 10MB archive per call

/api/auth/* is keyed by source IP alone: those requests are unauthenticated, so an X-API-Key header on them is unverified and must not mint a fresh budget. Every other class is keyed by API key where one is present and by IP otherwise, and is additionally capped by a shared per-IP ceiling of ten times the class limit, checked first — so one source address cannot get more by rotating keys. Raising a class’s limit raises its ceiling with it. Over-limit requests get a 429 with Retry-After, which the CLI honours.

VariableRequiredDefaultDescription
EXTERNAL_SCAN_CMDnoOpt-in external scanner run over each skill on publish and import. {dir} is replaced with the skill directory; the command must emit SARIF on stdout, e.g. gitleaks dir {dir} --report-format sarif --report-path /dev/stdout. Findings merge into the built-in scan
EXTERNAL_SCAN_TIMEOUTno60sPer-scan timeout for EXTERNAL_SCAN_CMD (Go duration)
EVENT_RETENTION_DAYSno90Days of activation events to keep. Older rows are purged once, at startup — not on a schedule. 0 disables the purge
LOG_LEVELnoinfotrace, debug, info, warn, error, fatal, panic
LOG_FORMATnopretty for colorized console output. Anything else, including unset, logs JSON
LOG_PRETTYnofalsetrue does the same as LOG_FORMAT=pretty; either one is enough
Terminal window
docker run -p 8080:8080 \
-e DATABASE_URL="postgres://user:pass@host:5432/skael?sslmode=disable" \
-v skael-data:/data/skills \
-e STORAGE_PATH=/data/skills \
ghcr.io/skael-dev/skael:latest
Terminal window
docker compose up -d

This starts the platform plus a Postgres container with a persistent volume. The platform is at http://localhost:8080; sign up to create your first account. Publishing and scanning work right away. Evaluations do not — that’s a separate opt-in piece, see below.

The server queues evaluation jobs but never runs them — no Docker socket and no LLM key live on it. Running evaluations requires a separate skael-worker process, because it needs a Docker daemon to sandbox each run and a direct Anthropic API key to judge the result.

Run it on the host, alongside Compose rather than inside it:

Terminal window
export SKAEL_ENDPOINT=http://localhost:8080
export SKAEL_API_KEY=<a personal API key with permission to claim eval jobs>
export ANTHROPIC_API_KEY=<your direct Anthropic API key>
skael-worker

This is enough for a VPS: no interactive login step, no credential directory to provision. ANTHROPIC_API_KEY covers both the judge and the claude-code panel agent, since the worker forwards it into the sandbox as an environment variable.

The worker also ships as an image, published with every release:

Terminal window
docker run -d \
-e SKAEL_ENDPOINT=http://localhost:8080 \
-e SKAEL_API_KEY=<a personal API key> \
-e ANTHROPIC_API_KEY=<your direct Anthropic API key> \
-e WORKER_RUN_ROOT=/var/lib/skael/run \
-e WORKER_WORK_ROOT=/var/lib/skael/work \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /var/lib/skael/run:/var/lib/skael/run \
-v /var/lib/skael/work:/var/lib/skael/work \
ghcr.io/skael-dev/skael-worker:latest

docker compose --profile worker up runs the same thing from source.

Two rules govern the container, and both are load-bearing. The worker starts sandbox containers as siblings through the mounted socket, so the host daemon resolves every bind source. WORKER_RUN_ROOT and WORKER_WORK_ROOT must therefore be bound at the same path on both sides — never a named volume. Docker creates a missing bind source as an empty directory instead of failing, so a container-local path yields a sandbox with no task and no verifier, which scores as a skill that did nothing. The worker refuses to start containerized unless both are set.

Auth must also arrive as environment variables rather than as a mounted ~/.claude. A subscription-billed panel works in a container through CLAUDE_CODE_OAUTH_TOKEN (claude setup-token), which is a token rather than a directory.

These two tables are duplicated on Quality scoring — change both together.

Required — the worker exits at startup naming whichever is missing:

VariableDescription
SKAEL_ENDPOINTBase URL of the skael server the worker claims jobs from
SKAEL_API_KEYAPI key the worker authenticates with
ANTHROPIC_API_KEYDirect Anthropic API key for the judge model, and (forwarded into the sandbox) for the claude-code panel agent — never a subscription CLI on PATH

Optional, with defaults:

VariableDefaultDescription
CLAUDE_CODE_OAUTH_TOKENSubscription auth for the claude-code panel agent, as an alternative to ANTHROPIC_API_KEY. Generate with claude setup-token. Set beside ANTHROPIC_BASE_URL it splits the two: the judge keeps the gateway, the panel runs on the subscription
WORKER_ID{hostname}-{pid}Identifies this worker in job leases
WORKER_LEASE5mHow long a claimed job’s lease lasts before it’s considered abandoned
WORKER_POLL15sInterval between claim attempts when the queue is empty
WORKER_WORK_ROOTOS temp dirDirectory to materialise eval workspaces under
WORKER_CONCURRENCY1Concurrent sandbox sessions. Must be a positive integer
WORKER_GRADE_CONCURRENCYwhetstone’s default (8)Concurrent judge calls. A container is bounded by CPU and memory, a judge call by the account’s rate limit, so the two are separate knobs
ANTHROPIC_AUTH_TOKENCredential sent as Authorization: Bearer — what OpenRouter issues. An alternative to ANTHROPIC_API_KEY, and it wins when both are set
ANTHROPIC_BASE_URLhttps://api.anthropic.comAn Anthropic-compatible gateway for the judge and the panel, unless CLAUDE_CODE_OAUTH_TOKEN is also set. Posts to {base}/v1/messages
LLM_MODELshipped defaultsComma-separated model ids, most capable first. The first judges every run and leads the panel; later entries are the panel’s floor members at the deep tier. Required behind a gateway that namespaces its identifiers

The judge’s credential is checked at startup — the worker exits naming the variables to set. The panel agent is not checked at startup: if no credential reaches the sandbox and no auth directory is mounted, the worker logs a warning naming the missing variables and the job comes back with an incomplete panel rather than an error. Only the claude-code adapter is wired up today. See Quality scoring for the OpenRouter example and what changing the model means for score comparability.

By default, skill archives are stored on the local filesystem under STORAGE_PATH (paths are validated to stay within the storage root). In Docker/Kubernetes, mount a persistent volume there — otherwise archives are lost when the container restarts.

For ephemeral/k8s deployments or to run multiple replicas, point STORAGE_PATH at S3-compatible object storage (AWS S3, MinIO, Cloudflare R2, Backblaze B2, DigitalOcean Spaces):

Terminal window
docker run -p 8080:8080 \
-e DATABASE_URL="postgres://user:pass@host:5432/skael?sslmode=disable" \
-e STORAGE_PATH="s3://my-bucket/skael" \
-e S3_REGION="us-east-1" \
-e S3_ACCESS_KEY_ID="..." -e S3_SECRET_ACCESS_KEY="..." \
ghcr.io/skael-dev/skael:latest
VariableDefaultDescription
STORAGE_PATH./data/skillss3://bucket/prefix switches to S3; any other value is a local path
S3_ENDPOINTs3.amazonaws.comSet for MinIO, R2, Spaces. No AWS_* fallback
S3_REGIONus-east-1Falls back to AWS_REGION when unset
S3_ACCESS_KEY_IDFalls back to AWS_ACCESS_KEY_ID
S3_SECRET_ACCESS_KEYFalls back to AWS_SECRET_ACCESS_KEY
S3_USE_PATH_STYLEfalsetrue for MinIO
S3_USE_SSLtruefalse for local MinIO. Only the literal false disables it

Credentials resolve in one step: if a key and a secret are both found — under either the S3_* or the AWS_* name — they are used as static credentials. If either is missing, skael falls back to an IAM instance role (EC2, ECS, EKS) and ignores the one that was set. So half a key pair is the same as none.

The bucket must already exist. skael checks it at startup and refuses to boot if it is missing or unreachable.