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;/metricsis enabled withoutmetrics.token;app.keyis missing or shorter than 32 bytes;vector.require_nativeis set and thekeel_vectorextension 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 acceptX-Forwarded-Forfrom 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-Startfrom the proxy to enable queue-time load shedding (load_shedding.max_queue_ms, default 2000). - Send
X-Request-Idandtraceparentto 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.