On a busy PHP application server, sending all HTTP workloads – web requests, admin panels, API calls and HTTP-triggered jobs – to a single PHP‑FPM pool can create a bottleneck. Long‑running requests occupy PHP‑FPM children and can delay other requests. CLI queue workers and cron jobs do not occupy FPM children, but can compete with them for CPU, memory, storage and database capacity. Separating HTTP workloads into PHP‑FPM pools and managing CLI workers with Supervisor or systemd lets you tune their concurrency independently.
In this article we’ll walk through a practical process architecture for PHP sites and frameworks like Laravel, Symfony, WordPress and custom apps. We’ll look at why isolation matters, how to separate HTTP workloads into PHP‑FPM pools and run CLI queue workers independently, where Supervisor and systemd fit in, and how to choose resource limits to protect checkout latency while heavy jobs run in the background.
Pool separation is a concurrency/process design, not complete security isolation or a latency guarantee. Same-master pools share OPcache; operating-system budgets need explicitly separate services/cgroups.
Why You Should Isolate PHP Sessions and Queue Workers
The classic single‑pool problem
A common PHP‑FPM setup starts with one PHP‑FPM pool, one user, one set of limits. Nginx or Apache sends all traffic to the same unix socket or TCP port. At first this is fine, but as soon as you add:
- Queued jobs (emails, imports, webhooks, video processing)
- Long‑running reports or exports from the admin panel
- API consumers that can spike traffic at any time
- High‑value sessions (logged‑in customers, admin users)
you may see two kinds of contention. A big export handled through FPM can occupy a child while page loads wait for a free one. A CPU‑hungry CLI queue worker does not use an FPM child, but can still slow checkout, login and product pages by consuming shared server or database resources.
What isolation gives you in practice
By separating session‑bearing HTTP requests into PHP‑FPM pools and running queue workers as independently managed CLI processes, you can gain:
- More predictable response times for web sessions: a dedicated pool reserves FPM request capacity for session‑bearing traffic, though shared CPU, memory and storage can still become bottlenecks.
- Controlled capacity for background jobs: CLI worker concurrency and resource limits can be tuned separately from FPM request capacity.
- Cleaner failure domains: a worker crash need not stop HTTP handling, but memory leaks or deadlocks affecting shared resources can still disrupt the site.
- Security separation: you can run web pools and CLI workers under different Unix users and assign filesystem permissions accordingly; network restrictions require additional controls.
- More accurate tuning: each FPM pool has its own
pm.max_children,pm.max_requests, timeouts and INI settings tuned for its workload.
We’ve written before about why background jobs matter so much on a VPS; the missing piece for many teams is giving those jobs their own resource “lane” instead of mixing them with interactive traffic.
The Building Blocks: PHP‑FPM Pools, Supervisor and systemd
PHP‑FPM pools as isolation units
At the heart of this architecture are multiple PHP‑FPM pools. Each pool groups FPM child processes with its own:
- User/group and actual filesystem grants.
- Process-manager settings: pm, max_children and recycling.
- Pool-specific INI directives within their supported scope; CLI uses its own configuration.
- Separate session_pool/api_pool sockets. A queue socket is not needed for CLI workers.
One possible application layout separates three workload roles; only the HTTP roles use FPM pools:
- web: for normal HTTP requests (frontend, API, maybe admin)
- session‑critical: sometimes a smaller dedicated pool for cart/checkout or login flows
- queue: independently managed CLI worker processes (Laravel queue workers, custom daemons), not an FPM pool
For more reading on PHP-FPM tuning, browse the DCHost blog. Parameters such as pm.max_children and pm.max_requests apply across PHP workloads, but values must be measured for the application.
Supervisor: keeping CLI workers alive
Supervisor is a simple, battle‑tested process manager for long‑running CLI programs. It’s ideal for things like:
php artisan queue:workorphp artisan horizonin Laravel- Symfony Messenger workers
- Custom PHP daemons (e.g. websocket bridges, importers)
Supervisor handles:
- Automatic restart when a worker exits, according to the configured restart policy
- Process count (how many workers per queue)
- Logging stdout/stderr to dedicated log files
Supervisor is one process-management option; a dedicated instance prevents this queue budget from silently including unrelated programs. Direct systemd units are an alternative.
systemd: units, slices and timers
On many modern Linux distributions, systemd is the init system responsible for services. It gives you:
- Service units: to run long‑running PHP workers or short‑lived scheduler commands as managed services
- Resource control: CPU, memory and IO limits using cgroups and slices
- Timers: cron‑like scheduling of service units; job health checks still need to be designed explicitly
You can choose either Supervisor or systemd for managing queue workers; both are valid. A useful division of responsibility is:
- PHP‑FPM for short‑lived HTTP requests
- Supervisor or systemd for long‑running CLI workers
If you’re curious about how systemd timers compare to classic cron for scheduled jobs, we have a separate guide on Cron vs systemd timers and reliable scheduling.
Session and Worker Isolation: Architecture Patterns
Pattern 1: Separate PHP‑FPM web handling from CLI queue workers
Start by distinguishing two execution contexts:
[web]pool – handles Nginx/Apache PHP requests.- CLI queue workers – run independently of FPM under Supervisor or systemd, with their own PHP configuration and process limits.
Implement this “web pool vs worker context” separation by:
- Running PHP‑FPM for HTTP traffic only
- Running CLI workers with a dedicated php.ini or environment;
php -d ... artisan queue:workis schematic, with the ellipsis standing for actual INI overrides
CLI workers already run outside the pool of FPM children. The remaining task is to bound their resource use so they do not overwhelm the resources shared with web requests.
Pattern 2: Isolating session‑heavy vs stateless traffic
In many real projects, not all traffic is equal:
- Logged‑out product/category pages can be cached heavily
- Logged‑in dashboard, cart and checkout depend on per‑user data and may use PHP sessions or application‑specific session storage
- Public API endpoints may be stateless but high‑volume
You can map these to different FPM pools, for example:
- session_pool: session-bearing frontend/admin requests.
- api_pool: API requests whose middleware actually behaves statelessly.
- Other per-role pools may be appropriate, but every pool/master must be counted in the aggregate host budget.
Use a defined application entrypoint contract rather than send arbitrary URI prefixes directly to FastCGI. The complete index.php-only example and optional same-host API prefix routing appear below; neither is a drop-in WordPress configuration.
Nginx first selects a prefix, then considers matching regex locations unless an exact or ^~ match terminates that search. The optional /api/ prefix deliberately preserves PHP/dot-path denial rules. Every FastCGI location checks that index.php exists and sends a fixed SCRIPT_FILENAME plus the original REQUEST_URI/query/POST parameters.
Correctly routed API requests use a different set of FPM children from session‑bearing requests. The pools still share server resources, and separating them does not remove session‑store contention.
Pattern 3: Sessions and cache storage isolation
Session storage must follow application state boundaries, independently of pool names. Options include:
- Files with permissions shared only by pools serving that application’s native sessions.
- Redis with a deliberate data scope and explicit locking/TTL/authentication policy.
- Memcached where the chosen handler’s persistence, expiration and concurrency semantics fit the application.
We covered the trade‑offs in detail in our guide on choosing PHP session and cache storage (files vs Redis vs Memcached). Isolate session storage between unrelated applications, but keep storage access and expiry settings consistent across pools serving the same application session. Separate databases, key prefixes or namespaces distinguish data; they should not be treated as independent resource budgets or security boundaries.
Pools serving the same native PHP session must share accessible storage, session name, cookie scope, lifetime and GC policy. Otherwise login/cart state can disappear when routing changes pools. Host-only cookies are not automatically shared across subdomains; choose cookie Domain deliberately. WordPress/WooCommerce and Laravel session layers are not automatically changed by native PHP INI settings. Laravel normally allows concurrent same-session requests; route blocking is an explicit application choice with an atomic-lock driver and appropriate TTL/wait settings.
For native file sessions, avoid holding the lock across unnecessary slow work. This runnable PHP example saves state and releases the lock before a synthetic slow step. Later writes to $_SESSION are not automatically persisted after session_write_close. Concurrent application updates still need a consistency design; early unlocking is not safe for every workflow.
<?php
declare(strict_types=1);
if (!session_start()) {
http_response_code(503);
exit('Session unavailable');
}
$_SESSION['visits'] = (int) ($_SESSION['visits'] ?? 0) + 1;
$visits = $_SESSION['visits'];
if (!session_write_close()) {
http_response_code(503);
exit('Session save failed');
}
// Perform slow work only after saving and releasing the native session lock.
// This synthetic pause is not an application background queue.
usleep(200000);
header('Content-Type: application/json');
echo json_encode(['visits' => $visits], JSON_THROW_ON_ERROR);
Redis is a useful optional store, not a guarantee of faster or lock-free requests. PhpRedis 6.2.0 documents session locking as disabled by default and supported for a single-master topology. The optional settings below are for a private local exercise Redis. Production requires its actual ACL/authentication, TLS where appropriate, access and retention controls. Apply the same settings across pools serving one application; unrelated applications need separate data scope.
This snippet uses PHP INI syntax. Before selecting Redis, verify that PhpRedis is loaded in the FPM context. In both pools, replace the existing php_admin_value[session.save_handler] value with redis and php_admin_value[session.save_path] with the same Redis connection address shown here; changing only the global php.ini while retaining the pool’s files and filesystem-path overrides is insufficient. Apply the locking settings in an INI file loaded by FPM. After a controlled reload, verify the effective handler/storage settings and cross-pool session continuity. Existing file sessions are not automatically imported into Redis; plan migration or deliberate reauthentication.
session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379?database=2&prefix=shop_session_&timeout=1&read_timeout=1"
redis.session.locking_enabled = 1
redis.session.lock_expire = 90
redis.session.lock_wait_time = 10000
redis.session.lock_retries = 100
session.gc_maxlifetime = 1440
lock_expire is seconds and lock_wait_time is microseconds. Here 90 s exceeds the longest 60 s FPM request ceiling; 100 × 10000 µs is approximately 1 s of retry waits, excluding network time. Handle failed session_start as a failed request if the lock cannot be acquired. Recheck long blocking I/O, shutdown behavior and changed timeouts. A Redis database/prefix separates keys, not resource budgets or authorization. Do not assume this single-master locking recipe applies to RedisCluster/RedisArray.
Designing Web vs Worker Resources and Limits
Different latency expectations
Choose latency targets separately for interactive requests and background jobs, then verify them under concurrent load.
- Web requests: define a measured latency target for interactive routes such as checkout and login; a few hundred milliseconds can be an example target, not a universal usability threshold.
- Queue workers: jobs may take seconds or minutes, provided their own timeout, retry window and business deadline allow it.
From this, the tuning approach becomes clearer:
- Select HTTP ceilings from actual endpoint behavior; PHP max_execution_time and FPM wall-clock termination are different, and I/O accounting depends on platform/build/version.
- Select queue concurrency, per-job timeout and total CPU/memory budget independently; a longer-running job still needs explicit downstream-I/O timeouts.
Example: sizing pools on a 4 vCPU / 8 GiB RAM VPS
For arithmetic only, suppose the host has 4 vCPUs and 8 GiB RAM. Draft budgets are 4 GiB for this dedicated FPM master, 2 GiB for the queue layer and 2 GiB for OS/Nginx/database/Redis/other overhead. The final share may be insufficient for your services; measure peaks, page cache and safety margin. A sum equal to installed RAM is not evidence of safe capacity.
- session_pool: dynamic, max_children 12, start/min spare 4, max spare 8.
- api_pool: dynamic, max_children 8, start/min spare 2, max spare 4.
- Four CLI queue workers in a single 2 GiB aggregate budget. These are illustrative values, not a recommendation for every 4 vCPU host.
Four CPU-heavy workers can saturate four vCPUs without an effective quota. NVMe does not guarantee negligible I/O contention. Measure HTTP p95/p99, throttling, queue age, memory and database pressure together. Our VPS sizing guide for WooCommerce, Laravel and Node.js provides additional context.
Using systemd slices and priorities
If you run queue workers under systemd, you can use slices and cgroups to enforce priorities. For example:
- Place the dedicated FPM master and queue layer in their explicitly named services/slices.
- Use aggregate queue CPU/memory limits; relative I/O weights depend on the actual hierarchy/controller/scheduler.
- Review every other master, pool and service competing on the host; pools do not reserve RAM/CPU.
These settings bound selected workloads but do not guarantee responsive web requests. Shared databases, storage and host pressure remain relevant. If a measured process peak were 80 MiB (session) or 60 MiB (API), 2048 MiB divided by that value gives about 25 or 34 processes before master/shared/variance overhead. At 350 MiB per queue worker, 2048/350 gives about 5 before overhead. Those are arithmetic upper divisions, not safe limits; the smaller 12/8 and 4 example still needs acceptance tests.
Putting It Together: Example Implementation
1. Define multiple PHP‑FPM pools
The concrete example targets Ubuntu 24.04, PHP 8.3 FPM/CLI, Nginx, systemd and Supervisor 4.2+. Start on a separate test server with a working, backed-up application. /var/www/site/public/index.php is the only PHP entrypoint in this routing contract. Application dependencies, an existing TLS certificate covering both hostnames, database, secrets and deployment identity are prerequisites. WordPress has separate entrypoints such as wp-login.php; this index.php-only configuration is not a drop-in WordPress configuration.
Create a new app group and nonlogin appweb/appqueue service identities as an administrator after checking for existing names. Keep deployed code unwritable by runtime identities; grant only required storage/bootstrap/cache access. appqueue needs the actual file/database permissions of its jobs; its name does not automatically make it less privileged. The Nginx www-data identity needs parent traversal and read access to public files. Shared application secrets remain within the same application trust boundary.
Create root-owned /etc/php/8.3/fpm-app.d, /var/log/php-fpm-app, /etc/supervisor-app-queue/programs and /var/log/app-queue. Create /var/lib/app-sessions with appweb ownership and mode 0700. Precreate each pool’s .error.log as appweb:app, 0640, and define log rotation/reopening and retention. Protect privileged service files, configuration and parent paths against developer/runtime writes.
The dedicated master below includes exactly the two application pools. Do not add a packaged www pool or unrelated pools to this include directory. Other running FPM masters/pools still count against host capacity; this service does not stop them. Migrate the application’s Nginx mapping with a maintenance/rollback plan and avoid accidentally running duplicate capacity. Pools separate request-concurrency settings; they share one master’s OPcache and are not full security boundaries.
[global]
pid = /run/php-fpm-app/master.pid
error_log = /var/log/php-fpm-app/master.log
daemonize = no
include = /etc/php/8.3/fpm-app.d/*.conf
Save the master configuration at /etc/php/8.3/fpm-app.conf and this unit at /etc/systemd/system/php-fpm-app.service. The 4 GiB cap covers this master and both pools together; it does not reserve RAM separately for each pool. Hitting MemoryMax may terminate HTTP work too. These numbers are illustrative until workload-specific acceptance tests pass.
[Unit]
Description=Dedicated FPM master for the example application
After=network.target
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
RuntimeDirectory=php-fpm-app
RuntimeDirectoryMode=0755
ExecStart=/usr/sbin/php-fpm8.3 --nodaemonize --fpm-config /etc/php/8.3/fpm-app.conf
ExecReload=/bin/kill -USR2 $MAINPID
KillSignal=SIGQUIT
KillMode=mixed
TimeoutStopSec=75s
Restart=on-failure
RestartSec=5s
MemoryHigh=3G
MemoryMax=4G
MemorySwapMax=0
CPUAccounting=yes
MemoryAccounting=yes
IOAccounting=yes
[Install]
WantedBy=multi-user.target
Save the following as session_pool.conf and api_pool.conf in the dedicated include directory. 128 MiB PHP memory_limit is illustrative and not a total RSS ceiling.
[session_pool]
user = appweb
group = app
listen = /run/php-fpm-app/session_pool.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 12
pm.start_servers = 4
pm.min_spare_servers = 4
pm.max_spare_servers = 8
pm.max_requests = 1000
request_terminate_timeout = 60s
request_terminate_timeout_track_finished = yes
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm-app/session_pool.slow.log
catch_workers_output = yes
clear_env = yes
security.limit_extensions = .php
php_admin_value[memory_limit] = 128M
php_admin_value[max_execution_time] = 30
php_admin_value[session.save_handler] = files
php_admin_value[session.save_path] = /var/lib/app-sessions
php_admin_value[session.gc_maxlifetime] = 1440
php_admin_value[session.gc_probability] = 1
php_admin_value[session.gc_divisor] = 100
php_admin_value[session.auto_start] = 0
php_admin_value[session.use_strict_mode] = 1
php_admin_value[session.use_only_cookies] = 1
php_admin_value[session.cookie_httponly] = 1
php_admin_value[session.cookie_secure] = 1
php_admin_value[session.cookie_samesite] = Lax
php_admin_flag[display_errors] = off
php_admin_flag[log_errors] = on
php_admin_value[error_log] = /var/log/php-fpm-app/session_pool.error.log
The API pool has separate children and request ceilings, while retaining the same native session storage policy for this application if code explicitly uses sessions:
[api_pool]
user = appweb
group = app
listen = /run/php-fpm-app/api_pool.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 8
pm.start_servers = 2
pm.min_spare_servers = 2
pm.max_spare_servers = 4
pm.max_requests = 1000
request_terminate_timeout = 15s
request_terminate_timeout_track_finished = yes
request_slowlog_timeout = 2s
slowlog = /var/log/php-fpm-app/api_pool.slow.log
catch_workers_output = yes
clear_env = yes
security.limit_extensions = .php
php_admin_value[memory_limit] = 128M
php_admin_value[max_execution_time] = 30
php_admin_value[session.save_handler] = files
php_admin_value[session.save_path] = /var/lib/app-sessions
php_admin_value[session.gc_maxlifetime] = 1440
php_admin_value[session.gc_probability] = 1
php_admin_value[session.gc_divisor] = 100
php_admin_value[session.auto_start] = 0
php_admin_value[session.use_strict_mode] = 1
php_admin_value[session.use_only_cookies] = 1
php_admin_value[session.cookie_httponly] = 1
php_admin_value[session.cookie_secure] = 1
php_admin_value[session.cookie_samesite] = Lax
php_admin_flag[display_errors] = off
php_admin_flag[log_errors] = on
php_admin_value[error_log] = /var/log/php-fpm-app/api_pool.error.log
session.auto_start=0 does not prohibit application code from starting a session or make middleware stateless. Separate Redis databases for pools serving the same session would split state; keep the intended application’s storage, expiration and cookie policy consistent.
2. Route requests to the right pool via Nginx
This complete site mapping is included inside Nginx’s http context. www.example.com uses session_pool and api.example.com uses api_pool. An existing certificate/key must cover both hostnames; Secure session cookies require HTTPS. Save the following parameter file as /etc/nginx/snippets/front-controller-fastcgi.conf. Do not also include a distribution-specific fastcgi_params file with potentially different script parameters.
fastcgi_param QUERY_STRING $query_string;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param PATH_INFO "";
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param DOCUMENT_URI $document_uri;
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param SERVER_PROTOCOL $server_protocol;
fastcgi_param REQUEST_SCHEME $scheme;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_param REMOTE_PORT $remote_port;
fastcgi_param SERVER_ADDR $server_addr;
fastcgi_param SERVER_PORT $server_port;
fastcgi_param SERVER_NAME $server_name;
fastcgi_param HTTP_HOST $http_host;
fastcgi_param HTTP_PROXY "";
fastcgi_param REDIRECT_STATUS 200;
upstream php_session_pool {
server unix:/run/php-fpm-app/session_pool.sock;
}
upstream php_api_pool {
server unix:/run/php-fpm-app/api_pool.sock;
}
server {
listen 443 ssl;
server_name www.example.com;
root /var/www/site/public;
index index.php;
ssl_certificate /etc/ssl/app/fullchain.pem;
ssl_certificate_key /etc/ssl/app/privkey.pem;
access_log /var/log/nginx/www.example.com.access.log;
error_log /var/log/nginx/www.example.com.error.log;
client_max_body_size 10m;
location = /index.php {
try_files /index.php =404;
include /etc/nginx/snippets/front-controller-fastcgi.conf;
fastcgi_pass php_session_pool;
fastcgi_connect_timeout 3s;
fastcgi_read_timeout 65s;
}
location ~ /\. { return 404; }
location ~* \.php(?:/|$) { return 404; }
location / {
try_files $uri $uri/ /index.php?$query_string;
}
}
server {
listen 443 ssl;
server_name api.example.com;
root /var/www/site/public;
index index.php;
ssl_certificate /etc/ssl/app/fullchain.pem;
ssl_certificate_key /etc/ssl/app/privkey.pem;
access_log /var/log/nginx/api.example.com.access.log;
error_log /var/log/nginx/api.example.com.error.log;
client_max_body_size 10m;
location = /index.php {
try_files /index.php =404;
include /etc/nginx/snippets/front-controller-fastcgi.conf;
fastcgi_pass php_api_pool;
fastcgi_connect_timeout 3s;
fastcgi_read_timeout 20s;
}
location ~ /\. { return 404; }
location ~* \.php(?:/|$) { return 404; }
location / {
try_files $uri $uri/ /index.php?$query_string;
}
}
Only an existing public/index.php is executed. Other .php paths, /index.php/extra PATH_INFO requests and dot paths are rejected; uploaded PHP files are not executable entrypoints. REQUEST_URI, query and POST parameters are preserved. Applications with multiple real entry files need their own routing contract. fastcgi_read_timeout measures the interval between successive reads, not an absolute request duration.
For same-host API routing, add these two locations only inside the www server block. /api and /api/... use the API pool; /apix retains the normal pool. The /api/ prefix deliberately omits ^~ so the existing PHP/dot-path denial regexes still apply. SCRIPT_FILENAME remains fixed to index.php; the application receives its original REQUEST_URI.
location = /api {
try_files /index.php =404;
include /etc/nginx/snippets/front-controller-fastcgi.conf;
fastcgi_pass php_api_pool;
fastcgi_connect_timeout 3s;
fastcgi_read_timeout 20s;
}
location /api/ {
try_files /index.php =404;
include /etc/nginx/snippets/front-controller-fastcgi.conf;
fastcgi_pass php_api_pool;
fastcgi_connect_timeout 3s;
fastcgi_read_timeout 20s;
}
The single-front-controller contract fits an application that really dispatches through public/index.php. A WordPress login endpoint must execute its actual entrypoint, not be mislabeled as index.php. Test allowed routes, missing entry files, upload/PATH_INFO rejection and query/POST preservation before deployment.
3. Configure queue workers with Supervisor
Choose one of the two managers below; never start the same worker set under both Supervisor and direct systemd. Prerequisites are a working Laravel application, required CLI extensions including PCNTL for timeouts, an existing database queue connection/tables and a least-privileged worker identity. Set retry_after=90 on the actual database connection in config/queue.php; the queue is default. Job-level overrides must not exceed 60 s. Adapt the relationship 60 s worker timeout <75 s graceful stop <90 s retry_after to real jobs and I/O. SQS uses visibility timeout instead of retry_after.
CLI does not inherit pool-specific INI settings: inspect php8.3 --ini and extensions in the CLI context. PHP memory_limit limits managed allocations, not total RSS; as of PHP 8.3.36 and related updated branches it is a soft allocation limit that may briefly be exceeded. Laravel –memory is a worker recycling threshold, while cgroup MemoryMax is an aggregate OS boundary. They are different mechanisms. Configure application/external-I/O timeouts, idempotent effects and retry/failure handling as well.
Do not start a second Supervisor using the existing global /etc/supervisor/supervisord.conf. Use /etc/supervisor-app-queue/supervisord.conf, including only this application’s program directory. It has its own Unix control socket and PID file, without a TCP control interface.
[unix_http_server]
file=/run/app-queue-supervisor/control.sock
chmod=0700
[supervisord]
nodaemon=true
pidfile=/run/app-queue-supervisor/supervisord.pid
logfile=/var/log/app-queue/supervisord.log
logfile_maxbytes=10MB
logfile_backups=3
childlogdir=/var/log/app-queue
[rpcinterface:supervisor]
supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///run/app-queue-supervisor/control.sock
[include]
files=/etc/supervisor-app-queue/programs/*.conf
Save this program as /etc/supervisor-app-queue/programs/queue.conf. The command is a single line. Each of the four processes has a distinct rotating log file; sharing one rotating file can corrupt logs.
[program:app-queue]
process_name=%(program_name)s_%(process_num)02d
command=/usr/bin/php8.3 -d memory_limit=384M /var/www/site/artisan queue:work database --queue=default --sleep=1 --tries=3 --timeout=60 --memory=256 --max-time=3600
directory=/var/www/site
numprocs=4
user=appqueue
autostart=true
autorestart=true
startsecs=1
startretries=3
stopsignal=TERM
stopwaitsecs=75
stopasgroup=true
killasgroup=true
redirect_stderr=true
stdout_logfile=/var/log/app-queue/worker_%(process_num)02d.log
stdout_logfile_maxbytes=10MB
stdout_logfile_backups=3
Install the dedicated parent unit at /etc/systemd/system/app-queue-supervisor.service:
[Unit]
Description=Dedicated Supervisor for application queue workers
After=network.target
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
RuntimeDirectory=app-queue-supervisor
RuntimeDirectoryMode=0700
ExecStart=/usr/bin/supervisord -n -c /etc/supervisor-app-queue/supervisord.conf
Restart=on-failure
RestartSec=5s
KillMode=mixed
TimeoutStopSec=90s
CPUAccounting=yes
CPUQuota=150%
MemoryAccounting=yes
MemoryHigh=1536M
MemoryMax=2G
MemorySwapMax=0
IOAccounting=yes
IOWeight=50
[Install]
WantedBy=multi-user.target
Key points:
All four CLI workers and their Supervisor share this cgroup budget. CPUQuota=150% is 1.5 CPUs worth of CPU time, approximately 37.5% of a 4 vCPU host; it does not reserve CPU for FPM. Use a single percent sign. MemoryHigh=1536M throttles under pressure; MemoryMax=2G may cause an in-cgroup OOM as a last defense, without guaranteeing the whole host cannot run out of memory. Swap is separately capped at 0. IOWeight=50 is relative to the default 100; a higher weight would favor the queue. Effect depends on the active controller, scheduler and hierarchy.
KillMode=mixed initially signals the Supervisor main process. Supervisor signals its worker groups and waits 75 s; the outer 90 s stop window allows additional shutdown time before remaining processes may be killed. Completion and exactly-once effects are not guaranteed. Repeated startup failures can hit StartLimit; investigate instead of assuming retries continue forever.
4. Or run workers via systemd units
Alternatively, omit Supervisor and install the shared /etc/systemd/system/app-queue.slice plus /etc/systemd/system/app-queue@.service below. Every instance belongs to the same aggregate 2 GiB/150% budget; a 512 MiB per-instance ceiling does not create a separate 2 GiB allowance per worker. The instance number identifies a process, not a queue: all four explicitly consume default.
[Unit]
Description=Aggregate resource budget for application queue workers
[Slice]
CPUAccounting=yes
CPUQuota=150%
MemoryAccounting=yes
MemoryHigh=1536M
MemoryMax=2G
MemorySwapMax=0
IOAccounting=yes
IOWeight=50
[Unit]
Description=Application queue worker instance %i
After=network.target
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
User=appqueue
Group=app
WorkingDirectory=/var/www/site
ExecStart=/usr/bin/php8.3 -d memory_limit=384M /var/www/site/artisan queue:work database --queue=default --sleep=1 --tries=3 --timeout=60 --memory=256 --max-time=3600
Restart=always
RestartSec=5s
SuccessExitStatus=0
KillSignal=SIGTERM
KillMode=mixed
TimeoutStopSec=75s
Slice=app-queue.slice
MemoryMax=512M
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Restart=always also restarts normal exits from –max-time or queue:restart; an intentional systemctl stop does not automatically restart the service. The same CLI/PCNTL, job-timeout and graceful-stop prerequisites apply. Verify the actual slice hierarchy and enabled controllers.
After staged syntax and application acceptance checks, an administrator may start only the selected manager. These commands operate real services; establish a maintenance/rollback plan and stop duplicate worker ownership first:
systemd-analyze verify /etc/systemd/system/app-queue@.service /etc/systemd/system/app-queue.slice
systemctl daemon-reload
systemctl enable --now app-queue@1 app-queue@2 app-queue@3 app-queue@4
The shared slice limits the sum of workers, and each worker has a 512 MiB cap. It does not reserve the remaining host resources for FPM or prove the desired latency. If Supervisor is selected instead, start app-queue-supervisor.service and inspect it with supervisorctl using the dedicated configuration path.
5. Observability: separate logs and metrics
Measure each pool and worker layer so you can connect symptoms to the workload that produced them:
- Separate Nginx access/error and FPM slow/error logs, with protected rotation/retention.
- FPM active/idle children, listen queue, max children reached and slow requests; keep status access private.
- Queue age/backlog, job duration/retries/failures and worker exits.
- CPU throttling, cgroup memory.events/MemoryCurrent, PSS/USS/RSS and storage/database pressure. RSS is KiB and may count shared pages repeatedly.
If you are not yet monitoring your VPS in a structured way, our guide on VPS monitoring and alerts with Prometheus, Grafana and Uptime Kuma is a good starting point.
ps -eo pid,user,rss,args --sort=-rss | awk '/php-fpm: pool/ && !/awk/ {print}'
systemctl show php-fpm-app.service -p MemoryCurrent -p MemoryHigh -p MemoryMax -p ControlGroup
systemctl show app-queue-supervisor.service -p CPUQuotaPerSecUSec -p MemoryCurrent -p MemoryMax -p ControlGroup
For direct systemd workers inspect app-queue.slice and the selected instance units instead of the Supervisor unit. A short process listing or an idle average is not a peak-capacity measurement.
Relating This to Per‑Site PHP‑FPM Pool Architecture
Per‑site and per‑role pools together
In many agency or multi‑project setups, you already run one FPM pool per site (or per customer). In that model, you can still apply the same idea, just one level deeper:
- Each site may have its own HTTP session/API pools and independent CLI queue services.
- Count all pools/masters/workers and dependencies in the host budget.
- Unrelated applications need distinct identities, file/secret/session policies and stronger boundaries where the trust model requires them.
The per-site Nginx/PHP-FPM guide gives related context. A per-site or per-role pool name is not complete tenant isolation; same-master OPcache and shared host resources remain.
When to move beyond a single server
At some point, even a well‑designed pool architecture on one VPS will hit its limits: queue workloads grow, databases need more IOPS, or uptime requirements push you to redundancy. That’s when it starts to make sense to:
- Move queue, Redis or database roles to an appropriate separate dedicated server or VPS where the measured trust/capacity requirement justifies it.
- Load balancing needs consistent session/application data and health/rollout controls.
- GeoDNS/multi-region routing additionally needs data replication, failover and recovery design; DNS alone does not provide redundancy.
We’ve covered bigger‑picture hosting decisions for PHP apps in articles like choosing hosting for Laravel, Symfony and custom PHP apps and our guide to GeoDNS and multi‑region hosting architecture.
Operational Tips and Common Pitfalls
Avoiding deadlocks and session contention
Isolating pools does not magically fix all session issues. Watch out for:
- A native-session request holding its handler lock during slow I/O.
- Framework/session handlers with their own concurrent-update or blocking semantics.
- Different stores/expiry/cookies across pools serving the same application, or improperly shared stores across unrelated applications.
FPM status can show busy processes or a backlog; it does not directly label a session-lock wait. Correlate slow traces, handler behavior, application timing and concurrent requests before diagnosing:
- Reduce unnecessary work while the native session is open; after session_write_close later changes are not automatically saved.
- Keep large work products outside session data and preserve application-level consistency.
- Choose and test storage handler locking/TTL behavior explicitly; Redis or Memcached alone does not remove contention.
Graceful deploys with queue and pool isolation
Manage deployment as a coordinated process change with retained state and rollback:
- Drain/stop the selected queue manager with a grace window longer than the maximum intended job; forced termination can still occur.
- FPM reload targets the master/service and its pools; a generic php-fpm reload command does not gradually reload individual pools. Validate the selected config and service’s actual reload behavior.
- For symlink release deployments, coordinate worker restarts, OPcache/realpath behavior, backward-compatible data changes and persistent session storage; separate pools do not guarantee zero downtime.
For more complex Laravel setups, our detailed guide on deploying Laravel on a VPS with Nginx, PHP‑FPM, Horizon and zero‑downtime releases shows exactly how these pieces fit together.
Laravel queue:restart signals are stored through the application cache; workers need the correct shared cache configuration and manager restart policy. Test current-job completion, retry behavior and rollback with sandbox effects before relying on graceful restarts.
Testing before enabling in production
When you introduce new pools and worker processes, always test on staging first:
- Use a staging copy with separate credentials/data and blocked email/payment/webhook effects.
- Test both hostname and optional prefix routing, existing/missing index.php, denied PHP/PATH_INFO/dot paths and query/POST preservation.
- Verify real application login/cart/checkout state and concurrent updates; native PHP session fixtures do not prove framework behavior.
- Apply mixed HTTP/queue load while recording p95/p99/errors, FPM backlog, queue age, throttling and memory pressure.
- Exercise manager stop/restart, timed-out/retried jobs, rollback and actual cgroup limits; syntax acceptance does not prove these.
We’ve written a practical guide on load testing your hosting before traffic spikes that pairs nicely with this kind of architecture change.
Keep HTTP Capacity and CLI Worker Budgets Explicit
Use FPM pools for distinct HTTP concurrency/timeouts and separate managed CLI processes for queue work. Shared native-session state follows the application boundary; resource controls follow the actual service/cgroup hierarchy. Neither a pool name nor a successful config parse proves isolation or production capacity.
Begin with measured budgets, explicit worker ownership and protected configuration. Validate routing, state consistency, retries and graceful/forced stopping before rollout. Keep observability and recovery steps with the configuration as the workload changes.
For a new PHP deployment or a refactor, document the application’s workloads, entrypoints, session handler and operational responsibilities before choosing capacity. Adapt the examples in a separate test environment and verify the management responsibilities included in the hosting service. This design provides controls to test and operate; it does not promise faster responses, zero downtime or complete tenant isolation.





