This guide details how to decouple the PHP runtime from your host operating system. By running an up-to-date PHP version inside a Docker container while retaining your native Apache web server for SSL and routing, you can address critical PHP vulnerabilities (like PHP2Shell) without upgrading or replacing your host OS.
This approach is particularly useful when running an outdated host that cannot easily be upgraded or replaced. For example, Debian 11 only provides PHP 7.4, which has not received a patch for this issue. Obviously, the ideal solution would be to migrate away from an EOL OS entirely. However, this may not be immediately feasible, and continuing to run a vulnerable version of PHP would not be a wise decision.
Step 1: Prepare the Host System
First, you must enable the necessary communication modules within your native host Apache installation so it can forward PHP requests to the FastCGI backend.
# Enable the required Apache proxy modules
sudo a2enmod proxy
sudo a2enmod proxy_fcgi
# (Optional) Disable your native old PHP module to free system memory
# Replace "7.4" or "8.0" with your specific native system version
sudo a2dismod php7.4
# Restart Apache to load the changes
sudo systemctl restart apache2
Step 2: Create the Docker Compose Deployment
Make sure you have Docker installed.
We will use Docker Compose to define the isolated environment. This keeps your runtime reproducible and easy to patch.
Create a docker-compose.yaml file in your administrative directory (e.g., /home/docker/php-fpm/):
services:
php-fpm:
image: hermsi/alpine-fpm-php:8.5
container_name: php-fpm
restart: unless-stopped
user: "1000:33"
network_mode: "host"
volumes:
- /var/www:/var/www
Note: Run id -u [username] and getent group www-data on your host system to verify your exact numeric IDs for the user: field.
Modify your volumes: directive to point to your Apache DocumentRoot. Add any additional volumes you may need for additional virtual hosts.
Launch the container in the background:
sudo docker-compose up -d
Note: I have used the deprecated docker-compose command. If you are following this guide, chances are you might have an outdated Docker installation too so this will be necessary. If you are using Docker v23 or newer the command should be docker compose with a space.
Step 3: Configure Host Apache to Forward PHP Requests to PHP-FPM
Update your native Apache VirtualHost configuration file (/etc/apache2/sites-available/your-site.conf) to bridge your front-end domains directly to the containerized socket handler.
By mapping your DocumentRoot volumes in the compose file, Apache and your container share the exact same root directory layouts and the execution paths line up seamlessly:
<VirtualHost *:443>
ServerName yourdomain.com
DocumentRoot /var/www
# ... Your existing SSL configuration rules ...
# The Core Mechanism: Intercept all PHP requests and forward them
# directly to the container listening locally on port 9000
<FilesMatch "\.php$">
SetHandler "proxy:fcgi://127.0.0.1:9000"
</FilesMatch>
<Directory /var/www>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
Reload Apache to enforce the new routing configuration:
sudo systemctl reload apache2
Key Architectural Gotchas & Solutions
The
localhostSocket Trap (Error 2002):
When a PHP application configuration (like Nextcloud or WordPress) uses the stringlocalhostfor its database target, the PHP engine forces a connection via a physical filesystem socket file (mysqld.sock). Since that file doesn't exist inside the container, connections will fail. Solution: Change your application database configuration files fromlocalhostto the explicit network loopback IP127.0.0.1.Database Permissions Matrix:
Ensure your native database server (MariaDB/MySQL) is configured to accept network connections from the loopback interface, and that your database user account has been explicitly granted privileges to authenticate from127.0.0.1via standard TCP networking.The Log Silencing Effect:
By default, containerized PHP applications log their primary system warnings directly to standard output streams (stdout/stderr). If you are searching for execution errors, rundocker compose logs php-fpmordocker logs php-fpmto view your application backtraces in real-time.
Future Upgrades
When a future security patch drops or a new major/minor PHP release launches, updating your entire environment is simple. The php version tag used in docker-compose.yaml is set to :8.5. Pulling the image from dockerhub will automatically fetch the latest patch release (currently 8.5.12). If you want to use a newer version just change this tag to :8.6 or eventually :9.0 when they become available. Be sure to check for any compatibility issues with your hosted applications first.
sudo docker-compose pull && \
sudo docker-compose up -d
Top comments (15)
One thing worth flagging:
network_mode: "host"means the container shares the host network namespace, so the isolation win here is mostly the UID split and getting PHP patched independently of the EOL OS, not network separation. Still a real win, just a narrower one than "isolated" suggests.If the app does not need to write into the docroot, mounting
/var/wwwread-only for the FPM container closes most of what is left after a shell lands. The writable uploads dir stays the hole either way.This guide is meant to peovide a drop-in replacement for an outdated php installation. Using network_mode:host is no less secure than the typical mod_php setup that it's intended to replace. I'ts definitely no substitute for a properly isolated, fully containerized app.
This 'untrusted runtime in a thin container, trusted host on the edge' pattern is the same shape we use in desktop: keep the privileged orchestrator (your Apache) small and patched, and push the untrusted code path (PHP, or in our case a third-party plugin renderer) into a constrained process where a single RCE only owns that sandbox. The one extra knob worth borrowing for a desktop client is least-privilege filesystem exposure — bind-mounting the whole /var/www read-write means a PHP bug still reads/writes every vhost's files; we default-mount only the directory the user actually picked, and watch for writes outside that root.
That is a very important point. Especially if your vhosts repesent data belonging to different clients/users. Isolating each vhost into it's own container with access only to it's own files would be a much better approach for that scenario.
This is a pragmatic approach — instead of fighting the EOL host, you shrink what it can do to you by moving execution into the container and leaving Apache to do what it is good at. The localhost socket trap is the detail I would have tripped over: it is exactly the kind of failure that looks like a database outage until you realize the socket file simply is not inside the container. One thing I would add: since the :8.5 tag floats to the latest patch, pinning the image by digest as well would make those future upgrades deliberate rather than something a pull does for you. Thanks for writing up the gotchas, not just the happy path.
Personally I think using the minor version tag is the best method for the average setup. This ensures that you always have the latest security updates for your current release. If you encounter issues with a specific patch you can easily pull and pin the last digest that worked for your use-case.
One correction worth having before anyone follows this, because it is the difference between "isolated" and "patched".
hermsi/alpine-fpm-php:8.5, as it resolves today, is built fromphp-8.5.12.tar.xz(PHP_VERSION=8.5.12in the image config, build dated 7 Nov). 8.5.12 is still the newest release, from 21 Oct. The fix for CVE-2026-115560 has not shipped: PHP's 23 Oct note says the full advisory arrives with the next regular bugfix release, and until then the standing mitigation issession.upload_progress.enabled=0.So the container buys isolation and a maintainable runtime, not a patched one. And that ini line has to be added inside it (
/usr/local/etc/php/conf.d/), because the host'sphp.ininever reaches the container. Carry an old host config across and you carry the exposure with it.Scope, from the advisory itself, so nobody over-reacts: it needs an app that sets
session.serialize_handlertophp_binaryorphp_serializeand then switches it tophpat runtime. Most never do.Last bit: pin the digest. The day the fix lands,
pullworks either way, and nothing in your compose file will tell you which build you got.Excellent points. The setup described in the guide does not provide full isolation. It would definitely be wise for those new to Docker to explore how this could be hardened for greater security. As written though, it does help significantly reduce your exposure to this vulnerability and makes it possible for an EOL host to benefit from current and future security fixes.
Hardening an EOL host is a race against the next unknown exposure, so compensating controls should be explicit about their limits. I’d prioritize network segmentation, immutable backups tested for restore, minimal service accounts, and high-signal process or egress telemetry. Those measures buy time, but the migration date still needs an owner and a measurable exit plan.
Absolutely, agree. Maintaining an EOL host is definitely not an ideal situation to be in. Devising a migration plan should be a top priority for anyone dealing with a system like this. Thankfully Docker exists and makes it possible to containerize your legacy architecture.
Good write-up on the decoupling pattern, but I have to flag the elephant in the room: network_mode: "host" quietly undoes a big chunk of the isolation you're describing.
The whole argument for moving PHP into a container is that a compromised runtime is bounded by the container. With host networking, that boundary mostly disappears — the container shares the host's network namespace, so a PHP RCE can bind or connect to anything the host can reach: your database, your Redis, SSH on 22, whatever else is listening. That's a meaningfully worse blast radius than a normal container, and it's exactly the scenario you're trying to defend against.
You can keep the setup simple without it. Bind-mount a UNIX socket into the container and have Apache proxy to that instead:
services:
php-fpm:
image: hermsi/alpine-fpm-php:8.5
volumes:
- /var/www:/var/www
- /run/php-fpm:/run/php-fpm
Then in Apache: SetHandler "proxy:unix:/run/php-fpm/sock|fcgi://localhost"
The socket lives in a directory only Apache and the container can reach, so PHP-FPM is still reachable for legitimate requests but not exposed on any interface. If you'd rather stay on TCP, publish it explicitly on loopback only — ports: ["127.0.0.1:9000:9000"] — which at least keeps it off every other interface on the host.
A couple of other things worth tightening while you're in there:
Your Apache block has Options Indexes, which means directory listings for any folder without an index file. On a public-facing host that's a small information leak you probably don't want.
Require all granted on the document root is fine if your app handles auth, but combined with Indexes it means anyone can browse unindexed paths.
The EOL-host reality is real and I don't envy anyone running Debian 11 today. But the container is the one part of this that you can actually get right cheaply, so it's worth not giving away the boundary you're paying for.
The purpose of this guide is to replace an outdated PHP runtime with a patched version on a host that cannot easily be upgraded. It is not intended to provide additional isolation beyond the original native PHP deployment. Host networking is a deliberate compatibility choice that preserves access to existing services without requiring additional infrastructure changes. The UNIX socket approach is certainly valid, but it introduces additional configuration requirements that fall outside the scope of this particular solution.
Good catch on Options Indexes, though. I will change that.
Fair enough — if the explicit goal is zero infrastructure changes and you just need a patched PHP binary to sit behind the existing Apache setup, host networking is the path of least friction.
The only thing I'd still push back on slightly is the blast radius. Since the specific vulnerability is an RCE (PHP2Shell), the moment someone exploits it, they inherit the host's network namespace. It’s not about expecting the container to provide full isolation; it’s that host networking removes the accidental network boundaries Docker usually provides as a side effect.
A quick note in the README warning about that specific trade-off might be worth it for anyone blindly copy-pasting this into a production environment. Glad you're fixing the Indexes issue, though — that's a real easy one to miss!
A practical and well-structured guide that addresses a real-world problem many administrators face when maintaining legacy infrastructure. I particularly appreciate the attention to the less obvious pitfalls, especially the localhost versus 127.0.0.1 database connection issue. One important consideration, though, is that containerization alone doesn't guarantee host isolation. The use of network_mode: host deserves extra scrutiny because it reduces network separation, and shared volumes can still expose sensitive host files. This is a useful mitigation strategy, but it shouldn't become a substitute for planning an eventual OS upgrade. Great job highlighting both the practical solution and its limitations!
This guide definitely stemmed from a real-world problem that I had to resolve. And the result, while not perfect isolation, is much more secure than when I started. I wouldn't use network_mode:host for a self-contained app with all it's services containerized, but for this use-case of globally overriding the system's native php install it's the optimal choice.
As for exposing sensitive host files, it really makes no difference if your DocumentRoot lives inside an isolated volume or directly on the host filesystem. In each of these scenarios the files in question are equally exposed. If you really want to protect these files from rogue modifications, mount the volume read-only.