Skip to content

Production

Build in the pipeline

composer install --no-dev --classmap-authoritative
KEEL_ENV=prod vendor/bin/keel build --env=prod
KEEL_ENV=prod vendor/bin/keel check --env=prod
KEEL_ENV=prod vendor/bin/keel migrate

Set app.key from a secret (e.g. APP_KEY), generated once with keel key:generate. It derives the keys for token fingerprints, signed cursors and Keel-issued JWTs. Rotating it invalidates cursors and Keel JWTs and forces re-binding of fingerprints, so rotate deliberately.

In production the runtime never compiles: a missing build is a boot error.

Run workers

FrankenPHP (worker mode):

{
    frankenphp {
        worker {
            file ./public/index.php
            num 8
            env KEEL_ENV prod
        }
    }
}

:8080 {
    root * public/
    php_server
}

RoadRunner: composer require spiral/roadrunner-http and point .rr.yaml's server.command at php public/index.php. FPM works unchanged as a fallback.

Worker recycling: KEEL_MAX_REQUESTS (default 1000) and KEEL_MAX_MEMORY_MB (default 256). On SIGTERM the worker finishes the request in flight, readiness starts failing, and the worker exits.

Production boot checks

A production worker refuses to start when:

  • debug is on (stack traces would leak);
  • rate limits, idempotency keys or auth tokens use in-memory stores (they would differ per node);
  • the application has job handlers but no real queue, or scheduled tasks but no shared lock store;
  • trustProxy() trusts every address;
  • /metrics is enabled without metrics.token;
  • app.key is missing or shorter than 32 bytes;
  • vector.require_native is set and the keel_vector extension is not loaded.

Use Redis-backed stores in production:

->bindIn('prod', RateLimitStore::class, RedisRateLimitStore::class)
->bindIn('prod', IdempotencyStore::class, RedisIdempotencyStore::class)

with redis.url in config.

Background processes

Run these next to the web workers, each as its own supervised process (systemd, a Kubernetes Deployment):

KEEL_ENV=prod vendor/bin/keel queue:work          # as many replicas as your job volume needs
KEEL_ENV=prod vendor/bin/keel schedule:work       # one or more; each task still runs once per minute

Both stop cleanly on SIGTERM. KEEL_MAX_REQUESTS and KEEL_MAX_MEMORY_MB do not apply to them; restart workers on deploy so they load the new build.

The vector extension

If the application uses numeric vectors for heavy work, build keel_vector in the image with the same PHP it will run on (ext/keel_vector/build.sh, which needs Rust), add the extension= line, and set 'vector' => ['require_native' => true] so a missing extension fails the boot instead of falling back to PHP silently. keel vector:info shows what is loaded.

The extension stores vector elements outside PHP's memory manager. memory_limit, memory_get_usage() and the memory threshold that recycles a worker do not see them. Limit sizes where data comes in (#[Count(max: ...)] on API inputs), and rely on the container's memory limit as the real ceiling. See the vectors guide and the extension's README.

Behind a load balancer

  • ->trustProxy('10.0.0.0/8') to accept X-Forwarded-For from your proxies only.
  • To add a TLS fingerprint to token binding, have the proxy that terminates TLS send the client's JA4 in X-JA4-Fingerprint (Keel only reads it from trusted proxies). See auth.
  • Send X-Request-Start from the proxy to enable queue-time load shedding (load_shedding.max_queue_ms, default 2000).
  • Send X-Request-Id and traceparent to correlate with edge logs and traces.

Defaults you can tune

Key Default
rate_limit.enabled / .capacity / .per_seconds / .exempt on / 120 / 60 / /health/, /metrics
idempotency.ttl_seconds 86400
http.max_body_bytes 1 MiB
security.hsts off (turn on when TLS terminates in front of you)
http_client.timeout_ms / .connect_timeout_ms 10000 / 2000
http_client.breaker_threshold / .breaker_cooldown_seconds / .max_concurrent_per_host 5 / 30 / 20

keel inspect lists every key your application actually reads.