Production deployment
The quickstart gets you running in minutes. Production adds five things: TLS termination, secure session cookies, encrypted Postgres connections, persistent storage, and locked-down signups. This page covers all five.
Production checklist
Section titled “Production checklist”| What to change | Why | |
|---|---|---|
| TLS | Put a reverse proxy in front | skael serves plain HTTP; TLS must be terminated upstream |
COOKIE_SECURE=true | Uncomment in .env | Session cookie is refused over plain HTTP when set; login breaks without a proxy |
DATABASE_URL with sslmode=require | Change from sslmode=disable | Encrypts traffic between skael and Postgres |
| Persistent volume or S3 | Mount a volume or set STORAGE_PATH=s3://… | Archives are lost on container restart without one |
DISABLE_SIGNUP=true | Set after creating all accounts | Prevents anyone with network access from registering |
Reverse proxy
Section titled “Reverse proxy”Caddy (recommended)
Section titled “Caddy (recommended)”Caddy terminates TLS automatically via Let’s Encrypt. The full config for a site is one block:
skael.example.com { reverse_proxy localhost:8080}Save this as /etc/caddy/Caddyfile and reload: systemctl reload caddy.
Caddy handles HTTPS certificate provisioning and renewal with no further configuration. If you need HTTP/2 or custom TLS settings, the Caddy docs cover those.
server { listen 80; server_name skael.example.com; return 301 https://$host$request_uri;}
server { listen 443 ssl; server_name skael.example.com;
ssl_certificate /etc/ssl/certs/skael.example.com.crt; ssl_certificate_key /etc/ssl/private/skael.example.com.key;
# skael caps request bodies at 10 MB server-side. The proxy limit must be # higher — nginx would reject the request first and return a 413 before # skael ever sees it. 12 MB gives a safe margin. client_max_body_size 12m;
location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }}Environment
Section titled “Environment”A production .env differs from the default in three places:
# Required — use your actual credentials; sslmode=require encrypts the# connection between skael and Postgres.
# Bind to loopback only. The reverse proxy reaches 127.0.0.1:8080;# exposing 0.0.0.0 would make the plain-HTTP port reachable from outside.LISTEN_ADDR=127.0.0.1:8080
# Tells the browser to send the session cookie only over HTTPS.# Without this, the cookie travels in plaintext on any HTTP request.# If you set this without a TLS proxy in front, login stops working.COOKIE_SECURE=true
# Lock down signups after initial setup (see below).DISABLE_SIGNUP=true
# Optional: use S3-compatible storage instead of a local volume.# STORAGE_PATH=s3://my-bucket/skaelEvery variable above is read by the server. Verified against internal/platform/config.go (DATABASE_URL, STORAGE_PATH, LISTEN_ADDR, DISABLE_SIGNUP) and internal/server/server.go (COOKIE_SECURE).
Storage
Section titled “Storage”By default, skill archives go to ./data/skills (or STORAGE_PATH if set). In Docker, that path disappears when the container is replaced.
Local volume — add a volume mount in docker-compose or docker run:
volumes: - skill-data:/data/skillsenvironment: STORAGE_PATH: /data/skillsS3-compatible object storage — set STORAGE_PATH=s3://bucket/prefix. For full configuration options (endpoint, region, access keys, MinIO path-style), see the self-hosting reference. The bucket must exist before starting skael.
Locking down signups
Section titled “Locking down signups”Do this once, after all accounts are created.
- Deploy with
DISABLE_SIGNUPunset (orfalse). Sign up to create the admin account. - Log in to the dashboard. Go to Settings → API keys and create keys for each teammate who needs CLI access.
- Distribute the API keys. Each person runs
skael setup <url> <key>to connect their CLI. - Set
DISABLE_SIGNUP=truein.envand restart the server. - Verify the lockdown: a POST to
/api/auth/signupmust return403.
curl -s -X POST https://skael.example.com/api/auth/signup \ -H "Content-Type: application/json" \Expected response when signups are disabled — HTTP 403:
{"title":"Forbidden","status":403,"detail":"signup is disabled"}If you see anything other than a 403, DISABLE_SIGNUP=true is not active. Check that the restart completed and the env var is set in the process environment, not only in .env.
Health probes
Section titled “Health probes”skael exposes two endpoints:
| Endpoint | Purpose | Returns |
|---|---|---|
GET /api/health | Liveness — always returns 200 if the process is running | {"status":"ok"} |
GET /api/health/ready | Readiness — checks database and storage connectivity | {"status":"ready","checks":{"database":"ok","storage":"ok"}} or 503 |
Use liveness for restart decisions. Use readiness to gate traffic. Do not use the ready endpoint for liveness — a transient DB blip would restart the pod unnecessarily.
Actual responses from a running server:
GET /api/health → 200{"status":"ok"}
GET /api/health/ready → 200{"status":"ready","checks":{"database":"ok","storage":"ok"}}When a dependency is unavailable, /api/health/ready returns HTTP 503. The response body describes which checks failed without exposing internal details (connection strings, hostnames, etc.).
Kubernetes
Section titled “Kubernetes”livenessProbe: httpGet: path: /api/health port: 8080 initialDelaySeconds: 5 periodSeconds: 10 failureThreshold: 3
readinessProbe: httpGet: path: /api/health/ready port: 8080 initialDelaySeconds: 5 periodSeconds: 10 failureThreshold: 3Docker Compose
Section titled “Docker Compose”The skael image is built on gcr.io/distroless/static-debian12 — no shell, no curl, no wget. Docker’s healthcheck requires an executable inside the container, and distroless has none suitable.
The correct pattern is to run the health check from a separate container:
services: server: image: ghcr.io/skael-dev/skael:latest ports: - "8080:8080" environment: DATABASE_URL: postgres://skael:pass@db:5432/skael?sslmode=require STORAGE_PATH: /data/skills COOKIE_SECURE: "true" DISABLE_SIGNUP: "true" volumes: - skill-data:/data/skills depends_on: db: condition: service_healthy
healthcheck: image: curlimages/curl:latest entrypoint: /bin/sh command: - -c - | until curl -sf http://server:8080/api/health/ready; do sleep 2 done depends_on: - server restart: "no"
db: image: postgres:17 environment: POSTGRES_USER: skael POSTGRES_PASSWORD: pass POSTGRES_DB: skael volumes: - pg-data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U skael"] interval: 5s timeout: 5s retries: 5
volumes: skill-data: pg-data:This gates startup sequencing only. Once the sidecar exits, Docker Compose does not monitor server health. For continuous monitoring use the Kubernetes httpGet probes above, or put monitoring at the orchestration layer (systemd, an external uptime check).
Security scanning
Section titled “Security scanning”Every publish and import runs skael’s built-in scanner (a pure-Go package, no external dependencies, always on). It covers hardcoded secrets, prompt injection, data exfiltration, dangerous shell commands, and obfuscation, with a shell-AST pass that catches dangerous pipelines structurally. Critical and high-severity findings block the publish; skael publish --force overrides for skills that legitimately document an anti-pattern.
Optional external scanner
Section titled “Optional external scanner”You can layer a second, free/open-source scanner on top via EXTERNAL_SCAN_CMD. skael runs it over each skill on publish/import, parses its SARIF output, and merges the findings (a SARIF error blocks; warning/note are advisory). It is opt-in and best-effort — if the tool is missing or errors, skael logs a warning and continues on the built-in scan alone.
# {dir} is replaced with the skill directory; the command must print SARIF to stdout.EXTERNAL_SCAN_CMD=gitleaks dir {dir} --report-format sarif --report-path /dev/stdout --exit-code 0EXTERNAL_SCAN_TIMEOUT=60sTwo free, offline options:
- gitleaks (MIT) — a single static Go binary; hardens secret detection with a frequently-updated ruleset. Secrets only.
- Cisco AI skill-scanner (Apache-2.0) — purpose-built for
SKILL.md+ bundled scripts; matches skael’s threat model most closely.EXTERNAL_SCAN_CMD=skill-scanner scan {dir} --format sarif. Run only its local analyzers — do not enable its optional LLM/VirusTotal/cloud features, which need paid third-party keys.
Caveats (read before enabling):
- The official
ghcr.io/skael-dev/skaelimage is distroless — no shell, nocurl, no Python.EXTERNAL_SCAN_CMDshells out, so the tool must exist in the container. Build a derived image thatCOPYs in the gitleaks static binary (easy), or for skill-scanner use a base image with Python 3.10+ (heavier). On bare-metal, just install the tool on the host. - Each free general-purpose tool covers one layer: gitleaks = secrets, Semgrep = dangerous code in scripts, ClamAV/YARA = known-bad binaries. Only the purpose-built SKILL.md scanners (Cisco skill-scanner) cover the prompt-injection prose that is the core skill threat — and they carry a Python dependency. There is no single free, pure-Go, SKILL.md-aware external scanner.
- Snyk’s “agent-scan” is not a fit: its real detection is a hosted API that needs a Snyk account/token and uploads your skill contents to a third party.