Configure a dedicated PHP-FPM pool for one application
Separate Unix identity, socket, resource limits, logs, environment, and health checks for an application instead of sharing the default pool.
Limit cross-application impact and make PHP worker ownership, capacity, and failures observable.
- PHP-FPM 8.2, 8.3, 8.4, 8.5
- Supported host and recovery access Use a supported distribution, keep an independent root-capable session, and record the current package repositories before changing the PHP runtime.
cat /etc/os-release && uname -m - Application compatibility Confirm the application, framework, extensions, and deployment tooling explicitly support the target PHP branch.
- Configuration backup Archive the active PHP, web-server, and application configuration before packages, pools, or handlers change.
OneLiners never runs these steps or stores secrets. Review placeholders, versions, current state, and change-control requirements before using a command.
Full guide
What you will build
- A least-privilege PHP-FPM pool for one application with its own Unix identity, root-owned configuration, private runtime directories and a 0660 Unix socket shared only with the selected web-server group.
- A capacity-based ondemand worker policy, worker recycling, clean environment, PHP-only execution, pool-scoped error/session/upload paths, and protected slow/status/ping observability.
- A validation and canary sequence proving configuration syntax, service health, socket/worker identity, FastCGI reachability, write-boundary isolation and rollback to the prior site handler.
- The web worker can connect to the socket but cannot write application source; the application worker can write only approved runtime directories and never runs as root or the web identity.
- Peak worker memory and host headroom justify `pm.max_children`; overload queues/fails predictably instead of exhausting the host, and workers recycle after a bounded request count.
- Status, ping, slow log and errors are observable only through local/authenticated administration paths, while final HTTP and filesystem tests prove the intended application boundary.
Architecture
How the parts fit together
The web server parses HTTP and forwards only reviewed PHP routes to `/run/php/php{{phpVersion}}-{{app}}.sock`. FPM's privileged master creates the socket, then workers execute as `{{appUser}}:{{appGroup}}`. Socket group `{{webGroup}}` grants connect permission without application ownership. The pool uses ondemand workers capped at eight as a documented starting value; operators must replace it from measured RSS and concurrency. Application source is read-only to workers, while `/srv/{{app}}/var`, `tmp`, sessions and logs are the explicit write set. Status/ping are FPM endpoints, not public application pages, and must be exposed only on an internal or authenticated location.
- nginx or Apache selects an allowed PHP script and connects through the group-readable socket.
- An ondemand FPM worker starts under the application identity, loads pool/runtime policy and executes the script.
- The worker reads source, writes only approved runtime paths and returns FastCGI output.
- Slow/error/status signals go to private paths or a restricted management location; operators validate identity and capacity.
Assumptions
- The exact PHP branch/service/binary are known, FPM currently validates, and a previous site handler is archived for rollback.
- The application has a managed non-login identity, read-only release model and explicit writable directories; shared-host multi-tenant risk has been reviewed.
- `{{webGroup}}` is the actual nginx/Apache worker group, and AppArmor/SELinux plus parent directory traversal are intentionally configured.
- Representative traffic or staging data exists to estimate worker RSS and concurrency; the example ceiling of eight is not copied blindly.
Key concepts
- FPM pool
- A named group of PHP workers with shared process, identity, socket, logging and runtime policy.
- pm.max_children
- The hard number of simultaneous workers/requests; memory and latency determine a safe value.
- ondemand
- A process-manager mode that starts workers when needed and removes them after an idle timeout.
- socket mode
- Filesystem owner/group/permissions controlling which local processes can submit FastCGI requests.
- clear_env
- A policy that clears arbitrary inherited environment variables before worker execution unless explicitly allowed.
- slow log
- A backtrace written when a request exceeds `request_slowlog_timeout`, used as sensitive diagnostic evidence.
Before you copy
Values used in this guide
{{app}}Short unique pool and path identifier.
Example: billing{{appUser}}Dedicated non-login worker user.
Example: billing{{appGroup}}Private application group.
Example: billing{{webGroup}}Existing nginx/Apache worker group allowed to connect.
Example: www-data{{phpVersion}}Installed versioned PHP-FPM unit/binary suffix.
Example: 8.3{{domain}}Canary hostname whose reviewed handler targets this pool.
Example: billing.example.comSecurity and production boundaries
- A separate Unix user is a useful boundary but not a container or VM. Kernel, shared extensions and readable host paths remain common.
- The web server must connect to the socket but must not write application source; the FPM worker must not own privileged configuration.
- Status, ping, slow logs and PHP error logs expose paths, queries and runtime data. Keep them local or strongly authenticated and redact exports.
- Do not solve permission failures with chmod 777, shared root/web identities, `clear_env=no`, disabled MAC policy or a TCP listener on all interfaces.
Stop before continuing if
- Stop if current memory/concurrency cannot be measured, the host has insufficient headroom, or the example child ceiling would threaten other services.
- Do not reload when FPM syntax fails, identity/path/socket collides, source becomes worker-writable, or status is publicly reachable.
- Do not cut traffic until direct socket, HTTP health, worker identity, write-boundary and rollback tests all pass.
instruction
Measure memory and concurrency
Estimate a worker budget from representative process RSS and available memory; do not copy arbitrary pm.max_children values.
Why this step matters
Each child can execute one request and consumes application-dependent memory; a copied ceiling can either starve traffic or trigger host-wide OOM.
What to understand
Measure RSS after representative warm requests, not an idle generic process, and reserve memory for kernel, cache, web/database/agents and failure spikes.
Estimate concurrency and queue tolerance, then load-test. `MemoryCurrent` includes more than individual RSS and is useful corroboration.
System changes
- No persistent change; reads process RSS, host memory and current service memory.
Syntax explained
ps -o rss- Shows resident KiB for each existing FPM process.
free -m- Shows host memory and available headroom.
systemctl show ... MemoryCurrent- Reports cgroup memory accounted to the FPM unit.
ps --no-headers -o rss,cmd -C php-fpm | sort -n; free -m; systemctl show php-fpm -p MemoryCurrentWorker RSS and host memory headroom are recorded.
Checkpoint: Checkpoint: capacity
Continue whenA documented worker RSS range, reserved host budget, target concurrency and justified initial `pm.max_children` exist.
Stop whenRepresentative RSS/concurrency is unavailable, swapping/OOM already occurs, or no memory is reserved for other services.
If this step fails
No current FPM workers exist to measure.
Likely causeThe application is new or ondemand workers are idle.
systemctl is-active php8.3-fpmfree -m
ResolutionWarm a staging/canary pool with representative requests and measure it before production sizing.
Security notes
- Resource exhaustion is an availability risk; never leave child count unlimited.
Alternatives
- Start with a smaller explicit ceiling and a canary load test when production measurements do not yet exist.
Stop conditions
- Representative RSS/concurrency is unavailable, swapping/OOM already occurs, or no memory is reserved for other services.
command
Create a non-login application account
Use a dedicated Unix identity and group, then grant the web server traverse access only where required.
Why this step matters
A dedicated non-login identity separates application execution and writable runtime data from the web server and other pools.
What to understand
Create group/user only when absent and stop if an existing name has unexpected UID/GID/home/shell.
Public source should be deployed read-only separately; `var`, `tmp`, logs and sessions are the deliberate write set.
System changes
- Creates the system group/user and `/srv/{{app}}` public/runtime directories with explicit ownership and mode.
Syntax explained
getent group ... || groupadd- Creates the private system group idempotently.
id -u ... || useradd- Creates a non-login system user only when absent.
install -d -m 0750- Creates explicit private directories without permissive defaults.
Values stay on this page and are never sent or saved.
getent group {{appGroup}} >/dev/null || sudo groupadd --system {{appGroup}}; id -u {{appUser}} >/dev/null 2>&1 || sudo useradd --system --gid {{appGroup}} --home-dir /srv/{{app}} --shell /usr/sbin/nologin {{appUser}}; sudo install -d -o {{appUser}} -g {{appGroup}} -m 0750 /srv/{{app}}/{public,var,tmp,var/log,var/sessions}The managed non-login identity exists and owns its private runtime directories.
Checkpoint: Checkpoint: account
Continue whenThe identity is non-login with intended UID/GID/home, runtime paths are private, and web user cannot write the application tree.
Stop whenNames already belong to another service, source/runtime data would be overwritten, or parent traversal/access policy is unknown.
If this step fails
The user exists but has a login shell or another primary group.
Likely causeThe name was reused or provisioned manually.
getent passwd billingid billingnamei -l /srv/billing
ResolutionChoose a unique managed identity or reconcile it through approved account management before assigning files.
Security notes
- Never reuse the nginx/Apache identity or grant the application sudo/login access.
Alternatives
- Provision the same identity and paths declaratively through system management.
Stop conditions
- Names already belong to another service, source/runtime data would be overwritten, or parent traversal/access policy is unknown.
config
Create the pool configuration
Define a complete unique pool and Unix socket instead of editing the packaged www pool.
Why this step matters
One complete root-owned pool file makes identity, socket, capacity, runtime and observability policy reviewable as a unit.
What to understand
Replace all placeholders before save and compare against current versioned FPM directive support.
The file is 0640 root-owned; workers consume it but cannot modify their own execution policy.
The sample uses ondemand and eight children only as a bounded starting point from the previous calculation.
System changes
- Creates `/etc/php/{{phpVersion}}/fpm/pool.d/{{app}}.conf` with the displayed structured configuration.
Syntax explained
listen- Names the unique local FastCGI socket.
pm = ondemand- Starts workers only on demand.
clear_env = yes- Clears arbitrary inherited variables.
security.limit_extensions = .php- Restricts accepted script suffixes.
php_admin_*- Applies pool settings that scripts cannot override.
/etc/php/{{phpVersion}}/fpm/pool.d/{{app}}.confValues stay on this page and are never sent or saved.
[{{app}}]
user = {{appUser}}
group = {{appGroup}}
listen = /run/php/php{{phpVersion}}-{{app}}.sock
listen.owner = {{appUser}}
listen.group = {{webGroup}}
listen.mode = 0660
pm = ondemand
pm.max_children = 8
pm.process_idle_timeout = 10s
pm.max_requests = 500
pm.status_path = /fpm-{{app}}-status
ping.path = /fpm-{{app}}-ping
ping.response = pong
request_slowlog_timeout = 5s
slowlog = /srv/{{app}}/var/log/php-fpm-slow.log
catch_workers_output = yes
clear_env = yes
security.limit_extensions = .php
php_admin_flag[log_errors] = on
php_admin_flag[display_errors] = off
php_admin_value[error_log] = /srv/{{app}}/var/log/php-error.log
php_admin_value[upload_tmp_dir] = /srv/{{app}}/tmp
php_admin_value[session.save_path] = /srv/{{app}}/var/sessionsValues stay on this page and are never sent or saved.
sudo install -o root -g root -m 0640 /dev/null /etc/php/{{phpVersion}}/fpm/pool.d/{{app}}.conf; sudoedit /etc/php/{{phpVersion}}/fpm/pool.d/{{app}}.conf[{{app}}], identity, socket, workers, runtime limits, status, slow log, and environment policy are explicit.Checkpoint: Checkpoint: pool
Continue whenThe complete file is root-owned 0640, has no placeholders/duplicates and passes `php-fpm{{phpVersion}} -tt`.
Stop whenAny path/identity/socket is unverified, directive conflicts with global policy, or configuration test reports a warning/error.
If this step fails
The configuration test reports an unknown or duplicate directive.
Likely causePHP version differs, the file is included twice, or an option was copied incorrectly.
sudo php-fpm8.3 -ttgrep -R -n '^\[billing\]' /etc/php/8.3/fpm/pool.d
ResolutionUse official docs for the installed branch, remove only the unintended duplicate and retest.
Security notes
- Pool configuration controls executable identity and writable paths; workers must not own it.
Alternatives
- Render the same file from a reviewed configuration-management template.
Stop conditions
- Any path/identity/socket is unverified, directive conflicts with global policy, or configuration test reports a warning/error.
config
Constrain socket access
Set listen.owner, listen.group, and mode 0660 so only the intended web-server group can submit FastCGI requests.
Why this step matters
The socket is the local authorization point between web server and application runtime; owner/group/mode must be explicit and verified after service creation.
What to understand
Owner `{{appUser}}`, group `{{webGroup}}` and 0660 let the web worker connect without world access.
Check the actual web service group and MAC policy rather than assuming `www-data` on every distribution.
System changes
- No new file beyond the pool policy; verifies socket directives before service reload.
Syntax explained
listen.owner- Sets socket owner when FPM creates it.
listen.group- Grants the reviewed web group connect access.
listen.mode = 0660- Denies all access to other users.
Values stay on this page and are never sent or saved.
sudo grep -E '^(listen|listen\.(owner|group|mode))' /etc/php/{{phpVersion}}/fpm/pool.d/{{app}}.confSocket path and 0660 ownership are explicit.
Checkpoint: Checkpoint: socket
Continue whenExactly one unique socket path and intended owner/group/mode are configured, with no TCP wildcard listener.
Stop whenWeb group is wrong/shared too broadly, socket collides, parent runtime directory is unsafe, or MAC policy is unresolved.
If this step fails
Web server cannot connect despite 0660.
Likely causeIts actual group differs, supplementary group is stale, parent/MAC policy blocks, or it targets another path.
ps -o user,group,cmd -C nginx -C apache2namei -l /run/php/php8.3-billing.socksudo ausearch -m AVC -ts recent
ResolutionCorrect the exact handler/group/path/policy mismatch; never make the socket world-writable.
Security notes
- Socket connect permission is equivalent to submitting PHP execution requests to this pool.
Alternatives
- Use an ACL or loopback namespaced listener when group-based Unix socket access cannot express the boundary.
Stop conditions
- Web group is wrong/shared too broadly, socket collides, parent runtime directory is unsafe, or MAC policy is unresolved.
config
Set an evidence-based process policy
Choose dynamic or ondemand mode, bound children and idle time, and recycle workers with pm.max_requests.
Why this step matters
Process policy converts measured memory/concurrency into predictable capacity and recycles long-lived workers without hiding application leaks.
What to understand
Ondemand saves idle memory but adds cold-start latency; dynamic mode may suit steady workloads.
A hard child ceiling causes queuing rather than unbounded memory growth; monitor saturation and adjust from evidence.
System changes
- No separate mutation; verifies the process directives stored in the pool file.
Syntax explained
pm.max_children- Hard concurrent worker/request limit.
pm.process_idle_timeout- Removes idle ondemand workers after the interval.
pm.max_requests- Recycles each worker after a bounded request count.
Values stay on this page and are never sent or saved.
sudo grep -E '^(pm|pm\.)' /etc/php/{{phpVersion}}/fpm/pool.d/{{app}}.confpm mode, max_children, spare limits or idle timeout, and max_requests are set.
Checkpoint: Checkpoint: workers
Continue whenMode, ceiling, idle timeout and recycle count match the documented capacity calculation and load test.
Stop whenThe ceiling is copied without memory math, load test exceeds latency/SLO, or workers trigger swap/OOM.
If this step fails
`server reached pm.max_children` appears.
Likely causeConcurrency exceeds the ceiling or requests/dependencies are too slow.
journalctl -u php8.3-fpm --since '-30 min' | grep -F max_childrenps -o rss,pid,etime,cmd -C php-fpm8.3
ResolutionFind blocked/slow work first, then recalculate and load-test any bounded increase.
Security notes
- Capacity bounds protect host availability from traffic bursts and expensive requests.
Alternatives
- Use dynamic mode with explicit min/max spare servers after measuring stable traffic.
Stop conditions
- The ceiling is copied without memory math, load test exceeds latency/SLO, or workers trigger swap/OOM.
config
Apply pool-scoped runtime limits
Set upload, execution, error logging, session, and temporary paths inside the pool without exposing secrets through clear_env=no.
Why this step matters
Pool-scoped administrative values keep errors, sessions and temporary uploads private and prevent application code from weakening critical settings.
What to understand
Use paths outside the public root and owned only by the application identity.
Keep `display_errors` off and `log_errors` on; logs can contain secrets and require retention/access controls.
System changes
- No separate file; verifies pool-scoped PHP administrative values in the root-owned pool configuration.
Syntax explained
php_admin_flag- Sets a boolean that scripts cannot override.
php_admin_value- Sets a pool value that scripts cannot override.
upload_tmp_dir/session.save_path- Places writable transient data outside the document root.
Values stay on this page and are never sent or saved.
sudo grep -E '^(php_admin_|php_value|env\[)' /etc/php/{{phpVersion}}/fpm/pool.d/{{app}}.confPool overrides point to application-owned private paths.
Checkpoint: Checkpoint: runtime
Continue whenPrivate paths exist/writable only as intended, errors are logged not displayed, and no secrets are imported via environment.
Stop whenRuntime path is public/shared/symlinked unexpectedly, worker cannot write it, or log/session privacy policy is absent.
If this step fails
Uploads or sessions fail after canary cutover.
Likely causePrivate path ownership, mode, capacity, confinement or cleanup policy is wrong.
namei -l /srv/billing/tmp /srv/billing/var/sessionsdf -h /srv/billingsudo -u billing test -w /srv/billing/tmp
ResolutionFix the narrow path/capacity/policy and retest; do not fall back to `/tmp` or document-root writes blindly.
Security notes
- Sessions, uploads and logs are sensitive attacker-influenced data; isolate and never execute them.
Alternatives
- Use an application-supported external session store while keeping upload/error paths private.
Stop conditions
- Runtime path is public/shared/symlinked unexpectedly, worker cannot write it, or log/session privacy policy is absent.
config
Enable a restricted status endpoint and slow log
Verify pm.status_path, ping.path, request_slowlog_timeout, slowlog, and catch_workers_output; expose status only through a localhost or authenticated management location.
Why this step matters
Pool-specific status, ping and slow logs distinguish capacity from application/dependency failures, but their contents are too sensitive for a public route.
What to understand
Expose status/ping only through a localhost, mTLS or strongly authenticated management location that forwards to this exact pool.
Slow logs capture stack/path details after five seconds; set retention and generate a controlled fixture to prove the path.
System changes
- Verifies observability directives and application-owned log directory; web-server exposure is a separate restricted configuration.
Syntax explained
pm.status_path- Defines the internal FastCGI status URI.
ping.path/ping.response- Defines a lightweight pool liveness response.
request_slowlog_timeout- Starts a worker backtrace after the threshold.
catch_workers_output- Redirects worker stdout/stderr into FPM logging.
Values stay on this page and are never sent or saved.
sudo grep -E '^(pm.status_path|ping.path|request_slowlog_timeout|slowlog|catch_workers_output)' /etc/php/{{phpVersion}}/fpm/pool.d/{{app}}.conf; sudo namei -l /srv/{{app}}/var/logStatus, ping, slowlog, worker output, and protected application-owned log paths are present.
Checkpoint: Checkpoint: observability
Continue whenPaths are configured, logs are writable/protected, local status/ping works and public unauthenticated access is rejected.
Stop whenStatus is public, log path is public/unprotected, backtraces violate policy, or test traffic cannot be isolated.
If this step fails
Status returns 404 while application PHP works.
Likely causeWeb server did not route the status URI to this pool or passed the wrong SCRIPT_NAME.
grep -R -n 'fpm-billing-status' /etc/nginx /etc/apache2sudo php-fpm8.3 -tt
ResolutionCreate a local-only exact FastCGI location and verify both positive local and negative public tests.
Security notes
- Status and slow logs reveal capacity, paths and code flow; restrict, redact and retain them deliberately.
Alternatives
- Collect equivalent pool metrics through a local exporter without exposing an HTTP route.
Stop conditions
- Status is public, log path is public/unprotected, backtraces violate policy, or test traffic cannot be isolated.
verification
Validate, reload, and inspect the pool
Run the versioned FPM configuration test before reload, then confirm the socket and child identity.
Why this step matters
The final gate must prove parser, graceful reload, socket creation and worker identity before a user-facing canary can be trusted.
What to understand
Run the versioned FPM test before reload; a failed left side must prevent mutation.
Inspect actual socket metadata and trigger a canary request so an ondemand worker exists for identity verification.
System changes
- Gracefully reloads the versioned FPM service and activates the new pool/socket.
Syntax explained
php-fpm{{phpVersion}} -tt- Tests and dumps the effective versioned configuration.
systemctl reload- Requests a graceful configuration reload.
ss -lxnp- Proves the configured socket is listening.
ps -eo user,group- Proves worker Unix identity after a canary request.
Values stay on this page and are never sent or saved.
sudo php-fpm{{phpVersion}} -tt; sudo systemctl reload php{{phpVersion}}-fpm; sudo ss -lxnp | grep '{{app}}'; ps -eo user,group,pid,cmd | grep '[p]hp-fpm: pool {{app}}'Syntax succeeds, the socket listens, and workers use the application identity.
Checkpoint: Checkpoint: validate
Continue whenSyntax is clean, service stays active, socket metadata matches policy, worker runs as app identity, and final HTTP/isolation checks pass.
Stop whenAny parser/service/socket/identity/health/write-boundary check fails or the prior handler cannot be restored.
If this step fails
Reload reports success but no worker appears.
Likely causeOndemand mode has not received a request, the handler points elsewhere, or the pool was not included.
sudo php-fpm8.3 -ttcurl -vk https://billing.example.com/health.phpps -eo user,group,pid,cmd | grep '[p]hp-fpm: pool billing'
ResolutionGenerate the controlled canary through the exact handler, confirm inclusion and stop if it selects another pool.
Security notes
- Verify negative write/public-status tests before accepting the boundary.
Alternatives
- Use direct `cgi-fcgi` socket testing before changing the public handler.
Stop conditions
- Any parser/service/socket/identity/health/write-boundary check fails or the prior handler cannot be restored.
Finish line
Verification checklist
curl --fail --silent https://{{domain}}/health.phpThe application endpoint returns its expected health response.sudo -u www-data test ! -w /srv/{{app}} && sudo -u {{appUser}} test -w /srv/{{app}}/varThe web-server account cannot write the application tree, while the pool account can write only runtime storage.Recovery guidance
Common problems and safe checks
The web server returns 502/503 while FPM is active.
Likely causeHandler socket differs, web group lacks connect permission, no worker can start, or MAC/path policy denies access.
sudo ss -lxnp | grep php8.3-billingnamei -l /run/php/php8.3-billing.sock /srv/billing/publicsudo journalctl -u php8.3-fpm -n 150 --no-pagersudo ausearch -m AVC -ts recent
ResolutionCorrect the exact socket/group/path/policy mismatch and retest; never broaden all filesystem permissions.
Requests queue or time out under load.
Likely causeAll children are busy, workers are too slow, backend dependencies block, or memory pressure causes reclaim/OOM.
systemctl show php8.3-fpm -p MemoryCurrentps -o rss,pid,etime,cmd -C php-fpm8.3journalctl -k --since '-30 min' | grep -i oomcurl --silent http://127.0.0.1/fpm-billing-status?full
ResolutionFix blocking work first, then recalculate children from measured RSS/headroom and load-test a bounded increase.
FPM starts but status or slow logs remain empty.
Likely causeThe web server does not route the internal status path, the threshold was not crossed, or worker identity cannot write the log path.
grep -E '^(pm.status_path|request_slowlog_timeout|slowlog)' /etc/php/8.3/fpm/pool.d/billing.confnamei -l /srv/billing/var/log
ResolutionAdd a local-only FastCGI status location and fix the narrow application log path; use a controlled slow fixture.
Reference
Frequently asked questions
Why not run FPM as `www-data`?
That collapses the web parser and application execution identities, allowing one compromise to gain broader write/read reach.
Is eight children a recommendation?
No. It is a visible bounded example. Replace it using measured per-worker RSS, reserved host memory and concurrency targets.
Should the socket be owned by the web server?
The FPM master creates it. This guide uses the app user as owner and web group for connect access, keeping application ownership separate.
Recovery
Rollback
Restore the prior handler and remove the dedicated pool only after traffic is drained.
- Point the virtual host back to the recorded prior socket, validate, and reload the web server.
- Disable the pool by renaming its .conf file, validate FPM, and reload.
- Preserve application logs and do not delete the Unix account until ownership dependencies are audited.
Evidence