Deploying ZealPHP
ZealPHP is a long-lived OpenSwoole server process. Unlike PHP-FPM, it does not exit between requests — workers are forked once at startup and reused for the life of the process. Plan your deployment accordingly: persistent state, signal-based reloads, and a reverse proxy in front for TLS.
1. Topology
[ Internet ] -> [ nginx / Caddy : 443 (TLS) ] -> [ ZealPHP : 8080 (N workers) ]
|
+--> static assets served directly
- One process per port. ZealPHP binds a single TCP port. Run multiple
instances on different ports (
-p 8080,-p 8081, ...) behind a load balancer if you need horizontal scaling on a single host. - N workers per process. Set
ZEALPHP_WORKERSto your CPU core count. - Bind a non-privileged port (default
8080). Let nginx/Caddy own:80/:443and proxy back to ZealPHP. The service user does not needCAP_NET_BIND_SERVICE. - Static assets can be served by ZealPHP (
public/*) or — for higher throughput — directly by the reverse proxy with analiasblock.
2. systemd service
The repo ships a service template at deploy/zealphp.service:
[Unit]
Description=ZealPHP App Server
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/zealphp
ExecStart=/usr/bin/php app.php
ExecStop=/bin/kill -TERM $MAINPID
KillMode=mixed
TimeoutStopSec=30s
Restart=on-failure
RestartSec=2s
LimitNOFILE=65535
# Environment="ZEALPHP_WORKERS=16"
# Environment="ZEALPHP_PORT=8080"
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Why Type=simple and no -d flag? With Type=simple systemd itself
tracks the master PID. The PHP process stays in the foreground, stdout and
stderr go to journald, and Restart=on-failure handles crashes. If you
daemonize via OpenSwoole (-d) the process double-forks and systemd loses
sight of the real PID.
Install
sudo cp deploy/zealphp.service /etc/systemd/system/zealphp.service
sudoedit /etc/systemd/system/zealphp.service # set User/Group/WorkingDirectory
sudo systemctl daemon-reload
sudo systemctl enable --now zealphp
sudo systemctl status zealphp
Logs
journalctl -u zealphp -f # tail live
journalctl -u zealphp --since '1 hour ago'
journalctl -u zealphp -p err # errors only
ZealPHP's per-stream log files (access.log, debug.log) still exist
under /tmp/zealphp/ and can be tailed independently with
php app.php logs.
3. Environment variables
All ZealPHP configuration is environment-driven. Set these in your
systemd unit (Environment="..."), Docker -e flags, or shell.
Networking
| Variable | Type | Default | Purpose |
|---|---|---|---|
ZEALPHP_HOST |
string | 0.0.0.0 |
Bind address for the HTTP server |
ZEALPHP_PORT |
int | 8080 |
TCP port |
ZEALPHP_WORKERS |
int | auto (CPU cores) | HTTP worker process count |
ZEALPHP_TASK_WORKERS |
int | 8 |
Async task worker count (set 0 to disable) |
ZEALPHP_MAX_REQUEST |
int | 100000 |
Requests per worker before clean recycle. Bounds memory growth from long-running PHP. Set 0 to disable. |
ZEALPHP_MAX_CONN |
int | OpenSwoole default | max_conn server setting |
ZEALPHP_MAX_COROUTINE |
int | OpenSwoole default | max_coroutine server setting |
ZEALPHP_BACKLOG |
int | OpenSwoole default | TCP listen backlog |
ZEALPHP_REACTOR_NUM |
int | OpenSwoole default | Reactor thread count |
Logging
| Variable | Type | Default | Purpose |
|---|---|---|---|
ZEALPHP_LOG_DIR |
path | /tmp/zealphp |
Base directory for all log files |
ZEALPHP_LOG_FILE |
path | (per-stream) | Single-file override for all streams |
ZEALPHP_ACCESS_LOG_FILE |
path | $LOG_DIR/access.log |
Per-request access log |
ZEALPHP_DEBUG_LOG_FILE |
path | $LOG_DIR/debug.log |
elog() output |
ZEALPHP_ZLOG_FILE |
path | $LOG_DIR/zlog.log |
zlog() output |
ZEALPHP_SERVER_LOG_FILE |
path | $LOG_DIR/server.log (daemon only) |
OpenSwoole server log |
ZEALPHP_ACCESS_LOG |
bool | 1 |
Toggle access logging |
ZEALPHP_DEBUG_LOG |
bool | 1 |
Toggle debug log (elog()); accepts 0/false/off |
ZEALPHP_LOG_ASYNC |
bool | 1 |
Use coroutine channels for log writes |
ZEALPHP_BENCH_MODE |
bool | 0 |
Disables all logging for benchmarks |
Compression
| Variable | Type | Default | Purpose |
|---|---|---|---|
ZEALPHP_HTTP_COMPRESSION |
bool | 1 (auto-off if middleware enabled) |
OpenSwoole's native gzip |
ZEALPHP_COMPRESSION_MIDDLEWARE |
bool | 0 |
Register the reference CompressionMiddleware (only if ZEALPHP_HTTP_COMPRESSION=0) |
Sessions
| Variable | Type | Default | Purpose |
|---|---|---|---|
ZEALPHP_SESSION_SECURE |
bool | auto-detect | Force secure flag on session cookie. Auto-detects via HTTPS=on, X-Forwarded-Proto: https, or SERVER_PORT=443. Set to 1 if your TLS terminator does not forward those headers. |
Misc
| Variable | Type | Default | Purpose |
|---|---|---|---|
ZEALPHP_SITE_URL |
URL | — | Canonical site URL used by helpers and absolute-URL generation |
ZEALPHP_SITE_HOST |
string | — | Fallback host if ZEALPHP_SITE_URL is not set |
ZEALPHP_DAEMONIZE |
bool | 0 |
OpenSwoole daemonize. Set by scripts/zealphp.sh; do not set under systemd |
ZEALPHP_PID_FILE |
path | $LOG_DIR/zealphp_$PORT.pid |
PID file location |
ZEALPHP_DEMO_MIDDLEWARE |
bool | 0 |
Enables the demo ETag/CORS middleware in app.php. Off in production unless you want them. |
Boolean variables accept 1/0, true/false, on/off, yes/no.
4. Reverse proxy
nginx
upstream zealphp {
server 127.0.0.1:8080;
keepalive 64;
}
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
location / {
proxy_pass http://zealphp;
proxy_http_version 1.1;
# WebSocket upgrade
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Standard forwarded headers — auto-detects session cookie `secure`
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;
# SSE / streaming: do not buffer responses
proxy_buffering off;
# Long-lived connections (SSE, WebSocket)
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
}
Caddy
app.example.com {
reverse_proxy 127.0.0.1:8080
}
Caddy handles WebSocket upgrades, automatic TLS, and HTTP/2 with no extra configuration. For SSE, Caddy disables buffering by default — no flag needed.
5. Docker
Build
docker build -t zealphp:0.2.38 .
The shipped Dockerfile is PHP 8.3-cli on bookworm with OpenSwoole and
uopz compiled via setup.sh --docker. Pin extension versions with the
build args:
docker build \
--build-arg OPENSWOOLE_VERSION=22.1.2 \
--build-arg UOPZ_VERSION=7.1.2 \
-t zealphp:0.2.38 .
Run (single container)
docker run -d \
-p 8080:8080 \
-e ZEALPHP_WORKERS=16 \
-e ZEALPHP_TASK_WORKERS=0 \
--restart unless-stopped \
--name zealphp \
zealphp:0.2.38
Production compose
The dev docker-compose.yml mounts the source tree for benchmarks. For
production, bake your app into the image and avoid volume mounts:
services:
app:
image: registry.example.com/zealphp-app:0.2.38
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
environment:
ZEALPHP_HOST: 0.0.0.0
ZEALPHP_PORT: 8080
ZEALPHP_WORKERS: 16
ZEALPHP_TASK_WORKERS: 4
ZEALPHP_SESSION_SECURE: 1
healthcheck:
test: ["CMD", "php", "-r", "exit(@file_get_contents('http://127.0.0.1:8080/healthz') === 'ok' ? 0 : 1);"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
memory: 1g
Add a /healthz route in your app:
$app->route('/healthz', fn() => 'ok');
6. Production checklist
- Disable debug logging:
ZEALPHP_DEBUG_LOG=0 -
ZEALPHP_WORKERSmatches CPU cores (oversubscribing hurts more than it helps with coroutines) - Run as a non-root user (the systemd template uses
www-data) - Bind a non-privileged port (
8080) — never80or443directly - Reverse proxy passes
X-Forwarded-Proto; otherwise setZEALPHP_SESSION_SECURE=1to force secure cookies behind HTTPS - Session save path (
/var/lib/php/sessions) is writable by the service user with mode0700 -
LimitNOFILE=65535in systemd unit (already set in the template) - Rotate logs from
/tmp/zealphp/— see logrotate config below - Pin OpenSwoole and uopz versions in your Dockerfile build args
- Set
ZEALPHP_TASK_WORKERS=0if you do not usetask()dispatch (saves ~8 worker processes) - OPcache tuned for long-running processes — see below
-
ZEALPHP_MAX_REQUESTis set (default 100000; tune for your leak profile; set0only if you've audited every static cache)
OPcache settings for long-running workers
ZealPHP is a long-running PHP process — opcache compiles your code once
at worker startup and serves the bytecode for the rest of the worker's
life. The defaults in php.ini are tuned for PHP-FPM (short-lived
processes that re-check files frequently). They're wrong for our model.
Recommended production php.ini:
; opcache loaded
opcache.enable = 1
opcache.enable_cli = 1
; Sized for the codebase. 256 MB is generous for most apps; bump if
; you load a lot of templates or run a large vendor tree.
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
; STOP checking file timestamps. Restart the server on deploy instead.
; This is the load-bearing setting: with validate_timestamps=1, every
; request pays a stat() on every file touched, which is significant under
; coroutine concurrency and gives you no benefit — workers stay alive.
opcache.validate_timestamps = 0
; Belt-and-suspenders. If you choose validate_timestamps=1 for dev
; convenience, at least keep revalidate_freq high so the stat cost is
; bounded. revalidate_freq=2 (PHP default) under load = stat storm.
opcache.revalidate_freq = 60
Deploy pattern: php app.php restart after deploying new code. The
manager process drains workers gracefully (current requests finish),
forks fresh workers that load the new bytecode, and the TCP listener
stays open the whole time — zero dropped requests.
The CGI bridge (legacy code via App::include() / proc_open)
needs the same opcache settings to benefit. With validate_timestamps=1
plus a low revalidate_freq, a recently-edited file can serve stale
bytecode for up to revalidate_freq seconds after deploy — looks
identical to a logic bug. The validate_timestamps=0 + restart pattern
above fixes it deterministically.
Worker recycle observability
When a worker exits — for any reason: max_request hit, graceful
shutdown, admin reload, OOM — the server logs:
[recycle] worker 17 exited after 99,847 requests, peak RSS 142 MB, uptime 4831s
Watch your access logs for these lines. They confirm max_request is
working as expected and surface workers that grow much faster than
others (likely leak sources). Set ZEALPHP_RECYCLE_LOG=0 to silence
the log line if your log volume is a concern.
logrotate
/etc/logrotate.d/zealphp:
/tmp/zealphp/*.log {
daily
rotate 14
compress
delaycompress
notifempty
missingok
copytruncate
su www-data www-data
}
copytruncate is required because ZealPHP holds the log files open for
the lifetime of the workers — it does not reopen on HUP.
7. Zero-downtime restarts
OpenSwoole supports SIGUSR1 for graceful worker reload. The master
process stays up; each worker finishes its in-flight request, then exits
and is re-forked. New code is loaded on fork.
# Reload workers (no master restart, no dropped connections)
sudo systemctl kill -s SIGUSR1 zealphp
# or:
kill -USR1 $(cat /tmp/zealphp/zealphp_8080.pid)
SIGUSR2 reloads only task workers.
A full restart (drops connections briefly) is:
sudo systemctl restart zealphp
# or via the CLI manager:
php app.php restart
Caveat: SIGUSR1 does not reload code held in the master process —
anything registered before $app->run() (e.g. Store::make(), route
tables) stays at the version the master booted with. For framework-level
changes, do a full restart.
8. Monitoring
Metrics
A built-in /metrics endpoint is on the v0.3 roadmap. Until then, expose
metrics yourself by combining Cache::stats(), Counter values, and a
recurring App::tick():
use ZealPHP\App;
use ZealPHP\Cache;
use ZealPHP\Counter;
$reqs = Counter::make('http_requests_total');
App::onWorkerStart(function ($workerId) use ($reqs) {
if ($workerId !== 0) return; // worker 0 only
App::tick(15_000, function () use ($reqs) {
$lines = [
'# TYPE http_requests_total counter',
"http_requests_total {$reqs->get()}",
];
foreach (Cache::stats() as $k => $v) {
$lines[] = "cache_{$k} {$v}";
}
file_put_contents(
'/var/lib/node_exporter/textfile/zealphp.prom',
implode("\n", $lines) . "\n"
);
});
});
Point Prometheus node-exporter's textfile collector at
/var/lib/node_exporter/textfile/ and you have scrape-friendly metrics
without an HTTP endpoint.
Log shipping
For Filebeat / Vector / Promtail, tail the structured files in
/tmp/zealphp/:
# Filebeat input
- type: filestream
paths:
- /tmp/zealphp/access.log
fields:
service: zealphp
stream: access
access.log lines are space-delimited (time method status path latency). Parse them in your shipper's pipeline.
Health checks
The /healthz pattern from section 5 is the simplest probe. For a
deeper check, hit a route that exercises your downstream dependencies
(database, cache):
$app->route('/readyz', function () {
// ping DB, cache, etc.; return 503 on failure
return DB::ping() ? 'ok' : 503;
});
Kubernetes maps /healthz to livenessProbe and /readyz to
readinessProbe.