Safeguard
Container Security

Hardening PHP-FPM and Apache Container Images

PHP containers ship with defaults built for compatibility, not security. Opcache settings, disabled functions, and process ownership close the gaps.

Safeguard Research Team
Research
6 min read

The official php Docker Hub image ships opcache.validate_timestamps enabled by default, disable_functions empty, and PHP-FPM running as root unless you override it — three defaults chosen for developer convenience that quietly widen the attack surface of anything shipped to production. None of these are secrets; they are documented behaviors that most Dockerfiles never touch after FROM php:8.3-fpm. The cost of leaving them alone is concrete: CVE-2024-4577, a critical PHP-CGI argument-injection flaw affecting Apache and PHP-CGI in "Best-Fit" charset conversion mode, was actively exploited within days of disclosure in mid-2024, and PHP-FPM's own status page carried an XSS bug that wasn't patched until PHP 8.2.31, 8.3.31, 8.4.21, and 8.5.6. A container that trusts every default inherits every one of these exposures by default, too. This post walks through four concrete changes — opcache configuration, disable_functions, worker process management, and user/filesystem posture — that turn a stock PHP image into a hardened one, without requiring a rewrite of the application itself. Each change is small, auditable in a Dockerfile diff, and independent of framework choice, so it applies equally to a Laravel app, a WordPress install, or a bare-metal PHP-FPM service behind Apache or nginx.

Why does opcache.validate_timestamps=0 matter for container security, not just performance?

Opcache is usually pitched as a performance feature — it caches compiled bytecode so PHP doesn't reparse every file on every request — but in a container, one of its settings doubles as a security control. opcache.validate_timestamps defaults to checking each file's modification time before deciding whether to use the cached bytecode or recompile from disk. Container filesystems are supposed to be immutable at runtime: the image is built once, and nothing should be writing new .php files into it afterward. Setting opcache.validate_timestamps=0 removes the per-request disk-stat check entirely, which is a real latency win under load. The security side effect is that if an attacker does manage to write a .php file into a running container — through a file-upload vulnerability, a misconfigured writable volume, or a supply-chain-tainted dependency — that file cannot be executed until the image is rebuilt and opcache is reset, because opcache never re-reads the disk to notice it. Pair this with opcache.revalidate_freq=0 (irrelevant once timestamps are disabled, but worth setting for clarity) and treat any deploy pipeline change to PHP source as a full image rebuild, not a hot file copy into a running pod.

Which PHP functions should disable_functions block, and why?

disable_functions in php.ini is a comma-separated list PHP refuses to call at all — not deprecate, not warn about, simply refuse, throwing a fatal error if invoked. The standard production hardening list, repeated across PHP hosting and security guides, targets the exec family: exec, shell_exec, system, passthru, proc_open, and popen. These functions let PHP spawn arbitrary OS processes, which is exactly the capability an attacker wants after finding a code-injection or deserialization bug — the difference between a contained application-layer bug and a shell on your container. Most web applications never legitimately call these functions; image processing, PDF generation, and queue workers are common exceptions, so audit your codebase (grep -rn for each function name) before blocking wholesale. Add disable_functions entries in a conf.d file baked into the image rather than a runtime environment variable, so the restriction can't be silently unset by a misconfigured orchestrator. This is defense in depth, not a substitute for input validation — it reduces the blast radius of a bug that already exists, it doesn't prevent the bug.

How should PHP-FPM's process manager be configured for a containerized workload?

PHP-FPM's pm directive controls how worker processes are spawned, and the default pm = dynamic was designed for long-lived bare-metal hosts that need to scale workers up and down as traffic fluctuates — a model that doesn't map cleanly onto a container with a fixed memory limit. Setting pm = static with pm.max_children sized against the container's memory ceiling (typical guidance: divide the container's memory limit by the observed per-worker RSS, leaving headroom for the master process) gives you a predictable, capped number of workers instead of FPM trying to spawn new ones under load and getting OOM-killed by the orchestrator mid-request. This matters for security as much as stability: a container that reactively spawns workers based on load is more exposed to slow-request or resource-exhaustion abuse patterns than one with a hard ceiling enforced at the process-manager level. Combine pm = static with request_terminate_timeout set to a sane value (30–60 seconds for most web apps) so a hung or exploited worker doesn't tie up capacity indefinitely, and never expose the FPM status page (pm.status_path) outside an internal network given its XSS history noted above.

Should PHP-FPM run as a non-root user inside the container?

Yes — the official php image explicitly documents support for this, and running as root in production is one of the most common and most avoidable container misconfigurations. Docker Hub's php image lets you run the container as an arbitrary or built-in non-root user, for example docker run --user daemon or a --user <uid>:<gid> pair, and the equivalent should be baked into the Dockerfile with a USER directive rather than left to whoever runs docker run. Running FPM master and worker processes as a dedicated unprivileged user means that if a PHP-level vulnerability is exploited into arbitrary file writes or command execution, the attacker inherits that user's limited permissions rather than root inside the container namespace. Pair this with a read-only root filesystem (docker run --read-only, or the Kubernetes readOnlyRootFilesystem: true security context) and an explicit writable volume only for the specific paths PHP needs — session storage, upload directories, cache — rather than leaving the entire filesystem writable by default. Each control is individually bypassable in isolation; together, non-root plus read-only plus disabled exec functions plus opcache timestamp locking removes several of the easiest post-exploitation paths at once.

How Safeguard Helps

The controls above — opcache settings, disable_functions, process limits, non-root users — live in your Dockerfile and php.ini, and no scanner can write them for you; they're a one-time hardening pass a team does once and then has to remember to preserve across every image rebuild. Where Safeguard fits is the layer above that: once your PHP-FPM base image is hardened, Safeguard's self-healing containers continuously watch it for new CVEs in the underlying OS packages, PHP runtime, and extensions, and automatically plan, rebuild, and promote a patched image when one lands — the same PHP-CGI argument-injection class of bug that produced CVE-2024-4577 is exactly the kind of upstream disclosure that triggers an automated rebuild rather than a manual scramble. Griffin AI can also flag when a hardening control regresses — say, a future Dockerfile change reintroduces opcache.validate_timestamps=1 or drops the USER directive — as part of continuous scanning, so the hardening you do today doesn't quietly erode the next time someone edits the base image.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.